'use client'; import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient, type UseQueryResult, } from '@tanstack/react-query'; import { Database, KeyRound, Save, ShieldAlert, X } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Spinner } from '@/components/ui/spinner'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import services from '@/lib/services'; import type { SystemConfig } from '@/lib/services/admin'; import { TemplatesManager } from './templates'; import { toast } from 'sonner'; import { useTranslations } from 'next-intl'; const LOG_RETENTION_FIELDS = [ { key: 'log_retention_days_postgres', label: 'PostgreSQL', descKey: 'retentionPostgresDesc', }, { key: 'log_retention_days_sqlite', label: 'SQLite', descKey: 'retentionSqliteDesc', }, { key: 'log_retention_days_clickhouse', label: 'ClickHouse', descKey: 'retentionClickhouseDesc', }, ] as const; interface OperationTabProps { configs: Record; systemConfigsQuery: UseQueryResult; } export function OperationTab({ configs, systemConfigsQuery, }: OperationTabProps) { const queryClient = useQueryClient(); const t = useTranslations('settings.operation'); const uploadTypesQuery = useQuery({ queryKey: ['admin', 'upload-types'], queryFn: () => services.adminSystemConfig.listUploadTypes(), }); const businessConfigsQuery = useQuery({ queryKey: ['admin', 'system-configs', 'business'], queryFn: () => services.adminSystemConfig.listSystemConfigs('business'), }); const businessConfigs = useMemo(() => { return (businessConfigsQuery.data ?? []).reduce< Record >((acc, config) => { acc[config.key] = config; return acc; }, {}); }, [businessConfigsQuery.data]); const [retentionValues, setRetentionValues] = useState< Record >({}); useEffect(() => { if (!businessConfigsQuery.data) return; setRetentionValues((prev) => { const next: Record = {}; LOG_RETENTION_FIELDS.forEach((field) => { const config = businessConfigs[field.key]; next[field.key] = config?.value || prev[field.key] || '90'; }); return next; }); }, [businessConfigsQuery.data, businessConfigs]); const updateRetentionMutation = useMutation({ mutationFn: async (values: Record) => { for (const field of LOG_RETENTION_FIELDS) { const raw = (values[field.key] ?? '').trim(); const num = Number(raw); if (!raw || !Number.isInteger(num) || num < 1) { throw new Error(t('retentionInvalid', { label: field.label })); } const config = businessConfigs[field.key]; if (!config) { throw new Error(`缺少配置项: ${field.key}`); } await services.adminSystemConfig.updateSystemConfig(field.key, { value: String(num), description: config.description, }); } }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['admin', 'system-configs'], }); toast.success(t('logRetentionUpdated')); }, onError: (error: Error) => { toast.error(error.message || t('logRetentionUpdateFailed')); }, }); const updateWhitelistMutation = useMutation({ mutationFn: async (newValue: string) => { const config = configs['file_access_whitelist']; if (!config) { throw new Error('缺少配置项: file_access_whitelist'); } await services.adminSystemConfig.updateSystemConfig( 'file_access_whitelist', { value: newValue, description: config.description, }, ); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['admin', 'system-configs'], }); await queryClient.invalidateQueries({ queryKey: ['public-config'] }); toast.success(t('whitelistUpdated')); }, onError: (error: Error) => { toast.error(error.message || t('updateWhitelistFailed')); }, }); const whitelistConfig = configs['file_access_whitelist']; const currentWhitelist = useMemo(() => { if (!whitelistConfig?.value) return ['avatar']; try { const parsed = JSON.parse(whitelistConfig.value); if (Array.isArray(parsed)) return parsed; } catch { // 降级支持逗号分隔解析 return whitelistConfig.value .split(',') .map((s) => s.trim()) .filter(Boolean); } return ['avatar']; }, [whitelistConfig?.value]); const handleAddType = (type: string) => { if (!type || currentWhitelist.includes(type)) return; const newWhitelist = [...currentWhitelist, type]; updateWhitelistMutation.mutate(JSON.stringify(newWhitelist)); }; const handleRemoveType = (typeToRemove: string) => { const newWhitelist = currentWhitelist.filter((t) => t !== typeToRemove); updateWhitelistMutation.mutate(JSON.stringify(newWhitelist)); }; const availableTypes = useMemo(() => { const types = uploadTypesQuery.data ?? []; return types.map((type) => { let label = type; if (type === 'avatar') label = t('typeAvatar'); else if (type === 'attachment') label = t('typeAttachment'); else if (type === 'doc') label = t('typeDoc'); else if (type === 'generic') label = t('typeGeneric'); return { value: type, label }; }); }, [t, uploadTypesQuery.data]); return (
{/* 文件访问白名单设置 */}
{t('fileAccessControl')} {t('fileAccessControlDesc')}
{t('addAuthFreeType')}
{/* 当前白名单列表 */}
{t('currentAuthFreeList')}
{currentWhitelist.length > 0 ? (
{currentWhitelist.map((type) => ( {availableTypes.find((t) => t.value === type)?.label || type} ))}
) : (

{t('whitelistEmpty')}

)}
{/* 日志保留时间设置 */}
{t('logRetentionTitle')} {t('logRetentionDesc')}
{LOG_RETENTION_FIELDS.map((field) => (
setRetentionValues((prev) => ({ ...prev, [field.key]: e.target.value, })) } /> {t('days')}

{t(field.descKey)}

))}
{/* 通知模板管理 */}
); }