mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 11:31:57 +08:00
Merge branch 'feature/20260408_security-update' into merge/feature-20260408-security-update-onto-dev
# Conflicts: # frontend/src/App.tsx # frontend/wailsjs/go/app/App.d.ts # frontend/wailsjs/go/app/App.js
This commit is contained in:
@@ -28,6 +28,7 @@ interface AISettingsModalProps {
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
focusProviderId?: string;
|
||||
}
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model
|
||||
@@ -79,7 +80,7 @@ const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; ico
|
||||
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
|
||||
];
|
||||
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||
@@ -135,6 +136,17 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
|
||||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !focusProviderId) {
|
||||
return;
|
||||
}
|
||||
if (!providers.some((provider) => provider.id === focusProviderId)) {
|
||||
return;
|
||||
}
|
||||
setActiveSection('providers');
|
||||
setActiveProviderId(focusProviderId);
|
||||
}, [focusProviderId, open, providers]);
|
||||
|
||||
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
|
||||
setEditingProvider(session.editingProvider as AIProviderConfig | null);
|
||||
setIsEditing(session.isEditing);
|
||||
|
||||
@@ -5,6 +5,11 @@ import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICO
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import {
|
||||
getStoredSecretPlaceholder,
|
||||
normalizeConnectionSecretErrorMessage,
|
||||
resolveConnectionTestFailureFeedback,
|
||||
} from '../utils/connectionModalPresentation';
|
||||
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
|
||||
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
@@ -135,7 +140,8 @@ const ConnectionModal: React.FC<{
|
||||
onClose: () => void;
|
||||
initialValues?: SavedConnection | null;
|
||||
onOpenDriverManager?: () => void;
|
||||
}> = ({ open, onClose, initialValues, onOpenDriverManager }) => {
|
||||
onSaved?: (savedConnection: SavedConnection) => void | Promise<void>;
|
||||
}> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useSSL, setUseSSL] = useState(false);
|
||||
@@ -1443,6 +1449,13 @@ const ConnectionModal: React.FC<{
|
||||
message.success('配置已保存(未连接)');
|
||||
}
|
||||
|
||||
if (onSaved) {
|
||||
void Promise.resolve(onSaved(savedConnection)).catch((error: unknown) => {
|
||||
console.warn('Failed to refresh post-save state', error);
|
||||
void message.warning('配置已保存,但安全更新状态暂未刷新,请稍后重新检查');
|
||||
});
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
setUseSSL(false);
|
||||
setUseSSH(false);
|
||||
@@ -1453,7 +1466,7 @@ const ConnectionModal: React.FC<{
|
||||
setClearSecrets(createEmptyConnectionSecretClearState());
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
message.error(normalizeConnectionSecretErrorMessage(e?.message || e, '保存失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -1508,10 +1521,14 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
|
||||
const text = String(reason ?? '').trim();
|
||||
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
|
||||
return `测试失败: ${normalized}`;
|
||||
const applyTestFailureFeedback = (feedback: { message: string; shouldToast: boolean }) => {
|
||||
setTestResult({ type: 'error', message: feedback.message });
|
||||
if (feedback.shouldToast) {
|
||||
void message.error({
|
||||
content: feedback.message,
|
||||
key: 'connection-test-failure',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
@@ -1522,14 +1539,21 @@ const ConnectionModal: React.FC<{
|
||||
const values = form.getFieldsValue(true);
|
||||
const unavailableReason = await resolveDriverUnavailableReason(values.type);
|
||||
if (unavailableReason) {
|
||||
const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用');
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'driver_unavailable',
|
||||
reason: unavailableReason,
|
||||
fallback: '驱动未安装启用',
|
||||
}));
|
||||
promptInstallDriver(values.type, unavailableReason);
|
||||
return;
|
||||
}
|
||||
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
|
||||
if (blockingSecretClearMessage) {
|
||||
setTestResult({ type: 'error', message: blockingSecretClearMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'secret_blocked',
|
||||
reason: blockingSecretClearMessage,
|
||||
fallback: '连接参数不完整',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -1555,6 +1579,7 @@ const ConnectionModal: React.FC<{
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
void message.destroy('connection-test-failure');
|
||||
setTestResult({ type: 'success', message: res.message });
|
||||
if (isRedisType) {
|
||||
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
||||
@@ -1578,27 +1603,33 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
} else {
|
||||
setDbList([]);
|
||||
message.warning(`连接成功,但获取数据库列表失败:${dbRes.message || '未知错误'}`);
|
||||
message.warning(`连接成功,但获取数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, '未知错误')}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const failMessage = buildTestFailureMessage(
|
||||
res?.message,
|
||||
'连接被拒绝或参数无效,请检查后重试'
|
||||
);
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'runtime',
|
||||
reason: res?.message,
|
||||
fallback: '连接被拒绝或参数无效,请检查后重试',
|
||||
}));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||
const failMessage = '测试失败: 请先完善必填项后再测试连接';
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'validation',
|
||||
reason: '',
|
||||
fallback: '请先完善必填项后再测试连接',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const reason = e instanceof Error
|
||||
? e.message
|
||||
: (typeof e === 'string' ? e : '未知异常');
|
||||
const failMessage = buildTestFailureMessage(reason, '未知异常');
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
|
||||
kind: 'runtime',
|
||||
reason,
|
||||
fallback: '未知异常',
|
||||
}));
|
||||
} finally {
|
||||
testInFlightRef.current = false;
|
||||
setLoading(false);
|
||||
@@ -1624,7 +1655,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
const result = await MongoDiscoverMembers(config as any);
|
||||
if (!result.success) {
|
||||
message.error(result.message || '成员发现失败');
|
||||
message.error(normalizeConnectionSecretErrorMessage(result.message, '成员发现失败'));
|
||||
return;
|
||||
}
|
||||
const data = (result.data as Record<string, any>) || {};
|
||||
@@ -1645,7 +1676,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
message.success(result.message || `发现 ${members.length} 个成员`);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '成员发现失败');
|
||||
message.error(normalizeConnectionSecretErrorMessage(error?.message || error, '成员发现失败'));
|
||||
} finally {
|
||||
setDiscoveringMembers(false);
|
||||
}
|
||||
@@ -2233,7 +2264,14 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} placeholder="留空沿用主库用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="mysqlReplicaPassword" label="从库密码(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主库密码" />
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
|
||||
emptyPlaceholder: '留空沿用主库密码',
|
||||
retainedLabel: '已保存从库密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
@@ -2283,7 +2321,14 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="mongoReplicaPassword" label="副本集密码(可选)" style={{ marginBottom: 0 }}>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主密码" />
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
emptyPlaceholder: '留空沿用主密码',
|
||||
retainedLabel: '已保存副本集密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'mongoReplicaPassword',
|
||||
@@ -2364,7 +2409,14 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="password" label="密码 (可选)">
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="Redis 密码(如果设置了 requirepass)" />
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: 'Redis 密码(如果设置了 requirepass)',
|
||||
retainedLabel: '已保存 Redis 密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: 'password',
|
||||
@@ -2397,7 +2449,14 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" style={{ marginBottom: 0 }}>
|
||||
<Input.Password {...noAutoCapInputProps} />
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: '密码',
|
||||
retainedLabel: '已保存密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{dbType === 'mongodb' && (
|
||||
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
|
||||
@@ -2518,7 +2577,14 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} placeholder="root" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sshPassword" label="SSH 密码" style={{ flex: 1 }}>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="密码" />
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasSSHPassword,
|
||||
emptyPlaceholder: '密码',
|
||||
retainedLabel: '已保存 SSH 密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
@@ -2573,7 +2639,14 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasProxyPassword,
|
||||
emptyPlaceholder: '留空表示无认证',
|
||||
retainedLabel: '已保存代理密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
@@ -2611,7 +2684,14 @@ const ConnectionModal: React.FC<{
|
||||
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
<Form.Item name="httpTunnelPassword" label="隧道密码(可选)" style={{ flex: 1 }}>
|
||||
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
|
||||
emptyPlaceholder: '留空表示无认证',
|
||||
retainedLabel: '已保存隧道密码',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
|
||||
102
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
102
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Checkbox, Input, Modal, Typography } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ConnectionPackagePasswordModalMode = 'import' | 'export';
|
||||
|
||||
export interface ConnectionPackagePasswordModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
mode?: ConnectionPackagePasswordModalMode;
|
||||
includeSecrets?: boolean;
|
||||
useFilePassword?: boolean;
|
||||
password: string;
|
||||
error?: string;
|
||||
confirmLoading?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onIncludeSecretsChange?: (value: boolean) => void;
|
||||
onUseFilePasswordChange?: (value: boolean) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConnectionPackagePasswordModal({
|
||||
open,
|
||||
title,
|
||||
mode = 'import',
|
||||
includeSecrets = true,
|
||||
useFilePassword = false,
|
||||
password,
|
||||
error,
|
||||
confirmLoading,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
onIncludeSecretsChange,
|
||||
onUseFilePasswordChange,
|
||||
onPasswordChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConnectionPackagePasswordModalProps) {
|
||||
const isExportMode = mode === 'export';
|
||||
const showFilePasswordInput = isExportMode ? useFilePassword : true;
|
||||
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
|
||||
const helperText = !includeSecrets
|
||||
? '将仅导出连接配置,不包含密码。'
|
||||
: (useFilePassword
|
||||
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
|
||||
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={confirmText}
|
||||
cancelText={cancelText}
|
||||
confirmLoading={confirmLoading}
|
||||
onOk={onConfirm}
|
||||
onCancel={onCancel}
|
||||
destroyOnClose={false}
|
||||
maskClosable={false}
|
||||
>
|
||||
{isExportMode ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Checkbox
|
||||
checked={includeSecrets}
|
||||
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
|
||||
>
|
||||
导出连接密码
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={useFilePassword}
|
||||
disabled={!includeSecrets}
|
||||
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
|
||||
>
|
||||
设置文件保护密码
|
||||
</Checkbox>
|
||||
</div>
|
||||
) : null}
|
||||
{showFilePasswordInput ? (
|
||||
<Input.Password
|
||||
autoFocus
|
||||
value={password}
|
||||
placeholder={placeholder}
|
||||
disabled={isExportMode && !useFilePassword}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{isExportMode ? (
|
||||
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
|
||||
{helperText}
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
|
||||
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
@@ -249,6 +250,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const currentSavedQuery = useMemo(() => {
|
||||
const savedId = String(tab.savedQueryId || '').trim();
|
||||
@@ -324,6 +326,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
// Fetch Database List
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDbs = async () => {
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn) return;
|
||||
@@ -367,10 +373,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
};
|
||||
void fetchDbs();
|
||||
}, [currentConnectionId, connections]);
|
||||
}, [autoFetchVisible, currentConnectionId, connections]);
|
||||
|
||||
// Fetch Metadata for Autocomplete (Cross-database)
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn) return;
|
||||
@@ -424,7 +434,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
};
|
||||
void fetchMetadata();
|
||||
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
}, [autoFetchVisible, currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
|
||||
// Query ID management helpers
|
||||
const setQueryId = (id: string) => {
|
||||
|
||||
154
frontend/src/components/SecurityUpdateBanner.tsx
Normal file
154
frontend/src/components/SecurityUpdateBanner.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Button } from 'antd';
|
||||
import { CloseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { SecurityUpdateStatus } from '../types';
|
||||
import { getSecurityUpdateStatusMeta } from '../utils/securityUpdatePresentation';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_BANNER_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateBannerSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateBannerProps {
|
||||
status: SecurityUpdateStatus;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
onStart: () => void;
|
||||
onRetry: () => void;
|
||||
onRestart: () => void;
|
||||
onOpenDetails: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const resolvePrimaryAction = (
|
||||
status: SecurityUpdateStatus,
|
||||
actions: Pick<SecurityUpdateBannerProps, 'onStart' | 'onRetry' | 'onRestart' | 'onOpenDetails'>,
|
||||
) => {
|
||||
switch (status.overallStatus) {
|
||||
case 'postponed':
|
||||
return {
|
||||
label: '立即更新',
|
||||
onClick: actions.onStart,
|
||||
};
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
case 'rolled_back':
|
||||
return {
|
||||
label: '重新开始更新',
|
||||
onClick: actions.onRestart,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSecondaryAction = (
|
||||
status: SecurityUpdateStatus,
|
||||
actions: Pick<SecurityUpdateBannerProps, 'onRetry' | 'onOpenDetails'>,
|
||||
) => {
|
||||
switch (status.overallStatus) {
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: '重新检查',
|
||||
onClick: actions.onRetry,
|
||||
};
|
||||
case 'rolled_back':
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const SecurityUpdateBanner = ({
|
||||
status,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
onStart,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onOpenDetails,
|
||||
onDismiss,
|
||||
}: SecurityUpdateBannerProps) => {
|
||||
const statusMeta = getSecurityUpdateStatusMeta(status);
|
||||
const primaryAction = resolvePrimaryAction(status, { onStart, onRetry, onRestart, onOpenDetails });
|
||||
const secondaryAction = resolveSecondaryAction(status, { onRetry, onOpenDetails });
|
||||
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={SECURITY_UPDATE_BANNER_CLASS}
|
||||
style={{
|
||||
margin: '12px 12px 0',
|
||||
padding: '14px 16px',
|
||||
borderRadius: 16,
|
||||
...getSecurityUpdateBannerSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
flexShrink: 0,
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
已保存配置可进行安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{statusMeta.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
{secondaryAction ? (
|
||||
<Button className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={secondaryAction.onClick}>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type="primary"
|
||||
onClick={primaryAction.onClick}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={{ ...actionButtonStyle, width: 36, minWidth: 36, paddingInline: 0 }}
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateBannerProps };
|
||||
export default SecurityUpdateBanner;
|
||||
133
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal file
133
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Button, Modal } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateIntroModalProps {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
onStart: () => void;
|
||||
onPostpone: () => void;
|
||||
onViewDetails: () => void;
|
||||
}
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
...getSecurityUpdateActionButtonStyle(),
|
||||
height: 38,
|
||||
paddingInline: 18,
|
||||
};
|
||||
|
||||
const SecurityUpdateIntroModal = ({
|
||||
open,
|
||||
loading = false,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
onStart,
|
||||
onPostpone,
|
||||
onViewDetails,
|
||||
}: SecurityUpdateIntroModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
title={(
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 18,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
|
||||
已保存配置安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
使用新的安全存储方式前,需要先完成一次本地配置更新。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
open={open}
|
||||
closable={!loading}
|
||||
maskClosable={!loading}
|
||||
keyboard={!loading}
|
||||
onCancel={onPostpone}
|
||||
width={560}
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8 },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="details"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
ghost
|
||||
style={actionButtonStyle}
|
||||
onClick={onViewDetails}
|
||||
disabled={loading}
|
||||
>
|
||||
查看详情
|
||||
</Button>,
|
||||
<Button
|
||||
key="later"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
ghost
|
||||
style={actionButtonStyle}
|
||||
onClick={onPostpone}
|
||||
disabled={loading}
|
||||
>
|
||||
稍后提醒我
|
||||
</Button>,
|
||||
<Button
|
||||
key="start"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
style={actionButtonStyle}
|
||||
loading={loading}
|
||||
onClick={onStart}
|
||||
>
|
||||
立即更新
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 0 6px',
|
||||
color: darkMode ? 'rgba(255,255,255,0.82)' : '#2f3b52',
|
||||
lineHeight: 1.8,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
为了让已保存的连接、代理和相关服务配置使用新的安全存储方式,本次更新需要进行一次本地配置更新。
|
||||
更新前会自动创建本地备份;如果本次未完成,系统会保留当前可用配置,你也可以稍后继续。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateIntroModalProps };
|
||||
export default SecurityUpdateIntroModal;
|
||||
69
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal file
69
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Modal, Spin } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateProgressModalProps {
|
||||
open: boolean;
|
||||
stageText: string;
|
||||
detailText?: string;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
}
|
||||
|
||||
const SecurityUpdateProgressModal = ({
|
||||
open,
|
||||
stageText,
|
||||
detailText,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
}: SecurityUpdateProgressModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
open={open}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
footer={null}
|
||||
width={420}
|
||||
centered
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { display: 'none' },
|
||||
body: { padding: 28 },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 18,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 22,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
{stageText}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{detailText ?? '更新过程中会保留当前可用配置,请稍候。'}
|
||||
</div>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateProgressModalProps };
|
||||
export default SecurityUpdateProgressModal;
|
||||
337
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal file
337
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Empty, Modal, Tag } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
|
||||
import {
|
||||
getSecurityUpdateIssueActionMeta,
|
||||
getSecurityUpdateIssueSeverityMeta,
|
||||
getSecurityUpdateItemStatusMeta,
|
||||
getSecurityUpdateStatusMeta,
|
||||
sortSecurityUpdateIssues,
|
||||
} from '../utils/securityUpdatePresentation';
|
||||
import {
|
||||
hasSecurityUpdateRecentResult,
|
||||
resolveSecurityUpdateFocusState,
|
||||
type SecurityUpdateFocusState,
|
||||
type SecurityUpdateSettingsFocusTarget,
|
||||
} from '../utils/securityUpdateRepairFlow';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
|
||||
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateSectionSurfaceStyle,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateSettingsModalProps {
|
||||
open: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
status: SecurityUpdateStatus;
|
||||
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
|
||||
focusRequest?: number;
|
||||
onClose: () => void;
|
||||
onStart: () => void;
|
||||
onRetry: () => void;
|
||||
onRestart: () => void;
|
||||
onIssueAction: (issue: SecurityUpdateIssue) => void;
|
||||
}
|
||||
|
||||
const sectionStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
surfaceOpacity: number,
|
||||
options?: { emphasized?: boolean },
|
||||
) => ({
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, {
|
||||
...options,
|
||||
surfaceOpacity,
|
||||
}),
|
||||
});
|
||||
|
||||
const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = {
|
||||
target: null,
|
||||
pulseKey: null,
|
||||
};
|
||||
|
||||
const SecurityUpdateSettingsModal = ({
|
||||
open,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
status,
|
||||
focusTarget = null,
|
||||
focusRequest = 0,
|
||||
onClose,
|
||||
onStart,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onIssueAction,
|
||||
}: SecurityUpdateSettingsModalProps) => {
|
||||
const statusMeta = getSecurityUpdateStatusMeta(status);
|
||||
const sortedIssues = sortSecurityUpdateIssues(status.issues);
|
||||
const showRecentResult = hasSecurityUpdateRecentResult(status);
|
||||
const showStart = status.overallStatus === 'pending' || status.overallStatus === 'postponed';
|
||||
const showRetry = status.overallStatus === 'needs_attention';
|
||||
const showRestart = status.overallStatus === 'needs_attention' || status.overallStatus === 'rolled_back';
|
||||
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
|
||||
const [activeFocus, setActiveFocus] = useState<SecurityUpdateFocusState>(EMPTY_FOCUS_STATE);
|
||||
const statusSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const recentResultRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextFocus = resolveSecurityUpdateFocusState(open, focusTarget, focusRequest);
|
||||
if (!nextFocus.target || !nextFocus.pulseKey) {
|
||||
setActiveFocus(EMPTY_FOCUS_STATE);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targetNode = nextFocus.target === 'recent_result'
|
||||
? recentResultRef.current
|
||||
: statusSectionRef.current;
|
||||
if (!targetNode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setActiveFocus(EMPTY_FOCUS_STATE);
|
||||
const animationFrame = window.requestAnimationFrame(() => {
|
||||
targetNode.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
targetNode.focus({ preventScroll: true });
|
||||
setActiveFocus(nextFocus);
|
||||
});
|
||||
const highlightTimer = window.setTimeout(() => {
|
||||
setActiveFocus((current) => (
|
||||
current.pulseKey === nextFocus.pulseKey ? EMPTY_FOCUS_STATE : current
|
||||
));
|
||||
}, 1800);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
window.clearTimeout(highlightTimer);
|
||||
};
|
||||
}, [focusRequest, focusTarget, open]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
title={(
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 18,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
|
||||
安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
管理已保存配置的安全更新状态与待处理项。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
showRetry ? (
|
||||
<Button key="retry" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRetry}>
|
||||
重新检查
|
||||
</Button>
|
||||
) : null,
|
||||
showRestart ? (
|
||||
<Button key="restart" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRestart}>
|
||||
重新开始更新
|
||||
</Button>
|
||||
) : null,
|
||||
showStart ? (
|
||||
<Button
|
||||
key="start"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type="primary"
|
||||
onClick={onStart}
|
||||
>
|
||||
开始更新
|
||||
</Button>
|
||||
) : null,
|
||||
<Button key="close" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={760}
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 14, padding: '12px 0' }}>
|
||||
<div
|
||||
ref={statusSectionRef}
|
||||
tabIndex={-1}
|
||||
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'status' })}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
当前状态:{statusMeta.label}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{statusMeta.description}
|
||||
</div>
|
||||
</div>
|
||||
<Tag color={
|
||||
statusMeta.tone === 'success'
|
||||
? 'success'
|
||||
: statusMeta.tone === 'error'
|
||||
? 'error'
|
||||
: statusMeta.tone === 'processing'
|
||||
? 'processing'
|
||||
: statusMeta.tone === 'warning'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}>
|
||||
{statusMeta.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||
影响范围
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
|
||||
{[
|
||||
{ label: '总计', value: status.summary.total },
|
||||
{ label: '已更新', value: status.summary.updated },
|
||||
{ label: '待处理', value: status.summary.pending },
|
||||
{ label: '已跳过', value: status.summary.skipped },
|
||||
{ label: '失败', value: status.summary.failed },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||
borderRadius: 12,
|
||||
padding: '12px 10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>{item.label}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 20, fontWeight: 700, color: overlayTheme.titleText }}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||
待处理清单
|
||||
</div>
|
||||
{sortedIssues.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="当前没有待处理项"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{sortedIssues.map((issue) => {
|
||||
const actionMeta = getSecurityUpdateIssueActionMeta(issue);
|
||||
const itemStatusMeta = getSecurityUpdateItemStatusMeta(issue.status);
|
||||
const issueSeverityMeta = getSecurityUpdateIssueSeverityMeta(issue.severity);
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
style={{
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
{issue.title || issue.message || issue.id}
|
||||
</div>
|
||||
<Tag color={itemStatusMeta.color}>
|
||||
状态:{itemStatusMeta.label}
|
||||
</Tag>
|
||||
<Tag color={issueSeverityMeta.color}>
|
||||
级别:{issueSeverityMeta.label}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{issue.message || '当前项需要进一步处理后才能完成安全更新。'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type={actionMeta.emphasis === 'primary' ? 'primary' : 'default'}
|
||||
onClick={() => onIssueAction(issue)}
|
||||
>
|
||||
{actionMeta.label}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRecentResult ? (
|
||||
<div
|
||||
ref={recentResultRef}
|
||||
tabIndex={-1}
|
||||
className={[
|
||||
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||
activeFocus.target === 'recent_result' ? SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'recent_result' })}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
|
||||
最近一次结果
|
||||
</div>
|
||||
{status.backupPath ? (
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
备份位置:<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{status.lastError ? (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#ff7875', lineHeight: 1.7 }}>
|
||||
最近错误:{status.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateSettingsModalProps };
|
||||
export default SecurityUpdateSettingsModal;
|
||||
@@ -42,6 +42,7 @@ import { getDbIcon } from './DatabaseIcons';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
@@ -119,6 +120,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
|
||||
// Background Helper (Duplicate logic for now, ideally shared)
|
||||
@@ -293,6 +295,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh queries for expanded databases
|
||||
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
@@ -311,7 +317,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
loadTables(node);
|
||||
}
|
||||
});
|
||||
}, [savedQueries]);
|
||||
}, [autoFetchVisible, savedQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
setTreeData((prev) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, D
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||
import type { TabData } from '../types';
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
|
||||
@@ -152,6 +153,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
|
||||
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!connection) return;
|
||||
@@ -180,7 +182,12 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
}
|
||||
}, [connection, tab.dbName]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
void loadData();
|
||||
}, [autoFetchVisible, loadData]);
|
||||
|
||||
const sortedFiltered = useMemo(() => {
|
||||
let list = [...tables];
|
||||
|
||||
Reference in New Issue
Block a user