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
+161 -6
View File
@@ -2,7 +2,13 @@ import { ref, computed } from 'vue'
import { ConfigService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
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 { getErrorMessage } from '@/utils/common'
import {
@@ -15,6 +21,122 @@ import {
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() {
const alertStore = useAlertStore()
const configStore = useConfigStore()
@@ -23,7 +145,9 @@ export function useSystemConfig() {
const config = ref<ConfigState>({ ...DEFAULT_CONFIG_STATE })
const isRefreshing = ref(false)
const isSaving = ref(false)
const isDiagnosticsRefreshing = ref(false)
const savedPayloadSnapshot = ref('')
const configDiagnostics = ref<NormalizedConfigDiagnostics>(emptyConfigDiagnostics())
const fileSize = ref(1)
const sizeUnit = ref<FileSizeUnit>('MB')
const saveTime = ref(1)
@@ -47,11 +171,14 @@ export function useSystemConfig() {
const snapshotPayload = (payload: Partial<ConfigState>) => JSON.stringify(payload)
const normalizeEditableConfig = (nextConfig: Partial<ConfigState>): ConfigState => ({
...DEFAULT_CONFIG_STATE,
...nextConfig,
admin_token: ''
})
const normalizeEditableConfig = (nextConfig: Partial<ConfigState>): ConfigState => {
const editableConfig = stripDiagnosticFields(nextConfig)
return {
...DEFAULT_CONFIG_STATE,
...editableConfig,
admin_token: ''
}
}
const markConfigSaved = () => {
savedPayloadSnapshot.value = snapshotPayload(buildSubmitPayload())
@@ -76,6 +203,24 @@ export function useSystemConfig() {
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> => {
try {
@@ -84,6 +229,7 @@ export function useSystemConfig() {
const response = await ConfigService.getConfig()
if (response.code === 200 && response.detail) {
configDiagnostics.value = normalizeConfigDiagnostics(response.detail)
config.value = normalizeEditableConfig(response.detail)
const notifyMessage = configStore.applyRemoteConfig(config.value)
if (notifyMessage) {
@@ -99,6 +245,7 @@ export function useSystemConfig() {
const storedConfig = getStoredConfig()
if (storedConfig) {
config.value = storedConfig
configDiagnostics.value = emptyConfigDiagnostics()
return config.value
}
@@ -121,6 +268,7 @@ export function useSystemConfig() {
syncConfigForm(config.value)
markConfigSaved()
saveConfigToStorage(config.value)
await refreshDiagnostics(false)
alertStore.showAlert('配置更新成功!', 'success')
return true
} else {
@@ -183,6 +331,9 @@ export function useSystemConfig() {
return Math.round(config.value.uploadSize / 1024 / 1024)
})
const configDiagnosticItems = computed(() => configDiagnostics.value.items)
const configDiagnosticSummary = computed(() => configDiagnostics.value.summary)
const isConfigLoaded = computed(() => {
return config.value.name !== DEFAULT_CONFIG_STATE.name || !isLoading.value
})
@@ -193,7 +344,10 @@ export function useSystemConfig() {
isLoading,
isRefreshing,
isSaving,
isDiagnosticsRefreshing,
isDirty,
configDiagnosticItems,
configDiagnosticSummary,
fileSize,
sizeUnit,
saveTime,
@@ -207,6 +361,7 @@ export function useSystemConfig() {
fetchConfig,
updateConfig,
refreshConfig,
refreshDiagnostics,
submitConfig,
toggleConfigFlag,
initConfig,
+60 -1
View File
@@ -822,7 +822,66 @@ export default {
saving: 'Saving',
unsavedChanges: 'Unsaved configuration changes',
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: {
title: 'System Settings',
+52 -1
View File
@@ -842,7 +842,58 @@ export default {
saving: '保存中',
unsavedChanges: '有未保存的配置变更',
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: {
title: '仪表盘',
+5 -1
View File
@@ -1,5 +1,5 @@
import api from './client'
import type { ApiResponse, ConfigState } from '@/types'
import type { ApiResponse, ConfigDiagnosticsResponse, ConfigState } from '@/types'
type PublicConfigEnvelope = {
config?: Partial<ConfigState>
@@ -17,6 +17,10 @@ export class ConfigService {
return api.get('/admin/config/get')
}
static async getDiagnostics(): Promise<ApiResponse<ConfigDiagnosticsResponse>> {
return api.get('/admin/config/diagnostics')
}
static async getUserConfig(): Promise<ApiResponse<ConfigState>> {
try {
const response = (await api.get('/api/v1/config')) as ApiResponse<
+52
View File
@@ -15,6 +15,53 @@ export interface ThemeChoice {
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 {
name: string
description: string
@@ -52,4 +99,9 @@ export interface ConfigState {
webdav_url: string
webdav_username: 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">
import { inject, onMounted } from 'vue'
import { inject, nextTick, onMounted, unref, type Component } from 'vue'
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 SettingNumberInput from '@/components/common/SettingNumberInput.vue'
import SettingSwitch from '@/components/common/SettingSwitch.vue'
import { useSystemConfig } from '@/composables'
import type { ConfigDiagnosticItem, ConfigDiagnosticSeverity } from '@/types'
const isDarkMode = inject('isDarkMode')
const { t } = useI18n()
@@ -13,16 +22,134 @@ const {
config,
isRefreshing,
isSaving,
isDiagnosticsRefreshing,
isDirty,
configDiagnosticItems,
configDiagnosticSummary,
fileSize,
sizeUnit,
saveTime,
saveTimeUnit,
refreshConfig,
refreshDiagnostics,
submitConfig,
toggleConfigFlag
} = 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(() => {
void refreshConfig()
})
@@ -81,6 +208,143 @@ onMounted(() => {
</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
class="space-y-6 rounded-lg shadow-md p-6"
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-70' : 'bg-white']"
@@ -143,6 +407,7 @@ onMounted(() => {
type="password"
minlength="6"
v-model="config.admin_token"
data-config-field="admin_token"
: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="[
@@ -304,6 +569,7 @@ onMounted(() => {
</label>
<select
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="[
isDarkMode
@@ -321,6 +587,7 @@ onMounted(() => {
</div>
<SettingSwitch
v-if="config.file_storage === 'local'"
data-config-field="enableChunk"
:label="t('manage.settings.chunkUploadNote')"
:model-value="config.enableChunk"
:enabled-text="t('common.enabled')"
@@ -340,6 +607,7 @@ onMounted(() => {
type="text"
:placeholder="t('manage.settings.webdavUrlPlaceholder')"
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="[
isDarkMode
@@ -360,6 +628,7 @@ onMounted(() => {
type="text"
:placeholder="t('manage.settings.webdavUsernamePlaceholder')"
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="[
isDarkMode
@@ -380,6 +649,7 @@ onMounted(() => {
type="password"
:placeholder="t('manage.settings.webdavPasswordPlaceholder')"
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="[
isDarkMode
@@ -403,6 +673,7 @@ onMounted(() => {
<input
type="text"
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="[
isDarkMode
@@ -422,6 +693,7 @@ onMounted(() => {
<input
type="password"
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="[
isDarkMode
@@ -441,6 +713,7 @@ onMounted(() => {
<input
type="text"
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="[
isDarkMode
@@ -538,6 +811,7 @@ onMounted(() => {
/>
<SettingSwitch
data-config-field="enableChunk"
:label="t('manage.settings.chunkUploadNote')"
:model-value="config.enableChunk"
:enabled-text="t('common.enabled')"
@@ -561,12 +835,14 @@ onMounted(() => {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<SettingNumberInput
v-model="config.uploadMinute"
data-config-field="uploadMinute"
:label="t('manage.settings.uploadPerMinute')"
:suffix="t('common.minute')"
/>
<SettingNumberInput
v-model="config.uploadCount"
data-config-field="uploadCount"
:label="t('manage.settings.uploadCountLimit')"
:suffix="t('common.files')"
/>
@@ -605,7 +881,7 @@ onMounted(() => {
</div>
</div>
<div class="space-y-2">
<div class="space-y-2" data-config-field="expireStyle">
<label
class="block text-sm font-medium mb-2"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"
@@ -642,7 +918,7 @@ onMounted(() => {
</div>
</div>
<div class="space-y-2">
<div class="space-y-2" data-config-field="max_save_seconds">
<label
class="block text-sm font-medium"
: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">
<SettingNumberInput
v-model="config.errorMinute"
data-config-field="errorMinute"
:label="t('manage.settings.errorPerMinute')"
:suffix="t('common.minute')"
/>
<SettingNumberInput
v-model="config.errorCount"
data-config-field="errorCount"
:label="t('manage.settings.errorCountLimit')"
:suffix="t('common.times')"
/>