diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..74b2199 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +package-lock.json +src/components/ui diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..0f2bddd --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 110, + "tabWidth": 2 +} diff --git a/frontend/commitlint.config.js b/frontend/commitlint.config.js new file mode 100644 index 0000000..54a3672 --- /dev/null +++ b/frontend/commitlint.config.js @@ -0,0 +1,13 @@ +/** Conventional Commits(scope 用功能域) */ +export default { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'revert'], + ], + 'subject-empty': [2, 'never'], + 'type-empty': [2, 'never'], + }, +}; diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..e837cdc 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -17,7 +17,11 @@ export default defineConfig([ ], languageOptions: { ecmaVersion: 2020, - globals: globals.browser, + globals: { ...globals.browser, ...globals.node }, + }, + rules: { + // 生产禁 console.log(允许 warn/error 上报保留) + 'no-console': ['warn', { allow: ['warn', 'error'] }], }, }, ]) diff --git a/frontend/package.json b/frontend/package.json index 346d76e..32d2167 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,11 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "lint:fix": "eslint . --fix", + "typecheck": "tsc --noEmit -p tsconfig.app.json", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", + "test": "vitest run", "audit:pages": "node scripts/audit-page-size.mjs 700", "preview": "vite preview" }, @@ -50,5 +55,14 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.css": [ + "prettier --write" + ] } } diff --git a/frontend/src/features/datasource/components/CreateInstanceForm.tsx b/frontend/src/features/datasource/components/CreateInstanceForm.tsx new file mode 100644 index 0000000..535d7cd --- /dev/null +++ b/frontend/src/features/datasource/components/CreateInstanceForm.tsx @@ -0,0 +1,552 @@ +/** + * 新建数据源四步表单(类型选择/基本信息/连接信息/高级配置),须先测试连接成功才可提交。 + * 必需 props:defaultType 初始数据库类型、onCancel 取消回调、onCreated 创建成功回调。 + * 已知限制:未通过「测试连接」前提交按钮不可用;步骤指示器仅允许回退已完成的步骤。 + */ +import { useState, useCallback } from 'react'; +import { + Database, Server, Zap, Activity, AlertTriangle, CheckCircle, XCircle, + ChevronRight, Eye, EyeOff, Loader2, +} from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { ModalOverlay } from '@/components/ModalOverlay'; +import { instanceApi } from '@/api/instance'; +import { DB_DEFAULT_PORTS } from '../utils'; +import { MySQLIcon, PostgreSQLIcon, RedisIcon, MongoDBIcon } from './DbTypeIcons'; + +/** 预置脱敏字段集合(高级配置一键填入用) */ +const PRESET_MASK_FIELDS = [ + 'mobile', 'concat_mobile', 'email_alias', 'email', 'password', + 'ga_key', 'private_key', 'secret', 'secret_key', 'key_snow', + 'telephone', 'username', 'real_name', 'token', 'two_factor_secret', 'content', +]; + +/** 创建向导的四个步骤(静态序列) */ +const formSteps = [ + { label: '类型选择', icon: Database }, + { label: '基本信息', icon: Server }, + { label: '连接信息', icon: Zap }, + { label: '高级配置', icon: Activity }, +]; + +/** 宽松布尔解析(后端测试连接返回的 cluster_mode 可能是字符串) */ +function toBool(value: unknown): boolean { + return value === true || value === 'true' || value === 1 || value === '1'; +} + +interface CreateInstanceFormProps { + defaultType: string; + onCancel: () => void; + onCreated: () => void; +} + +export function CreateInstanceForm({ defaultType, onCancel, onCreated }: CreateInstanceFormProps) { + const [currentStep, setCurrentStep] = useState(0); + const [dbType, setDbType] = useState(defaultType); + const [name, setName] = useState(''); + const [host, setHost] = useState(''); + const [port, setPort] = useState(String(DB_DEFAULT_PORTS[defaultType] || 3306)); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [showPwd, setShowPwd] = useState(false); + const [database, setDatabase] = useState(''); + const [redisDb, setRedisDb] = useState('0'); + const [redisClusterMode, setRedisClusterMode] = useState(false); + const [env, setEnv] = useState('development'); + const [customEnv, setCustomEnv] = useState(''); + const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite'); + const [excludedDbs, setExcludedDbs] = useState(''); + const [maskFields, setMaskFields] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null); + const isRedis = dbType === 'redis'; + const needUsername = !isRedis; // Redis username is optional (6.0+ ACL) + const needDatabase = !isRedis; + const finalEnv = env === '__custom__' ? customEnv.trim() : env; + const canSubmit = testResult?.success === true; + + const handleTypeChange = useCallback((t: string) => { + setDbType(t); + setPort(String(DB_DEFAULT_PORTS[t] || 3306)); + setTestResult(null); + if (t === 'redis') { setDatabase(''); } else { setRedisClusterMode(false); } + }, []); + + const handleSubmit = useCallback(async () => { + if (!name) { setError('请填写名称'); return; } + if (!host || !port) { setError('请填写地址和端口'); return; } + if (needUsername && !username) { setError('请填写用户名'); return; } + setSubmitting(true); setError(''); + try { + await instanceApi.create({ + name, db_type: dbType, host, port: Number(port), + username: isRedis ? (username || undefined) : (username || 'default'), + password: password || 'default', + database: database || undefined, + cluster_mode: isRedis ? redisClusterMode : undefined, + env: (finalEnv || 'development') as string, + access_mode: accessMode, + excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined, + mask_fields: maskFields ? maskFields.split('\n').filter(Boolean).map(line => { + const parts = line.split(':'); + return { table: parts[0]?.trim() || '', field: parts[1]?.trim() || parts[0]?.trim() || '', rule: parts[2]?.trim() || 'mask' }; + }) : undefined, + }); + onCreated(); + } catch (e: unknown) { + const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '创建失败'; + setError(msg); + } finally { + setSubmitting(false); + } + }, [name, host, port, dbType, username, password, database, redisClusterMode, finalEnv, accessMode, excludedDbs, maskFields, needUsername, isRedis, onCreated]); + + const handleTestConnection = useCallback(async () => { + if (!host || !port) { setError('请填写地址和端口'); return; } + if (needUsername && !username) { setError('请填写用户名'); return; } + setTesting(true); setError(''); setTestResult(null); + try { + const res = await instanceApi.testNewConnection({ + name: name || '__test__', db_type: dbType, host, port: Number(port), + username: isRedis ? (username || undefined) : (username || 'default'), + password: password || 'default', + database: database || undefined, + cluster_mode: isRedis ? redisClusterMode : undefined, + env: (finalEnv || 'development') as string, access_mode: accessMode, + }); + const result = res as { success: boolean; message: string; version?: string; cluster_mode?: boolean }; + if (isRedis && result.success) { + setRedisClusterMode(toBool(result.cluster_mode)); + } + setTestResult(result); + } catch (e: unknown) { + const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败'; + setTestResult({ success: false, message: msg }); + } finally { + setTesting(false); + } + }, [host, port, dbType, username, password, database, redisClusterMode, finalEnv, accessMode, needUsername, name, isRedis]); + + const validateStep = useCallback((step: number): boolean => { + setError(''); + if (step === 1) { + if (!name) { setError('请填写实例名称'); return false; } + } + if (step === 2) { + if (!host || !port) { setError('请填写地址和端口'); return false; } + if (needUsername && !username) { setError('请填写用户名'); return false; } + } + return true; + }, [name, host, port, needUsername, username]); + + const goNext = useCallback(() => { + if (validateStep(currentStep)) { + setCurrentStep(s => Math.min(s + 1, formSteps.length - 1)); + } + }, [currentStep, validateStep]); + + const goPrev = useCallback(() => { + setError(''); + setCurrentStep(s => Math.max(s - 1, 0)); + }, []); + + const inputCls = 'w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors'; + const labelCls = 'block text-sm font-medium mb-1.5'; + + return ( + <> + +
+ {/* Header */} +
+
+

添加数据源

+

步骤 {currentStep + 1} / {formSteps.length}: {formSteps[currentStep].label}

+
+ +
+ + {/* Step Indicator */} +
+
+ {formSteps.map((step, idx) => ( +
+ + {idx < formSteps.length - 1 && ( + + )} +
+ ))} +
+
+ +
+ {/* Step 0: Type Selection */} + {currentStep === 0 && ( + + +

选择数据源类型

+
+ {[ + { value: 'mysql', label: 'MySQL', Icon: MySQLIcon }, + { value: 'postgres', label: 'PostgreSQL', Icon: PostgreSQLIcon }, + { value: 'redis', label: 'Redis', Icon: RedisIcon }, + { value: 'mongo', label: 'MongoDB', Icon: MongoDBIcon }, + ].map(t => ( + + ))} +
+
+
+ )} + + {/* Step 1: Basic Info */} + {currentStep === 1 && ( + + +

基本信息

+
+
+ + { setName(e.target.value); setTestResult(null); }} + className={inputCls} + placeholder="例如:prod-mysql-只读库" + /> +
+
+ +
+ + {env === '__custom__' && ( + setCustomEnv(e.target.value)} + className={inputCls} + placeholder="输入自定义环境名" + /> + )} +
+
+
+ {isRedis && ( +
+

+ + Redis 6.0+ ACL 支持用户名+密码认证;传统模式仅填密码即可 +

+
+ )} +
+
+ )} + + {/* Step 2: Connection Info */} + {currentStep === 2 && ( + + +

连接信息

+
+
+ + { setHost(e.target.value); setTestResult(null); }} + className={inputCls} + placeholder="例如:mysql.example.com" + /> +
+
+ + { setPort(e.target.value); setTestResult(null); }} + className={inputCls} + placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)} + /> +
+
+ + { setUsername(e.target.value); setTestResult(null); }} + className={inputCls} + placeholder={isRedis ? 'ACL 用户名,留空则仅密码认证' : 'root'} + /> +
+
+
+
+ +
+ { setPassword(e.target.value); setTestResult(null); }} + className={inputCls + ' pr-10'} + placeholder={isRedis ? '可选,Redis 无密码可留空' : '数据库密码'} + /> + +
+
+ {needDatabase ? ( +
+ + { setDatabase(e.target.value); setTestResult(null); }} + className={inputCls} + placeholder="可选,留空则连接默认库" + /> +
+ ) : ( +
+ + setRedisDb(e.target.value)} + className={inputCls} + placeholder="默认 0" + /> +
+ )} +
+ {isRedis && ( +
+ +
+ {[ + { value: false, label: '单节点', desc: '普通 Redis 或哨兵入口,查询只访问当前实例' }, + { value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由并扫描所有 master' }, + ].map(mode => ( + + ))} +
+
+ )} + {/* Connection Mode */} +
+ +
+ {[ + { value: 'readwrite' as const, label: '读写' }, + { value: 'write' as const, label: '写' }, + { value: 'read' as const, label: '读' }, + ].map(m => ( + + ))} +
+
+ {/* Test Result */} + {testResult && ( +
+ {testResult.success + ? <> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}{isRedis ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''} + : <> {testResult.message} + } +
+ )} +
+
+ )} + + {/* Step 3: Advanced Config */} + {currentStep === 3 && ( + + +

高级配置

+ {!isRedis && ( +
+ + setExcludedDbs(e.target.value)} + className={inputCls} + placeholder="以逗号分隔,如:sys, information_schema, mysql" + /> +
+ )} +
+
+ +
+ + +
+
+
+ {PRESET_MASK_FIELDS.map(f => { + const isSelected = maskFields.split(/[\n,:]/).some(s => s.trim() === f); + return ( + + ); + })} +
+