feat: add admin config diagnostics

This commit is contained in:
Lan
2026-06-03 08:01:55 +08:00
parent 5c3eb2d144
commit 6c3fe3a55f
6 changed files with 612 additions and 13 deletions
+159 -4
View File
@@ -2,7 +2,13 @@ import { ref, computed } from 'vue'
import { ConfigService } from '@/services' import { ConfigService } from '@/services'
import { useAlertStore } from '@/stores/alertStore' import { useAlertStore } from '@/stores/alertStore'
import { useConfigStore } from '@/stores/configStore' import { useConfigStore } from '@/stores/configStore'
import type { ConfigState } from '@/types' import type {
ConfigDiagnosticItem,
ConfigDiagnosticsResponse,
ConfigDiagnosticSeverity,
ConfigDiagnosticSummary,
ConfigState
} from '@/types'
import { DEFAULT_CONFIG_STATE, readStoredConfig } from '@/utils/config-storage' import { DEFAULT_CONFIG_STATE, readStoredConfig } from '@/utils/config-storage'
import { getErrorMessage } from '@/utils/common' import { getErrorMessage } from '@/utils/common'
import { import {
@@ -15,6 +21,122 @@ import {
type ConfigFlagKey = 'enableChunk' | 's3_proxy' | 'openUpload' type ConfigFlagKey = 'enableChunk' | 's3_proxy' | 'openUpload'
type NormalizedConfigDiagnostics = {
items: ConfigDiagnosticItem[]
diagnosticItems: ConfigDiagnosticItem[]
diagnostic_items: ConfigDiagnosticItem[]
summary: ConfigDiagnosticSummary
diagnosticSummary: ConfigDiagnosticSummary
diagnostic_summary: ConfigDiagnosticSummary
}
type ConfigDiagnosticsSource =
| (Partial<ConfigState> &
ConfigDiagnosticsResponse & {
diagnostics?: ConfigDiagnosticsResponse
})
| null
| undefined
const diagnosticSeverities: ConfigDiagnosticSeverity[] = ['danger', 'warning', 'success', 'neutral']
const emptyDiagnosticSummary = (): ConfigDiagnosticSummary => ({
total: 0,
dangerCount: 0,
warningCount: 0,
successCount: 0,
neutralCount: 0,
strongestSeverity: 'success'
})
const emptyConfigDiagnostics = (): NormalizedConfigDiagnostics => {
const items: ConfigDiagnosticItem[] = []
const summary = emptyDiagnosticSummary()
return {
items,
diagnosticItems: items,
diagnostic_items: items,
summary,
diagnosticSummary: summary,
diagnostic_summary: summary
}
}
const normalizeDiagnosticSeverity = (severity: string | undefined): ConfigDiagnosticSeverity =>
diagnosticSeverities.includes(severity as ConfigDiagnosticSeverity)
? (severity as ConfigDiagnosticSeverity)
: 'neutral'
const normalizeConfigDiagnostics = (
value: ConfigDiagnosticsSource
): NormalizedConfigDiagnostics => {
const diagnostics = value?.diagnostics
const items = (
diagnostics?.items ??
diagnostics?.diagnosticItems ??
diagnostics?.diagnostic_items ??
value?.items ??
value?.diagnosticItems ??
value?.diagnostic_items ??
[]
)
.filter((item) => item && item.key)
.map((item) => ({
...item,
count: Number(item.count || 0),
priority: Number(item.priority || 0),
severity: normalizeDiagnosticSeverity(item.severity),
fields: item.fields || (item.field ? [String(item.field)] : [])
}))
const rawSummary =
diagnostics?.summary ??
diagnostics?.diagnosticSummary ??
diagnostics?.diagnostic_summary ??
value?.summary ??
value?.diagnosticSummary ??
value?.diagnostic_summary ??
{}
const summary: ConfigDiagnosticSummary = {
total: Number(rawSummary.total ?? items.length),
dangerCount: Number(rawSummary.dangerCount ?? rawSummary.danger_count ?? 0),
danger_count: Number(rawSummary.dangerCount ?? rawSummary.danger_count ?? 0),
warningCount: Number(rawSummary.warningCount ?? rawSummary.warning_count ?? 0),
warning_count: Number(rawSummary.warningCount ?? rawSummary.warning_count ?? 0),
successCount: Number(rawSummary.successCount ?? rawSummary.success_count ?? 0),
success_count: Number(rawSummary.successCount ?? rawSummary.success_count ?? 0),
neutralCount: Number(rawSummary.neutralCount ?? rawSummary.neutral_count ?? 0),
neutral_count: Number(rawSummary.neutralCount ?? rawSummary.neutral_count ?? 0),
strongestSeverity: normalizeDiagnosticSeverity(
rawSummary.strongestSeverity ?? rawSummary.strongest_severity
),
strongest_severity: normalizeDiagnosticSeverity(
rawSummary.strongestSeverity ?? rawSummary.strongest_severity
)
}
return {
items,
diagnosticItems: items,
diagnostic_items: items,
summary,
diagnosticSummary: summary,
diagnostic_summary: summary
}
}
const stripDiagnosticFields = (nextConfig: Partial<ConfigState>): Partial<ConfigState> => {
const {
diagnostics: _diagnostics,
diagnosticItems: _diagnosticItems,
diagnostic_items: _diagnosticItemsSnake,
diagnosticSummary: _diagnosticSummary,
diagnostic_summary: _diagnosticSummarySnake,
...editableConfig
} = nextConfig
return editableConfig
}
export function useSystemConfig() { export function useSystemConfig() {
const alertStore = useAlertStore() const alertStore = useAlertStore()
const configStore = useConfigStore() const configStore = useConfigStore()
@@ -23,7 +145,9 @@ export function useSystemConfig() {
const config = ref<ConfigState>({ ...DEFAULT_CONFIG_STATE }) const config = ref<ConfigState>({ ...DEFAULT_CONFIG_STATE })
const isRefreshing = ref(false) const isRefreshing = ref(false)
const isSaving = ref(false) const isSaving = ref(false)
const isDiagnosticsRefreshing = ref(false)
const savedPayloadSnapshot = ref('') const savedPayloadSnapshot = ref('')
const configDiagnostics = ref<NormalizedConfigDiagnostics>(emptyConfigDiagnostics())
const fileSize = ref(1) const fileSize = ref(1)
const sizeUnit = ref<FileSizeUnit>('MB') const sizeUnit = ref<FileSizeUnit>('MB')
const saveTime = ref(1) const saveTime = ref(1)
@@ -47,11 +171,14 @@ export function useSystemConfig() {
const snapshotPayload = (payload: Partial<ConfigState>) => JSON.stringify(payload) const snapshotPayload = (payload: Partial<ConfigState>) => JSON.stringify(payload)
const normalizeEditableConfig = (nextConfig: Partial<ConfigState>): ConfigState => ({ const normalizeEditableConfig = (nextConfig: Partial<ConfigState>): ConfigState => {
const editableConfig = stripDiagnosticFields(nextConfig)
return {
...DEFAULT_CONFIG_STATE, ...DEFAULT_CONFIG_STATE,
...nextConfig, ...editableConfig,
admin_token: '' admin_token: ''
}) }
}
const markConfigSaved = () => { const markConfigSaved = () => {
savedPayloadSnapshot.value = snapshotPayload(buildSubmitPayload()) savedPayloadSnapshot.value = snapshotPayload(buildSubmitPayload())
@@ -76,6 +203,24 @@ export function useSystemConfig() {
configStore.updateConfig(configData) configStore.updateConfig(configData)
} }
const refreshDiagnostics = async (showError = true) => {
try {
isDiagnosticsRefreshing.value = true
const response = await ConfigService.getDiagnostics()
if (response.code === 200 && response.detail) {
configDiagnostics.value = normalizeConfigDiagnostics(response.detail)
} else {
throw new Error(response.message || '获取配置诊断失败')
}
} catch (error) {
if (showError) {
alertStore.showAlert(getErrorMessage(error, '获取配置诊断失败'), 'error')
}
} finally {
isDiagnosticsRefreshing.value = false
}
}
// 获取系统配置 // 获取系统配置
const fetchConfig = async (): Promise<ConfigState | null> => { const fetchConfig = async (): Promise<ConfigState | null> => {
try { try {
@@ -84,6 +229,7 @@ export function useSystemConfig() {
const response = await ConfigService.getConfig() const response = await ConfigService.getConfig()
if (response.code === 200 && response.detail) { if (response.code === 200 && response.detail) {
configDiagnostics.value = normalizeConfigDiagnostics(response.detail)
config.value = normalizeEditableConfig(response.detail) config.value = normalizeEditableConfig(response.detail)
const notifyMessage = configStore.applyRemoteConfig(config.value) const notifyMessage = configStore.applyRemoteConfig(config.value)
if (notifyMessage) { if (notifyMessage) {
@@ -99,6 +245,7 @@ export function useSystemConfig() {
const storedConfig = getStoredConfig() const storedConfig = getStoredConfig()
if (storedConfig) { if (storedConfig) {
config.value = storedConfig config.value = storedConfig
configDiagnostics.value = emptyConfigDiagnostics()
return config.value return config.value
} }
@@ -121,6 +268,7 @@ export function useSystemConfig() {
syncConfigForm(config.value) syncConfigForm(config.value)
markConfigSaved() markConfigSaved()
saveConfigToStorage(config.value) saveConfigToStorage(config.value)
await refreshDiagnostics(false)
alertStore.showAlert('配置更新成功!', 'success') alertStore.showAlert('配置更新成功!', 'success')
return true return true
} else { } else {
@@ -183,6 +331,9 @@ export function useSystemConfig() {
return Math.round(config.value.uploadSize / 1024 / 1024) return Math.round(config.value.uploadSize / 1024 / 1024)
}) })
const configDiagnosticItems = computed(() => configDiagnostics.value.items)
const configDiagnosticSummary = computed(() => configDiagnostics.value.summary)
const isConfigLoaded = computed(() => { const isConfigLoaded = computed(() => {
return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value
}) })
@@ -193,7 +344,10 @@ export function useSystemConfig() {
isLoading, isLoading,
isRefreshing, isRefreshing,
isSaving, isSaving,
isDiagnosticsRefreshing,
isDirty, isDirty,
configDiagnosticItems,
configDiagnosticSummary,
fileSize, fileSize,
sizeUnit, sizeUnit,
saveTime, saveTime,
@@ -207,6 +361,7 @@ export function useSystemConfig() {
fetchConfig, fetchConfig,
updateConfig, updateConfig,
refreshConfig, refreshConfig,
refreshDiagnostics,
submitConfig, submitConfig,
toggleConfigFlag, toggleConfigFlag,
initConfig, initConfig,
+60 -1
View File
@@ -822,7 +822,66 @@ export default {
saving: 'Saving', saving: 'Saving',
unsavedChanges: 'Unsaved configuration changes', unsavedChanges: 'Unsaved configuration changes',
allChangesSaved: 'All configuration changes are saved', allChangesSaved: 'All configuration changes are saved',
refreshBlocked: 'Save current changes before refreshing' refreshBlocked: 'Save current changes before refreshing',
diagnosticsTitle: 'Configuration Checkup',
diagnosticsDesc:
'Automatically checks security, storage, upload, and retention policies. Click a suggestion to jump to the related field.',
diagnosticsRefresh: 'Refresh checkup',
diagnosticsTotal: 'Suggestions',
diagnosticsDanger: 'High risk',
diagnosticsWarning: 'Action needed',
diagnosticsFocusAction: 'Locate setting',
diagnosticSeverity: {
danger: 'High risk',
warning: 'Action needed',
success: 'Stable',
neutral: 'Suggestion'
},
diagnosticsItems: {
default_admin_password: {
title: 'Default admin password is still in use',
description:
'The default admin password is a security risk. Set a strong password as soon as possible.'
},
s3_incomplete: {
title: 'S3 configuration is incomplete',
description: 'S3 is missing {count} fields. Complete them before enabling object storage.'
},
webdav_incomplete: {
title: 'WebDAV configuration is incomplete',
description:
'WebDAV is missing {count} fields. Complete them before enabling remote storage.'
},
guest_upload_retention: {
title: 'Guest uploads have no retention limit',
description:
'Guest upload is enabled with unlimited retention. Set a default retention window to reduce storage pressure.'
},
chunking_recommended: {
title: 'Chunked upload is recommended',
description:
'Enable chunked upload for large files to improve upload stability and recovery after failures.'
},
upload_guard_disabled: {
title: 'Upload rate limiting is disabled',
description:
'Upload count limits are not active. Set an upload window and maximum upload count.'
},
access_guard_disabled: {
title: 'Access protection is disabled',
description:
'Error access protection is not active. Set a detection window and maximum error count.'
},
expiration_style_empty: {
title: 'Expiration method is not configured',
description:
'Choose a default expiration method so uploaded files are retained or expired as expected.'
},
healthy: {
title: 'Configuration looks stable',
description: 'No configuration risks need priority attention right now.'
}
}
}, },
systemSettings: { systemSettings: {
title: 'System Settings', title: 'System Settings',
+52 -1
View File
@@ -842,7 +842,58 @@ export default {
saving: '保存中', saving: '保存中',
unsavedChanges: '有未保存的配置变更', unsavedChanges: '有未保存的配置变更',
allChangesSaved: '所有配置已保存', allChangesSaved: '所有配置已保存',
refreshBlocked: '请先保存当前变更再刷新' refreshBlocked: '请先保存当前变更再刷新',
diagnosticsTitle: '配置体检',
diagnosticsDesc: '自动检查安全、存储、上传和保留策略,点击建议可定位到相关配置。',
diagnosticsRefresh: '刷新体检',
diagnosticsTotal: '建议项',
diagnosticsDanger: '高风险',
diagnosticsWarning: '需处理',
diagnosticsFocusAction: '定位配置',
diagnosticSeverity: {
danger: '高风险',
warning: '需处理',
success: '稳定',
neutral: '建议'
},
diagnosticsItems: {
default_admin_password: {
title: '管理员密码仍为默认值',
description: '默认管理员密码存在安全风险,建议立即设置一个强密码。'
},
s3_incomplete: {
title: 'S3 配置不完整',
description: '当前 S3 存储缺少 {count} 项必要配置,补齐后再启用对象存储。'
},
webdav_incomplete: {
title: 'WebDAV 配置不完整',
description: '当前 WebDAV 存储缺少 {count} 项必要配置,补齐后再启用远程存储。'
},
guest_upload_retention: {
title: '游客上传缺少保留上限',
description: '游客上传已开启且保存时间不受限,建议设置默认保留期降低存储压力。'
},
chunking_recommended: {
title: '建议开启分片上传',
description: '大文件上传场景建议启用分片上传,提升上传稳定性和失败恢复能力。'
},
upload_guard_disabled: {
title: '上传限流未启用',
description: '当前上传次数限制未生效,建议设置上传窗口和次数上限。'
},
access_guard_disabled: {
title: '访问保护未启用',
description: '当前错误访问保护未生效,建议设置检测窗口和错误次数上限。'
},
expiration_style_empty: {
title: '未配置过期方式',
description: '建议选择默认过期方式,确保上传文件按预期保留或失效。'
},
healthy: {
title: '配置状态稳定',
description: '当前未发现需要优先处理的配置风险。'
}
}
}, },
dashboard: { dashboard: {
title: '仪表盘', title: '仪表盘',
+5 -1
View File
@@ -1,5 +1,5 @@
import api from './client' import api from './client'
import type { ApiResponse, ConfigState } from '@/types' import type { ApiResponse, ConfigDiagnosticsResponse, ConfigState } from '@/types'
type PublicConfigEnvelope = { type PublicConfigEnvelope = {
config?: Partial<ConfigState> config?: Partial<ConfigState>
@@ -17,6 +17,10 @@ export class ConfigService {
return api.get('/admin/config/get') return api.get('/admin/config/get')
} }
static async getDiagnostics(): Promise<ApiResponse<ConfigDiagnosticsResponse>> {
return api.get('/admin/config/diagnostics')
}
static async getUserConfig(): Promise<ApiResponse<ConfigState>> { static async getUserConfig(): Promise<ApiResponse<ConfigState>> {
try { try {
const response = (await api.get('/api/v1/config')) as ApiResponse< const response = (await api.get('/api/v1/config')) as ApiResponse<
+52
View File
@@ -15,6 +15,53 @@ export interface ThemeChoice {
version: string version: string
} }
export type ConfigDiagnosticSeverity = 'danger' | 'warning' | 'success' | 'neutral'
export interface ConfigDiagnosticAction {
type?: string
field?: keyof ConfigState | string | null
fields?: string[]
category?: string
}
export interface ConfigDiagnosticItem {
key: string
severity: ConfigDiagnosticSeverity | string
category: string
priority?: number
count?: number
field?: keyof ConfigState | string | null
fields?: string[]
action?: ConfigDiagnosticAction
actionType?: string
action_type?: string
targetField?: keyof ConfigState | string | null
target_field?: keyof ConfigState | string | null
}
export interface ConfigDiagnosticSummary {
total: number
dangerCount: number
danger_count?: number
warningCount: number
warning_count?: number
successCount: number
success_count?: number
neutralCount: number
neutral_count?: number
strongestSeverity: ConfigDiagnosticSeverity
strongest_severity?: ConfigDiagnosticSeverity
}
export interface ConfigDiagnosticsResponse {
items?: ConfigDiagnosticItem[]
diagnosticItems?: ConfigDiagnosticItem[]
diagnostic_items?: ConfigDiagnosticItem[]
summary?: Partial<ConfigDiagnosticSummary>
diagnosticSummary?: Partial<ConfigDiagnosticSummary>
diagnostic_summary?: Partial<ConfigDiagnosticSummary>
}
export interface ConfigState { export interface ConfigState {
name: string name: string
description: string description: string
@@ -52,4 +99,9 @@ export interface ConfigState {
webdav_url: string webdav_url: string
webdav_username: string webdav_username: string
webdav_password: string webdav_password: string
diagnostics?: ConfigDiagnosticsResponse
diagnosticItems?: ConfigDiagnosticItem[]
diagnostic_items?: ConfigDiagnosticItem[]
diagnosticSummary?: Partial<ConfigDiagnosticSummary>
diagnostic_summary?: Partial<ConfigDiagnosticSummary>
} }
+282 -4
View File
@@ -1,11 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted } from 'vue' import { inject, nextTick, onMounted, unref, type Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { RefreshCwIcon, SaveIcon } from 'lucide-vue-next' import {
AlertTriangleIcon,
ArrowRightIcon,
CheckCircleIcon,
InfoIcon,
RefreshCwIcon,
SaveIcon,
ShieldAlertIcon
} from 'lucide-vue-next'
import BaseButton from '@/components/common/BaseButton.vue' import BaseButton from '@/components/common/BaseButton.vue'
import SettingNumberInput from '@/components/common/SettingNumberInput.vue' import SettingNumberInput from '@/components/common/SettingNumberInput.vue'
import SettingSwitch from '@/components/common/SettingSwitch.vue' import SettingSwitch from '@/components/common/SettingSwitch.vue'
import { useSystemConfig } from '@/composables' import { useSystemConfig } from '@/composables'
import type { ConfigDiagnosticItem, ConfigDiagnosticSeverity } from '@/types'
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const { t } = useI18n() const { t } = useI18n()
@@ -13,16 +22,134 @@ const {
config, config,
isRefreshing, isRefreshing,
isSaving, isSaving,
isDiagnosticsRefreshing,
isDirty, isDirty,
configDiagnosticItems,
configDiagnosticSummary,
fileSize, fileSize,
sizeUnit, sizeUnit,
saveTime, saveTime,
saveTimeUnit, saveTimeUnit,
refreshConfig, refreshConfig,
refreshDiagnostics,
submitConfig, submitConfig,
toggleConfigFlag toggleConfigFlag
} = useSystemConfig() } = useSystemConfig()
const diagnosticSeverities: ConfigDiagnosticSeverity[] = ['danger', 'warning', 'success', 'neutral']
const normalizeDiagnosticSeverity = (severity: string): ConfigDiagnosticSeverity =>
diagnosticSeverities.includes(severity as ConfigDiagnosticSeverity)
? (severity as ConfigDiagnosticSeverity)
: 'neutral'
const isDark = () => Boolean(unref(isDarkMode))
const getDiagnosticTitle = (item: ConfigDiagnosticItem) =>
t(`manage.settings.diagnosticsItems.${item.key}.title`, {
count: item.count ?? 0
})
const getDiagnosticDescription = (item: ConfigDiagnosticItem) =>
t(`manage.settings.diagnosticsItems.${item.key}.description`, {
count: item.count ?? 0
})
const getDiagnosticSeverityLabel = (severity: string) =>
t(`manage.settings.diagnosticSeverity.${normalizeDiagnosticSeverity(severity)}`)
const getDiagnosticIcon = (severity: string): Component => {
const normalizedSeverity = normalizeDiagnosticSeverity(severity)
if (normalizedSeverity === 'danger') {
return ShieldAlertIcon
}
if (normalizedSeverity === 'warning') {
return AlertTriangleIcon
}
if (normalizedSeverity === 'success') {
return CheckCircleIcon
}
return InfoIcon
}
const getDiagnosticClass = (severity: string) => {
const normalizedSeverity = normalizeDiagnosticSeverity(severity)
const darkMode = isDark()
if (normalizedSeverity === 'danger') {
return darkMode
? 'border-red-500/40 bg-red-500/10 hover:border-red-400'
: 'border-red-200 bg-red-50 hover:border-red-300'
}
if (normalizedSeverity === 'warning') {
return darkMode
? 'border-amber-500/40 bg-amber-500/10 hover:border-amber-400'
: 'border-amber-200 bg-amber-50 hover:border-amber-300'
}
if (normalizedSeverity === 'success') {
return darkMode
? 'border-emerald-500/40 bg-emerald-500/10 hover:border-emerald-400'
: 'border-emerald-200 bg-emerald-50 hover:border-emerald-300'
}
return darkMode
? 'border-gray-600 bg-gray-700/50 hover:border-gray-500'
: 'border-gray-200 bg-gray-50 hover:border-gray-300'
}
const getDiagnosticIconClass = (severity: string) => {
const normalizedSeverity = normalizeDiagnosticSeverity(severity)
if (normalizedSeverity === 'danger') {
return 'bg-red-100 text-red-600'
}
if (normalizedSeverity === 'warning') {
return 'bg-amber-100 text-amber-600'
}
if (normalizedSeverity === 'success') {
return 'bg-emerald-100 text-emerald-600'
}
return 'bg-gray-100 text-gray-600'
}
const getDiagnosticTargetField = (item: ConfigDiagnosticItem) => {
const field =
item.targetField ??
item.target_field ??
item.field ??
item.fields?.[0] ??
item.action?.field ??
item.action?.fields?.[0]
return field ? String(field) : ''
}
const findConfigFieldElement = (field: string) =>
document.querySelector<HTMLElement>(`[data-config-field="${field}"]`)
const focusDiagnosticField = async (item: ConfigDiagnosticItem) => {
const targetField = getDiagnosticTargetField(item)
if (!targetField) {
return
}
await nextTick()
const fieldElement =
findConfigFieldElement(targetField) ??
(targetField === 'enableChunk' ? findConfigFieldElement('file_storage') : null)
if (!fieldElement) {
return
}
fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
window.setTimeout(() => {
const focusable = fieldElement.matches('input, select, textarea, button')
? fieldElement
: fieldElement.querySelector<HTMLElement>('input, select, textarea, button')
focusable?.focus({ preventScroll: true })
}, 260)
}
onMounted(() => { onMounted(() => {
void refreshConfig() void refreshConfig()
}) })
@@ -81,6 +208,143 @@ onMounted(() => {
</div> </div>
</div> </div>
<section
class="mb-6 space-y-4 border-b pb-6"
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="max-w-3xl">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-md"
:class="[
isDarkMode ? 'bg-indigo-500/15 text-indigo-300' : 'bg-indigo-50 text-indigo-600'
]"
>
<ShieldAlertIcon class="h-5 w-5" />
</div>
<div>
<h3
class="text-lg font-semibold"
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
>
{{ t('manage.settings.diagnosticsTitle') }}
</h3>
<p class="mt-1 text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
{{ t('manage.settings.diagnosticsDesc') }}
</p>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<div
class="min-w-24 rounded-md border px-3 py-2"
:class="[isDarkMode ? 'border-gray-700 bg-gray-800/70' : 'border-gray-200 bg-white']"
>
<p class="text-xs" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
{{ t('manage.settings.diagnosticsTotal') }}
</p>
<p
class="mt-1 text-xl font-semibold"
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
>
{{ configDiagnosticSummary.total }}
</p>
</div>
<div
class="min-w-24 rounded-md border px-3 py-2"
:class="[isDarkMode ? 'border-red-500/30 bg-red-500/10' : 'border-red-200 bg-red-50']"
>
<p class="text-xs" :class="[isDarkMode ? 'text-red-200' : 'text-red-600']">
{{ t('manage.settings.diagnosticsDanger') }}
</p>
<p
class="mt-1 text-xl font-semibold"
:class="[isDarkMode ? 'text-red-100' : 'text-red-700']"
>
{{ configDiagnosticSummary.dangerCount }}
</p>
</div>
<div
class="min-w-24 rounded-md border px-3 py-2"
:class="[
isDarkMode ? 'border-amber-500/30 bg-amber-500/10' : 'border-amber-200 bg-amber-50'
]"
>
<p class="text-xs" :class="[isDarkMode ? 'text-amber-200' : 'text-amber-600']">
{{ t('manage.settings.diagnosticsWarning') }}
</p>
<p
class="mt-1 text-xl font-semibold"
:class="[isDarkMode ? 'text-amber-100' : 'text-amber-700']"
>
{{ configDiagnosticSummary.warningCount }}
</p>
</div>
<BaseButton
variant="outline"
:loading="isDiagnosticsRefreshing"
:disabled="isRefreshing || isSaving"
@click="() => void refreshDiagnostics()"
>
<template #icon>
<RefreshCwIcon class="mr-2 h-4 w-4" />
</template>
{{ t('manage.settings.diagnosticsRefresh') }}
</BaseButton>
</div>
</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-2">
<button
v-for="item in configDiagnosticItems"
:key="item.key"
type="button"
class="group flex h-full items-start gap-3 rounded-md border p-4 text-left transition"
:class="getDiagnosticClass(item.severity)"
@click="focusDiagnosticField(item)"
>
<span
class="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md"
:class="getDiagnosticIconClass(item.severity)"
>
<component :is="getDiagnosticIcon(item.severity)" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-center gap-2">
<span
class="text-sm font-semibold"
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
>
{{ getDiagnosticTitle(item) }}
</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium"
:class="[isDarkMode ? 'bg-gray-900/70 text-gray-300' : 'bg-white/80 text-gray-600']"
>
{{ getDiagnosticSeverityLabel(item.severity) }}
</span>
</span>
<span
class="mt-1 block text-sm leading-6"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
>
{{ getDiagnosticDescription(item) }}
</span>
</span>
<span
class="mt-1 inline-flex items-center gap-1 text-xs font-medium"
:class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-600']"
>
{{ t('manage.settings.diagnosticsFocusAction') }}
<ArrowRightIcon class="h-3.5 w-3.5 transition group-hover:translate-x-0.5" />
</span>
</button>
</div>
</section>
<div <div
class="space-y-6 rounded-lg shadow-md p-6" class="space-y-6 rounded-lg shadow-md p-6"
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-70' : 'bg-white']" :class="[isDarkMode ? 'bg-gray-800 bg-opacity-70' : 'bg-white']"
@@ -143,6 +407,7 @@ onMounted(() => {
type="password" type="password"
minlength="6" minlength="6"
v-model="config.admin_token" v-model="config.admin_token"
data-config-field="admin_token"
:placeholder="t('admin.settings.passwordPlaceholder')" :placeholder="t('admin.settings.passwordPlaceholder')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
@@ -304,6 +569,7 @@ onMounted(() => {
</label> </label>
<select <select
v-model="config.file_storage" v-model="config.file_storage"
data-config-field="file_storage"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer"
:class="[ :class="[
isDarkMode isDarkMode
@@ -321,6 +587,7 @@ onMounted(() => {
</div> </div>
<SettingSwitch <SettingSwitch
v-if="config.file_storage === 'local'" v-if="config.file_storage === 'local'"
data-config-field="enableChunk"
:label="t('manage.settings.chunkUploadNote')" :label="t('manage.settings.chunkUploadNote')"
:model-value="config.enableChunk" :model-value="config.enableChunk"
:enabled-text="t('common.enabled')" :enabled-text="t('common.enabled')"
@@ -340,6 +607,7 @@ onMounted(() => {
type="text" type="text"
:placeholder="t('manage.settings.webdavUrlPlaceholder')" :placeholder="t('manage.settings.webdavUrlPlaceholder')"
v-model="config.webdav_url" v-model="config.webdav_url"
data-config-field="webdav_url"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -360,6 +628,7 @@ onMounted(() => {
type="text" type="text"
:placeholder="t('manage.settings.webdavUsernamePlaceholder')" :placeholder="t('manage.settings.webdavUsernamePlaceholder')"
v-model="config.webdav_username" v-model="config.webdav_username"
data-config-field="webdav_username"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -380,6 +649,7 @@ onMounted(() => {
type="password" type="password"
:placeholder="t('manage.settings.webdavPasswordPlaceholder')" :placeholder="t('manage.settings.webdavPasswordPlaceholder')"
v-model="config.webdav_password" v-model="config.webdav_password"
data-config-field="webdav_password"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -403,6 +673,7 @@ onMounted(() => {
<input <input
type="text" type="text"
v-model="config.s3_access_key_id" v-model="config.s3_access_key_id"
data-config-field="s3_access_key_id"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -422,6 +693,7 @@ onMounted(() => {
<input <input
type="password" type="password"
v-model="config.s3_secret_access_key" v-model="config.s3_secret_access_key"
data-config-field="s3_secret_access_key"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -441,6 +713,7 @@ onMounted(() => {
<input <input
type="text" type="text"
v-model="config.s3_bucket_name" v-model="config.s3_bucket_name"
data-config-field="s3_bucket_name"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -538,6 +811,7 @@ onMounted(() => {
/> />
<SettingSwitch <SettingSwitch
data-config-field="enableChunk"
:label="t('manage.settings.chunkUploadNote')" :label="t('manage.settings.chunkUploadNote')"
:model-value="config.enableChunk" :model-value="config.enableChunk"
:enabled-text="t('common.enabled')" :enabled-text="t('common.enabled')"
@@ -561,12 +835,14 @@ onMounted(() => {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<SettingNumberInput <SettingNumberInput
v-model="config.uploadMinute" v-model="config.uploadMinute"
data-config-field="uploadMinute"
:label="t('manage.settings.uploadPerMinute')" :label="t('manage.settings.uploadPerMinute')"
:suffix="t('common.minute')" :suffix="t('common.minute')"
/> />
<SettingNumberInput <SettingNumberInput
v-model="config.uploadCount" v-model="config.uploadCount"
data-config-field="uploadCount"
:label="t('manage.settings.uploadCountLimit')" :label="t('manage.settings.uploadCountLimit')"
:suffix="t('common.files')" :suffix="t('common.files')"
/> />
@@ -605,7 +881,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2" data-config-field="expireStyle">
<label <label
class="block text-sm font-medium mb-2" class="block text-sm font-medium mb-2"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
@@ -642,7 +918,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2" data-config-field="max_save_seconds">
<label <label
class="block text-sm font-medium" class="block text-sm font-medium"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
@@ -699,12 +975,14 @@ onMounted(() => {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<SettingNumberInput <SettingNumberInput
v-model="config.errorMinute" v-model="config.errorMinute"
data-config-field="errorMinute"
:label="t('manage.settings.errorPerMinute')" :label="t('manage.settings.errorPerMinute')"
:suffix="t('common.minute')" :suffix="t('common.minute')"
/> />
<SettingNumberInput <SettingNumberInput
v-model="config.errorCount" v-model="config.errorCount"
data-config-field="errorCount"
:label="t('manage.settings.errorCountLimit')" :label="t('manage.settings.errorCountLimit')"
:suffix="t('common.times')" :suffix="t('common.times')"
/> />