refactor(frontend): split 7 oversized pages into feature co-location
Per guide ch.7 page split pattern (skeleton orchestrates, private components live in features/<domain>/): - InstancePage 1716->328, UsersPage 1309->240, TicketsPage 1076->195, TicketDetailPage 911->287, QueriesPage 883->479, LdapConfigPage 860->393, WorkflowPage 852->194 - new feature modules: datasource/ rbac/ ticket/ query/ system/ldap/ governance/workflow/ (components + hooks + types + utils) - stable keys replace index keys in dynamic lists; polling intervals and toast call sites preserved 1:1 (verified by agents) - filter/pagination state moved to URL query via new useQueryState hook (Instance/Users/Tickets/AuditLogs pages): refresh/share/back-button restore state; composite updates applied atomically - permission pure functions extracted to lib/permissions.ts (used by useAuth + useLogin); page-size audit: 0 pages over 700-line budget
This commit is contained in:
1 parent
ac563d1c5e
commit
ac498c7ba2
92 files changed
+8224
-6146
No files matched your search
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
src/components/ui
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 110,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
@@ -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'] }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<ModalOverlay open={true} onClose={onCancel} width="max-w-2xl">
|
||||
<div
|
||||
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">添加数据源</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">步骤 {currentStep + 1} / {formSteps.length}: {formSteps[currentStep].label}</p>
|
||||
</div>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="px-6 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{formSteps.map((step, idx) => (
|
||||
<div key={step.label} className="flex items-center gap-2 flex-1">
|
||||
<button
|
||||
onClick={() => { if (idx < currentStep) setCurrentStep(idx); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all flex-1 justify-center ${
|
||||
idx === currentStep ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' :
|
||||
idx < currentStep ? 'bg-green-500/10 text-green-400 border border-green-500/20 cursor-pointer' :
|
||||
'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-[var(--dg-border)]'
|
||||
}`}
|
||||
>
|
||||
<step.icon className="w-3.5 h-3.5" />
|
||||
{step.label}
|
||||
</button>
|
||||
{idx < formSteps.length - 1 && (
|
||||
<ChevronRight className={`w-3 h-3 flex-shrink-0 ${idx < currentStep ? 'text-green-400' : 'text-[var(--dg-text-muted)]'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 0: Type Selection */}
|
||||
{currentStep === 0 && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">选择数据源类型</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ 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 => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => handleTypeChange(t.value)}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||
dbType === t.value ? 'border-blue-500 bg-blue-500/5' : 'border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
<t.Icon className="w-10 h-10 object-contain" />
|
||||
<span className="text-xs font-medium">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 1: Basic Info */}
|
||||
{currentStep === 1 && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">基本信息</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>名称 <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => { setName(e.target.value); setTestResult(null); }}
|
||||
className={inputCls}
|
||||
placeholder="例如:prod-mysql-只读库"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>环境 <span className="text-red-400">*</span></label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={env}
|
||||
onChange={e => setEnv(e.target.value)}
|
||||
className={inputCls + (env === '__custom__' ? ' mb-2' : '')}
|
||||
>
|
||||
<option value="development">开发</option>
|
||||
<option value="staging">预发</option>
|
||||
<option value="production">生产</option>
|
||||
<option value="test">测试</option>
|
||||
<option value="uat">UAT</option>
|
||||
<option value="__custom__">自定义...</option>
|
||||
</select>
|
||||
{env === '__custom__' && (
|
||||
<input
|
||||
autoFocus
|
||||
value={customEnv}
|
||||
onChange={e => setCustomEnv(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="输入自定义环境名"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRedis && (
|
||||
<div className="p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<p className="text-xs text-blue-400 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
Redis 6.0+ ACL 支持用户名+密码认证;传统模式仅填密码即可
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Connection Info */}
|
||||
{currentStep === 2 && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接信息</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>地址 <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
value={host}
|
||||
onChange={e => { setHost(e.target.value); setTestResult(null); }}
|
||||
className={inputCls}
|
||||
placeholder="例如:mysql.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>端口 <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
value={port}
|
||||
onChange={e => { setPort(e.target.value); setTestResult(null); }}
|
||||
className={inputCls}
|
||||
placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
用户名 {isRedis ? <span className="text-[var(--dg-text-muted)]">(可选)</span> : <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={e => { setUsername(e.target.value); setTestResult(null); }}
|
||||
className={inputCls}
|
||||
placeholder={isRedis ? 'ACL 用户名,留空则仅密码认证' : 'root'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{isRedis ? '密码' : '密码'} <span className={isRedis ? 'text-[var(--dg-text-muted)]' : 'text-red-400'}>{isRedis ? '' : '*'}</span></label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); setTestResult(null); }}
|
||||
className={inputCls + ' pr-10'}
|
||||
placeholder={isRedis ? '可选,Redis 无密码可留空' : '数据库密码'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPwd(!showPwd)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{needDatabase ? (
|
||||
<div>
|
||||
<label className={labelCls}>默认数据库</label>
|
||||
<input
|
||||
value={database}
|
||||
onChange={e => { setDatabase(e.target.value); setTestResult(null); }}
|
||||
className={inputCls}
|
||||
placeholder="可选,留空则连接默认库"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className={labelCls}>数据库编号</label>
|
||||
<input
|
||||
value={redisDb}
|
||||
onChange={e => setRedisDb(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="默认 0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isRedis && (
|
||||
<div>
|
||||
<label className={labelCls}>Redis 部署模式</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: false, label: '单节点', desc: '普通 Redis 或哨兵入口,查询只访问当前实例' },
|
||||
{ value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由并扫描所有 master' },
|
||||
].map(mode => (
|
||||
<button
|
||||
key={mode.label}
|
||||
type="button"
|
||||
onClick={() => { setRedisClusterMode(mode.value); setTestResult(null); }}
|
||||
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||
redisClusterMode === mode.value
|
||||
? 'bg-blue-500/10 border-blue-500/40 text-blue-400'
|
||||
: 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{mode.label}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{mode.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Connection Mode */}
|
||||
<div>
|
||||
<label className={labelCls}>连接模式</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'readwrite' as const, label: '读写' },
|
||||
{ value: 'write' as const, label: '写' },
|
||||
{ value: 'read' as const, label: '读' },
|
||||
].map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => setAccessMode(m.value)}
|
||||
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
accessMode === m.value
|
||||
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div className={`flex items-center gap-3 p-3 rounded-lg text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{testResult.success
|
||||
? <><CheckCircle className="w-4 h-4 flex-shrink-0" /> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}{isRedis ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''}</>
|
||||
: <><XCircle className="w-4 h-4 flex-shrink-0" /> {testResult.message}</>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Advanced Config */}
|
||||
{currentStep === 3 && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">高级配置</h3>
|
||||
{!isRedis && (
|
||||
<div>
|
||||
<label className={labelCls}>排除的数据库</label>
|
||||
<input
|
||||
value={excludedDbs}
|
||||
onChange={e => setExcludedDbs(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="以逗号分隔,如:sys, information_schema, mysql"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className={labelCls}>脱敏字段</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(PRESET_MASK_FIELDS.join(', ')).then(() => {
|
||||
const btn = document.getElementById('copy-mask-btn');
|
||||
if (btn) {
|
||||
btn.textContent = '已复制';
|
||||
setTimeout(() => { if (btn) btn.textContent = '复制全部'; }, 1500);
|
||||
}
|
||||
});
|
||||
}}
|
||||
id="copy-mask-btn"
|
||||
className="text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)]"
|
||||
>
|
||||
复制全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newFields = PRESET_MASK_FIELDS.map(f => f + ':mask').join('\n');
|
||||
setMaskFields(maskFields ? maskFields + '\n' + newFields : newFields);
|
||||
}}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-0.5 rounded bg-blue-500/10"
|
||||
>
|
||||
一键填入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{PRESET_MASK_FIELDS.map(f => {
|
||||
const isSelected = maskFields.split(/[\n,:]/).some(s => s.trim() === f);
|
||||
return (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setMaskFields(maskFields.split('\n').filter(line => {
|
||||
return !line.split(':')[0]?.trim()?.includes(f);
|
||||
}).join('\n'));
|
||||
} else {
|
||||
setMaskFields(maskFields ? maskFields + '\n' + f + ':mask' : f + ':mask');
|
||||
}
|
||||
}}
|
||||
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||
isSelected ? 'bg-blue-500/20 text-blue-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<textarea
|
||||
value={maskFields}
|
||||
onChange={e => setMaskFields(e.target.value)}
|
||||
className={inputCls + ' min-h-[80px] resize-y font-mono text-xs'}
|
||||
placeholder={'每行一个,格式:字段名:规则\n点击上方标签快速添加\n例如:\nmobile:mask\nemail:mask\npassword:hide'}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <div className="text-sm text-red-400 flex items-center gap-2"><AlertTriangle className="w-4 h-4" />{error}</div>}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !host || !port}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40 transition-all"
|
||||
>
|
||||
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
{currentStep > 0 && (
|
||||
<button onClick={goPrev} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
上一步
|
||||
</button>
|
||||
)}
|
||||
{currentStep < formSteps.length - 1 ? (
|
||||
<button onClick={goNext} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm text-white transition-colors">
|
||||
下一步
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={onCancel} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !canSubmit}
|
||||
className={`px-4 py-2 rounded-lg text-sm text-white transition-all ${
|
||||
canSubmit ? 'bg-blue-600 hover:bg-blue-700' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{submitting ? '创建中...' : canSubmit ? '添加数据源' : '请先测试连接'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 数据库品牌图标与类型展示配置。
|
||||
* 用途:导出 MySQL/PostgreSQL/Redis/MongoDB 图标组件及 dbTypeConfig 映射,供列表卡片/表格/详情/创建表单复用。
|
||||
* 必需依赖:public/db-logos 下的 svg 资源;未知类型经 resolveDbTypeConfig 回退为 MySQL 样式。
|
||||
* 已知限制:图标以 <img> 形式渲染,仅接受 className 控制尺寸,不支持 currentColor 换色。
|
||||
*/
|
||||
import { dbTypeColorMap } from '@/lib/constants';
|
||||
import type { DbTypeConfig } from '../types';
|
||||
|
||||
export function MySQLIcon({ className }: { className?: string }) {
|
||||
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
|
||||
}
|
||||
export function PostgreSQLIcon({ className }: { className?: string }) {
|
||||
return <img src="/db-logos/postgres.svg" alt="PostgreSQL" className={className} />;
|
||||
}
|
||||
export function RedisIcon({ className }: { className?: string }) {
|
||||
return <img src="/db-logos/redis.svg" alt="Redis" className={className} />;
|
||||
}
|
||||
export function MongoDBIcon({ className }: { className?: string }) {
|
||||
return <img src="/db-logos/mongo.svg" alt="MongoDB" className={className} />;
|
||||
}
|
||||
|
||||
/** 各数据库类型的展示配置(图标/徽标底色/展示名) */
|
||||
export const dbTypeConfig: Record<string, DbTypeConfig> = {
|
||||
mysql: { icon: MySQLIcon, badge: dbTypeColorMap.mysql, label: 'MySQL' },
|
||||
postgres: { icon: PostgreSQLIcon, badge: dbTypeColorMap.postgres, label: 'PostgreSQL' },
|
||||
postgresql: { icon: PostgreSQLIcon, badge: dbTypeColorMap.postgresql, label: 'PostgreSQL' },
|
||||
pg: { icon: PostgreSQLIcon, badge: dbTypeColorMap.pg, label: 'PostgreSQL' },
|
||||
redis: { icon: RedisIcon, badge: dbTypeColorMap.redis, label: 'Redis' },
|
||||
mongo: { icon: MongoDBIcon, badge: dbTypeColorMap.mongo, label: 'MongoDB' },
|
||||
};
|
||||
|
||||
/** 按类型取展示配置,未知类型回退 MySQL */
|
||||
export function resolveDbTypeConfig(dbType: string): DbTypeConfig {
|
||||
return dbTypeConfig[dbType] || dbTypeConfig.mysql;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 批量操作条:选中计数、全选/取消全选切换、批量删除按钮。
|
||||
* 必需 props:selectedCount/total 计数、canDelete 权限、onSelectAll/onBatchDelete 回调;deleting 可选(默认 false)。
|
||||
* 已知限制:仅应在 selectedCount > 0 时由父级渲染,空态展示逻辑不在组件内。
|
||||
*/
|
||||
import { Trash2, Loader2 } from 'lucide-react';
|
||||
|
||||
interface InstanceBatchBarProps {
|
||||
selectedCount: number;
|
||||
total: number;
|
||||
canDelete: boolean;
|
||||
deleting: boolean;
|
||||
onSelectAll: () => void;
|
||||
onBatchDelete: () => void;
|
||||
}
|
||||
|
||||
export function InstanceBatchBar({
|
||||
selectedCount,
|
||||
total,
|
||||
canDelete,
|
||||
deleting,
|
||||
onSelectAll,
|
||||
onBatchDelete,
|
||||
}: InstanceBatchBarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-blue-400 font-medium">已选择 {selectedCount} 个实例</span>
|
||||
<button
|
||||
onClick={onSelectAll}
|
||||
className="text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{selectedCount === total ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={onBatchDelete}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-500/15 text-red-400 border border-red-500/30 rounded-md hover:bg-red-500/25 transition-all disabled:opacity-50"
|
||||
>
|
||||
{deleting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
|
||||
{deleting ? '删除中...' : '批量删除'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 实例详情视图:头部操作栏(返回/测试连接/克隆/编辑/删除)、健康评分环形图、性能指标与连接信息。
|
||||
* 必需 props:instance 实例数据、config 类型展示配置、onBack/onDelete/onEdit 回调、canEdit/canDelete 权限;onClone 可选(无新增权限时不渲染)。
|
||||
* 已知限制:指标仅在进入详情时拉取一次,不自动刷新;测试连接结果不落库,仅本组件内展示。
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ChevronLeft, Wifi, WifiOff, Zap, Copy, Edit2, Trash2,
|
||||
CheckCircle, XCircle, Gauge, BarChart3, Users, Activity, Signal, HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import type { InstanceItem, DbTypeConfig } from '../types';
|
||||
import { envLabel, getStatusInfo, calcHealthScore, healthColor, healthLabel } from '../utils';
|
||||
|
||||
interface InstanceDetailProps {
|
||||
instance: InstanceItem;
|
||||
onBack: () => void;
|
||||
config: DbTypeConfig;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
onClone?: () => void;
|
||||
}
|
||||
|
||||
export function InstanceDetail({
|
||||
instance,
|
||||
onBack,
|
||||
config,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onClone,
|
||||
}: InstanceDetailProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
|
||||
const [metrics, setMetrics] = useState<{ connections: number; qps: number; latency: number; uptime: string; cpu?: number; memory?: number; disk?: number; used_memory_human?: string; threads_running?: number; max_connections?: number; slow_queries?: number; data_size_mb?: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
instanceApi.metrics(instance.id).then(res => {
|
||||
setMetrics({ connections: res.connections ?? 0, qps: res.qps ?? 0, latency: res.latency ?? 0, uptime: res.uptime ?? '-', cpu: res.cpu, memory: res.memory, disk: res.disk, used_memory_human: res.used_memory_human, threads_running: res.threads_running, max_connections: res.max_connections, slow_queries: res.slow_queries, data_size_mb: res.data_size_mb });
|
||||
}).catch(() => {});
|
||||
}, [instance.id]);
|
||||
|
||||
const health = calcHealthScore(instance);
|
||||
const info = getStatusInfo(instance.status);
|
||||
const DetailIcon = config.icon;
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await instanceApi.testConnection(instance.id);
|
||||
setTestResult(res as { success: boolean; message: string; version?: string });
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败';
|
||||
setTestResult({ success: false, message: msg });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [instance.id]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl ${config.badge} flex items-center justify-center`}>
|
||||
<DetailIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-3">
|
||||
{instance.name}
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${info.online ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{info.text}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">{instance.host}:{instance.port} · {instance.version || '版本未知'}{instance.cluster_mode ? ' · 集群' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
{onClone && (
|
||||
<button onClick={onClone} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<Copy className="w-4 h-4" />
|
||||
克隆
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={onEdit} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-red-400 hover:bg-red-500/10 hover:border-red-500/30 transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Test Result */}
|
||||
{testResult && (
|
||||
<Card className={`dg-card ${testResult.success ? 'border-green-500/30' : 'border-red-500/30'}`}>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${testResult.success ? 'bg-green-500/15' : 'bg-red-500/15'}`}>
|
||||
{testResult.success ? <CheckCircle className="w-4 h-4 text-green-400" /> : <XCircle className="w-4 h-4 text-red-400" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>{testResult.message}</div>
|
||||
{testResult.version && <div className="text-xs text-[var(--dg-text-muted)]">版本: {testResult.version}</div>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Health + Metrics */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Health Score Card */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
|
||||
健康评分
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="relative w-28 h-28">
|
||||
<svg className="w-28 h-28 -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--dg-bg-tertiary)" strokeWidth="8" />
|
||||
<circle
|
||||
cx="50" cy="50" r="42" fill="none"
|
||||
stroke={health >= 90 ? 'var(--dg-green)' : health >= 70 ? 'var(--dg-yellow)' : health >= 50 ? 'var(--dg-orange)' : 'var(--dg-red)'}
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(health / 100) * 264} 264`}
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-2xl font-bold ${healthColor(health)}`}>{health}</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">{healthLabel(health)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const isRedis = instance.db_type === 'redis';
|
||||
// Dynamic metric items based on db_type
|
||||
const items = isRedis ? [
|
||||
{ label: 'CPU 使用率', value: metrics?.cpu, text: undefined, color: (metrics?.cpu ?? 0) > 80 ? 'bg-red-400' : (metrics?.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' },
|
||||
{ label: '内存使用率', value: metrics?.memory, text: metrics?.used_memory_human, color: (metrics?.memory ?? 0) > 85 ? 'bg-red-400' : (metrics?.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' },
|
||||
] : [
|
||||
{ label: '连接使用率', value: metrics?.cpu, text: undefined, color: (metrics?.cpu ?? 0) > 80 ? 'bg-red-400' : (metrics?.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' },
|
||||
{ label: 'Buffer Pool', value: metrics?.memory, text: undefined, color: (metrics?.memory ?? 0) > 85 ? 'bg-red-400' : (metrics?.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' },
|
||||
{ label: '慢查询', value: undefined, text: metrics?.slow_queries != null ? String(metrics.slow_queries) + ' 条' : undefined, color: '' },
|
||||
];
|
||||
return items.filter(item => item.value != null || item.text != null).map(item => {
|
||||
const hasPercent = item.value != null && item.value >= 0;
|
||||
const pct = item.value ?? 0;
|
||||
return (
|
||||
<div key={item.label}>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-[var(--dg-text-muted)]">{item.label}</span>
|
||||
<span className="font-medium">{hasPercent ? `${pct}%` : (item.text || 'N/A')}</span>
|
||||
</div>
|
||||
{hasPercent && (
|
||||
<div className="h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${item.color}`} style={{ width: `${Math.max(pct, 1)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<Card className="dg-card lg:col-span-2">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-blue-400" />
|
||||
性能指标
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ label: '连接数', value: metrics?.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: 'QPS', value: metrics?.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '延迟', value: metrics?.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||
{ label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-cyan-400', bg: 'bg-cyan-400/10' },
|
||||
].map(m => (
|
||||
<div key={m.label} className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-center">
|
||||
<div className={`w-8 h-8 rounded-lg ${m.bg} flex items-center justify-center mx-auto mb-2`}>
|
||||
<m.icon className={`w-4 h-4 ${m.color}`} />
|
||||
</div>
|
||||
<div className="text-lg font-bold">
|
||||
{typeof m.value === 'number' ? m.value.toLocaleString() : m.value}
|
||||
<span className="text-xs font-normal text-[var(--dg-text-muted)]">{m.unit}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">{m.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Connection Info */}
|
||||
<Card className="border border-[var(--dg-border)]">
|
||||
<CardHeader className="border-b border-[var(--dg-border-light)] py-3">
|
||||
<CardTitle className="text-sm font-semibold">连接信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
{[
|
||||
{ label: '实例名称', value: instance.name },
|
||||
{ label: '主机地址', value: instance.host },
|
||||
{ label: '端口', value: instance.port.toString() },
|
||||
{ label: '用户名', value: instance.username },
|
||||
{ label: '默认数据库', value: instance.database || '-' },
|
||||
{ label: '运行环境', value: envLabel[instance.env] || instance.env },
|
||||
{ label: '版本', value: `${instance.version || '-'}${instance.cluster_mode ? ' (集群)' : ''}` },
|
||||
{ label: '创建时间', value: new Date(instance.created_at).toLocaleString() },
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex justify-between py-1.5 border-b border-[var(--dg-border-light)] last:border-0">
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<span className="text-xs font-medium">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{instance.description && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-sm font-semibold">描述</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">{instance.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 实例列表筛选栏:关键字搜索 + 状态/环境下拉 + 清除筛选。
|
||||
* 必需 props:searchQuery/filterStatus/filterEnv 当前值,对应变更回调,onClear 清除回调。
|
||||
* 已知限制:受控组件,筛选状态由父级持有(已迁移至 URL query),自身不存状态。
|
||||
*/
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
interface InstanceFilterBarProps {
|
||||
searchQuery: string;
|
||||
filterStatus: string;
|
||||
filterEnv: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onEnvChange: (value: string) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function InstanceFilterBar({
|
||||
searchQuery,
|
||||
filterStatus,
|
||||
filterEnv,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
onEnvChange,
|
||||
onClear,
|
||||
}: InstanceFilterBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none transition-colors"
|
||||
placeholder="搜索实例名称/地址"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="px-3 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"
|
||||
value={filterStatus}
|
||||
onChange={e => onStatusChange(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
<option value="error">异常</option>
|
||||
</select>
|
||||
<select
|
||||
className="px-3 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"
|
||||
value={filterEnv}
|
||||
onChange={e => onEnvChange(e.target.value)}
|
||||
>
|
||||
<option value="">全部环境</option>
|
||||
<option value="production">生产</option>
|
||||
<option value="staging">预发</option>
|
||||
<option value="development">开发</option>
|
||||
<option value="test">测试</option>
|
||||
<option value="uat">UAT</option>
|
||||
</select>
|
||||
{(searchQuery || filterStatus || filterEnv) && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 实例网格视图(卡片模式):品牌图标、环境/状态徽标、健康评分条、核心指标与悬浮操作(克隆/编辑)。
|
||||
* 必需 props:instances 数据、selectedIds 选中集合、canAdd/canEdit 权限、onOpenDetail/onToggleSelect/onClone/onEdit 回调。
|
||||
* 已知限制:纯展示组件,数据过滤与健康分计算所需数据由父级提供,指标字段缺失时按原样展示。
|
||||
*/
|
||||
import {
|
||||
CheckCircle, Wifi, WifiOff, Gauge, Users, Activity, Signal,
|
||||
ChevronRight, Copy, Edit2,
|
||||
} from 'lucide-react';
|
||||
import type { InstanceItem } from '../types';
|
||||
import { resolveDbTypeConfig } from './DbTypeIcons';
|
||||
import {
|
||||
envLabel, envColor, getStatusInfo, calcHealthScore, healthColor, healthLabel,
|
||||
} from '../utils';
|
||||
|
||||
interface InstanceGridProps {
|
||||
instances: InstanceItem[];
|
||||
selectedIds: Set<number>;
|
||||
canAdd: boolean;
|
||||
canEdit: boolean;
|
||||
onOpenDetail: (instance: InstanceItem) => void;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onClone: (instance: InstanceItem) => void;
|
||||
onEdit: (instance: InstanceItem) => void;
|
||||
}
|
||||
|
||||
export function InstanceGrid({
|
||||
instances,
|
||||
selectedIds,
|
||||
canAdd,
|
||||
canEdit,
|
||||
onOpenDetail,
|
||||
onToggleSelect,
|
||||
onClone,
|
||||
onEdit,
|
||||
}: InstanceGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{instances.map(instance => {
|
||||
const ic = resolveDbTypeConfig(instance.db_type);
|
||||
const IconComp = ic.icon;
|
||||
const health = calcHealthScore(instance);
|
||||
const info = getStatusInfo(instance.status);
|
||||
const isSelected = selectedIds.has(instance.id);
|
||||
return (
|
||||
<div
|
||||
key={instance.id}
|
||||
className={`dg-card p-3.5 cursor-pointer dg-card-hover group relative transition-all ${
|
||||
isSelected ? 'ring-2 ring-blue-500/50' : ''
|
||||
}`}
|
||||
onClick={() => onOpenDetail(instance)}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
<div
|
||||
className="absolute top-3 left-3 z-10"
|
||||
onClick={e => { e.stopPropagation(); onToggleSelect(instance.id); }}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all ${
|
||||
isSelected ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)] hover:border-blue-400'
|
||||
}`}>
|
||||
{isSelected && <CheckCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between mb-4 pl-6">
|
||||
<div className={`w-9 h-9 rounded-lg ${ic.badge} flex items-center justify-center`}>
|
||||
<IconComp className="w-5 h-5 object-contain" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'}`}>
|
||||
{envLabel[instance.env] || instance.env}
|
||||
</span>
|
||||
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium ${
|
||||
info.online ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'
|
||||
}`}>
|
||||
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{info.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mb-0.5 truncate">{instance.name}</h3>
|
||||
<p className="text-[11px] text-[var(--dg-text-muted)] mb-0.5">{instance.host}:{instance.port}</p>
|
||||
<p className="text-[11px] text-[var(--dg-text-muted)] mb-2">
|
||||
{ic.label} {instance.version || ''}
|
||||
</p>
|
||||
|
||||
{/* Health Score */}
|
||||
<div className="flex items-center gap-2 mb-2 p-1.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">健康评分</span>
|
||||
<span className={`text-xs font-bold ${healthColor(health)}`}>{health} <span className="font-normal text-[var(--dg-text-muted)]">{healthLabel(health)}</span></span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[var(--dg-bg-primary)] mt-1 overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${
|
||||
health >= 90 ? 'bg-green-400' : health >= 70 ? 'bg-yellow-400' : health >= 50 ? 'bg-orange-400' : 'bg-red-400'
|
||||
}`} style={{ width: `${health}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-3 gap-1.5 text-center">
|
||||
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-[11px] font-semibold">{instance.connections}</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">连接</span>
|
||||
</div>
|
||||
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Activity className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-[11px] font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">QPS</span>
|
||||
</div>
|
||||
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Signal className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-[11px] font-semibold">{instance.latency}ms</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">延迟</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-between">
|
||||
<span className="text-[11px] text-blue-400 flex items-center gap-1">
|
||||
查看详情
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</span>
|
||||
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
||||
{canAdd && (
|
||||
<button
|
||||
onClick={() => onClone(instance)}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="克隆实例"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(instance)}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="编辑实例"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 实例状态总览卡片行:在线/离线/总实例数/平均健康分。
|
||||
* 必需 props:online/offline/total/avgHealth 四个统计值。
|
||||
* 已知限制:纯展示组件,统计口径由父级(InstancePage)计算完成。
|
||||
*/
|
||||
import { Wifi, WifiOff, Server, Gauge } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { healthColor, healthBg } from '../utils';
|
||||
|
||||
interface InstanceStatsOverviewProps {
|
||||
online: number;
|
||||
offline: number;
|
||||
total: number;
|
||||
avgHealth: number;
|
||||
}
|
||||
|
||||
export function InstanceStatsOverview({ online, offline, total, avgHealth }: InstanceStatsOverviewProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||
<Wifi className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{online}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">在线实例</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center">
|
||||
<WifiOff className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{offline}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">离线实例</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{total}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">总实例数</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${healthBg(avgHealth)} flex items-center justify-center`}>
|
||||
<Gauge className={`w-5 h-5 ${healthColor(avgHealth)}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-2xl font-bold ${healthColor(avgHealth)}`}>{avgHealth}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">平均健康分</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 实例表格视图(列表模式):全选表头 + 名称/类型/地址/环境/状态/健康分/连接数/操作列。
|
||||
* 必需 props:instances 数据、selectedIds 选中集合、allSelected 全选态、canAdd 权限、onOpenDetail/onToggleSelect/onSelectAll/onClone 回调。
|
||||
* 已知限制:纯展示组件,过滤与分页(如有)由父级负责;操作列仅含查看与克隆。
|
||||
*/
|
||||
import { CheckCircle, Wifi, WifiOff, Copy } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { InstanceItem } from '../types';
|
||||
import { resolveDbTypeConfig } from './DbTypeIcons';
|
||||
import { envLabel, envColor, getStatusInfo, calcHealthScore, healthColor } from '../utils';
|
||||
|
||||
interface InstanceTableProps {
|
||||
instances: InstanceItem[];
|
||||
selectedIds: Set<number>;
|
||||
allSelected: boolean;
|
||||
canAdd: boolean;
|
||||
onOpenDetail: (instance: InstanceItem) => void;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onSelectAll: () => void;
|
||||
onClone: (instance: InstanceItem) => void;
|
||||
}
|
||||
|
||||
export function InstanceTable({
|
||||
instances,
|
||||
selectedIds,
|
||||
allSelected,
|
||||
canAdd,
|
||||
onOpenDetail,
|
||||
onToggleSelect,
|
||||
onSelectAll,
|
||||
onClone,
|
||||
}: InstanceTableProps) {
|
||||
return (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-4 py-3 w-8">
|
||||
<div
|
||||
onClick={onSelectAll}
|
||||
className={`w-4 h-4 rounded border-2 flex items-center justify-center cursor-pointer transition-all ${
|
||||
allSelected ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)] hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
{allSelected && <CheckCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">实例名称</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">类型</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">地址</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">环境</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">健康分</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">连接/QPS</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{instances.map(instance => {
|
||||
const ic = resolveDbTypeConfig(instance.db_type);
|
||||
const IconComp = ic.icon;
|
||||
const health = calcHealthScore(instance);
|
||||
const info = getStatusInfo(instance.status);
|
||||
const isSelected = selectedIds.has(instance.id);
|
||||
return (
|
||||
<tr
|
||||
key={instance.id}
|
||||
className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer"
|
||||
onClick={() => onOpenDetail(instance)}
|
||||
>
|
||||
<td className="px-4 py-3" onClick={e => { e.stopPropagation(); onToggleSelect(instance.id); }}>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all ${
|
||||
isSelected ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)]'
|
||||
}`}>
|
||||
{isSelected && <CheckCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{instance.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`flex items-center gap-1.5 ${ic.badge} px-1.5 rounded-md`}>
|
||||
<IconComp className="w-4 h-4 object-contain" />
|
||||
<span className="text-xs">{ic.label}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--dg-text-muted)]">{instance.host}:{instance.port}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || ''}`}>
|
||||
{envLabel[instance.env] || instance.env}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium ${info.online ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{info.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs font-bold ${healthColor(health)}`}>{health}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>{instance.connections} 连接</span>
|
||||
<span>{instance.qps?.toLocaleString()} QPS</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onOpenDetail(instance); }}
|
||||
className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
{canAdd && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onClone(instance); }}
|
||||
className="text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="克隆实例"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 快速导入数据源弹窗:粘贴 CSV 文本批量创建实例,含字段说明、解析预览与逐行导入结果。
|
||||
* 必需 props:onClose 关闭回调、onImported 全部成功后的导入完成回调。
|
||||
* 已知限制:CSV 解析为简单逗号切分,不支持引号转义/换行字段;部分失败时不自动关闭弹窗。
|
||||
*/
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { DB_DEFAULT_PORTS } from '../utils';
|
||||
|
||||
/** 一行导入数据(与 CSV 列顺序对应) */
|
||||
type ImportRow = {
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
env?: string;
|
||||
access_mode?: string;
|
||||
cluster_mode?: boolean;
|
||||
};
|
||||
|
||||
/** 导入模板(含表头,便于用户对照列顺序) */
|
||||
const IMPORT_TEMPLATE = `name,db_type,host,port,username,password,database,env,access_mode,cluster_mode
|
||||
prod-mysql,mysql,10.0.0.10,3306,root,secret,app,production,readwrite,false
|
||||
prod-pg,postgres,10.0.0.11,5432,postgres,secret,app,production,read,false
|
||||
prod-redis,redis,10.0.0.12,6379,,secret,,production,read,true
|
||||
prod-mongo,mongo,10.0.0.13,27017,root,secret,admin,production,readwrite,false`;
|
||||
|
||||
/** 解析 CSV 文本为导入行;首行若以 name, 开头则视为表头跳过 */
|
||||
function parseQuickImport(text: string): ImportRow[] {
|
||||
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return [];
|
||||
const hasHeader = lines[0].toLowerCase().startsWith('name,');
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
return dataLines.map(line => {
|
||||
const parts = line.split(',').map(item => item.trim());
|
||||
const [name, dbType, host, port, username, password, database, env, accessMode, clusterMode] = parts;
|
||||
return {
|
||||
name,
|
||||
db_type: dbType,
|
||||
host,
|
||||
port: Number(port || DB_DEFAULT_PORTS[dbType] || 3306),
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
database: database || undefined,
|
||||
env: env || 'development',
|
||||
access_mode: accessMode || 'readwrite',
|
||||
cluster_mode: clusterMode === 'true' || clusterMode === '1' || clusterMode === 'yes',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface QuickImportDialogProps {
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
export function QuickImportDialog({ onClose, onImported }: QuickImportDialogProps) {
|
||||
const [text, setText] = useState(IMPORT_TEMPLATE);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ success: number; failed: string[] } | null>(null);
|
||||
const rows = useMemo(() => parseQuickImport(text), [text]);
|
||||
const validRows = rows.filter(row => row.name && row.db_type && row.host && row.port);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
setSubmitting(true);
|
||||
const failed: string[] = [];
|
||||
let success = 0;
|
||||
for (const row of validRows) {
|
||||
try {
|
||||
await instanceApi.create({
|
||||
...row,
|
||||
username: row.db_type === 'redis' ? row.username : (row.username || 'default'),
|
||||
password: row.password || '',
|
||||
cluster_mode: row.db_type === 'redis' ? row.cluster_mode : undefined,
|
||||
});
|
||||
success += 1;
|
||||
} catch (e: unknown) {
|
||||
const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '创建失败';
|
||||
failed.push(`${row.name || row.host}: ${detail}`);
|
||||
}
|
||||
}
|
||||
setResult({ success, failed });
|
||||
setSubmitting(false);
|
||||
if (success > 0 && failed.length === 0) onImported();
|
||||
}, [validRows, onImported]);
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-4xl">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg shadow-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">快速导入数据源</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">支持 MySQL、PostgreSQL、Redis、MongoDB。每行一个实例,逗号分隔。</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
className="w-full min-h-[220px] px-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs font-mono focus:border-blue-400 focus:outline-none resize-y"
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className="text-sm font-medium mb-2">字段说明</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] leading-6">
|
||||
name, db_type, host, port, username, password, database, env, access_mode, cluster_mode
|
||||
<br />
|
||||
db_type 使用 mysql / postgres / redis / mongo;Redis 集群将 cluster_mode 写 true。
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className="text-sm font-medium mb-2">预览</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">
|
||||
共解析 {rows.length} 行,可导入 {validRows.length} 行。
|
||||
</div>
|
||||
<div className="mt-2 max-h-24 overflow-y-auto space-y-1">
|
||||
{validRows.slice(0, 5).map(row => (
|
||||
<div key={`${row.name}-${row.host}`} className="text-xs">
|
||||
{row.name} · {row.db_type} · {row.host}:{row.port}{row.db_type === 'redis' && row.cluster_mode ? ' · 集群' : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<div className={`p-3 rounded-lg text-sm ${result.failed.length === 0 ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>
|
||||
成功 {result.success} 个,失败 {result.failed.length} 个
|
||||
{result.failed.length > 0 && (
|
||||
<div className="mt-2 text-xs space-y-1">
|
||||
{result.failed.map(item => <div key={item}>{item}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)]">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={submitting || validRows.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
导入 {validRows.length} 个
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 实例列表数据 Hook:拉取 /instances 全量列表并维护 loading 状态,挂载时自动加载一次。
|
||||
* 用途:为 InstancePage 提供实例数据 instances、加载态 loading 与手动刷新 fetchInstances。
|
||||
* 已知限制:请求失败时静默保留旧数据不抛错;全量拉取,无分页/轮询。
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import type { InstanceItem } from '../types';
|
||||
|
||||
export function useInstanceList() {
|
||||
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchInstances = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await instanceApi.list({});
|
||||
const data = Array.isArray(res) ? res : [];
|
||||
setInstances(data);
|
||||
} catch {
|
||||
/* keep existing */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchInstances(); }, [fetchInstances]);
|
||||
|
||||
return { instances, loading, fetchInstances };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 实例多选 Hook:维护选中 id 集合,提供单选切换/全选切换/清空能力,供批量操作使用。
|
||||
* 用途:入参为当前可见(过滤后)实例列表,selectAll 以其长度判断全选态。
|
||||
* 已知限制:过滤条件变化后选中集不会自动收敛,可能包含已不可见实例的 id。
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { InstanceItem } from '../types';
|
||||
|
||||
export function useInstanceSelection(visibleInstances: InstanceItem[]) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggleSelect = useCallback((id: number) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
if (selectedIds.size === visibleInstances.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(visibleInstances.map(f => f.id)));
|
||||
}
|
||||
}, [visibleInstances, selectedIds.size]);
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
|
||||
|
||||
return { selectedIds, toggleSelect, selectAll, clearSelection };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 数据源域共享类型定义。
|
||||
* 用途:实例列表/详情/创建表单共用的数据结构,字段与 instanceApi(/instances)返回保持一致。
|
||||
* 已知限制:connections/qps/cpu 等指标字段依赖后端采集,可能为 undefined,展示层需自行兜底。
|
||||
*/
|
||||
import type { ElementType } from 'react';
|
||||
|
||||
/** 实例列表项(与 instanceApi.list 返回结构一致) */
|
||||
export interface InstanceItem {
|
||||
id: number;
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
database: string | null;
|
||||
access_mode?: string;
|
||||
env: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
version: string | null;
|
||||
cluster_mode?: boolean;
|
||||
created_at: string;
|
||||
connections?: number;
|
||||
qps?: number;
|
||||
latency?: number;
|
||||
cpu?: number;
|
||||
memory?: number;
|
||||
disk?: number;
|
||||
}
|
||||
|
||||
/** 列表展示模式:网格 / 表格 */
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
|
||||
/** 数据库类型的展示配置(品牌图标 + 徽标底色 + 展示名) */
|
||||
export interface DbTypeConfig {
|
||||
icon: ElementType;
|
||||
badge: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 数据源域纯工具函数:状态/环境映射、列表筛选与健康分计算。
|
||||
* 用途:供 InstancePage 及 features/datasource/components 下各组件共享,无副作用、不依赖 React。
|
||||
* 已知限制:健康分为客户端按指标阈值估算的结果,并非后端权威评分。
|
||||
*/
|
||||
import type { InstanceItem } from './types';
|
||||
|
||||
/** 各数据库类型默认端口 */
|
||||
export const DB_DEFAULT_PORTS: Record<string, number> = {
|
||||
mysql: 3306, postgres: 5432, redis: 6379, mongo: 27017,
|
||||
};
|
||||
|
||||
/** 环境展示名 */
|
||||
export const envLabel: Record<string, string> = {
|
||||
production: '生产', staging: '预发', development: '开发', test: '测试', testing: '测试', uat: 'UAT',
|
||||
};
|
||||
|
||||
/** 环境徽标配色 */
|
||||
export const envColor: Record<string, string> = {
|
||||
production: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
staging: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
development: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
test: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
testing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
uat: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
|
||||
};
|
||||
|
||||
/** 状态展示信息(在线/离线/异常文案) */
|
||||
const statusLabel: Record<string, { text: string; online: boolean }> = {
|
||||
connected: { text: '在线', online: true },
|
||||
online: { text: '在线', online: true },
|
||||
active: { text: '在线', online: true },
|
||||
disconnected: { text: '离线', online: false },
|
||||
offline: { text: '离线', online: false },
|
||||
error: { text: '异常', online: false },
|
||||
};
|
||||
|
||||
/** 取状态展示信息,未知状态回退「未知」 */
|
||||
export function getStatusInfo(status: string) {
|
||||
return statusLabel[status] || { text: '未知', online: false };
|
||||
}
|
||||
|
||||
/** 是否离线状态 */
|
||||
export function isOfflineStatus(status: string): boolean {
|
||||
return status === 'offline' || status === 'disconnected';
|
||||
}
|
||||
|
||||
/** 状态筛选匹配(online/offline 归并为一类) */
|
||||
export function matchesStatusFilter(status: string, filterStatus: string): boolean {
|
||||
if (!filterStatus) return true;
|
||||
const info = getStatusInfo(status);
|
||||
if (filterStatus === 'online') return info.online;
|
||||
if (filterStatus === 'offline') return isOfflineStatus(status);
|
||||
return status === filterStatus;
|
||||
}
|
||||
|
||||
/** 环境筛选匹配(test/testing 归并为一类) */
|
||||
export function matchesEnvFilter(env: string, filterEnv: string): boolean {
|
||||
if (!filterEnv) return true;
|
||||
if (filterEnv === 'test') return env === 'test' || env === 'testing';
|
||||
return env === filterEnv;
|
||||
}
|
||||
|
||||
/** 计算实例健康分(0-100) */
|
||||
export function calcHealthScore(inst: InstanceItem): number {
|
||||
if (inst.status === 'offline' || inst.status === 'disconnected' || inst.status === 'error') return 0;
|
||||
let score = 100;
|
||||
const cpu = inst.cpu ?? 0;
|
||||
const memory = inst.memory ?? 0;
|
||||
const latency = inst.latency ?? 0;
|
||||
const disk = inst.disk ?? 0;
|
||||
|
||||
if (cpu > 80) score -= 25;
|
||||
else if (cpu > 60) score -= 10;
|
||||
if (memory > 85) score -= 20;
|
||||
else if (memory > 70) score -= 10;
|
||||
if (latency > 10) score -= 20;
|
||||
else if (latency > 5) score -= 10;
|
||||
if (disk > 80) score -= 15;
|
||||
return Math.max(0, score);
|
||||
}
|
||||
|
||||
/** 健康分文字配色 */
|
||||
export function healthColor(score: number): string {
|
||||
if (score >= 90) return 'text-green-400';
|
||||
if (score >= 70) return 'text-yellow-400';
|
||||
if (score >= 50) return 'text-orange-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
/** 健康分背景配色 */
|
||||
export function healthBg(score: number): string {
|
||||
if (score >= 90) return 'bg-green-500/15';
|
||||
if (score >= 70) return 'bg-yellow-500/15';
|
||||
if (score >= 50) return 'bg-orange-500/15';
|
||||
return 'bg-red-500/15';
|
||||
}
|
||||
|
||||
/** 健康分档位文案 */
|
||||
export function healthLabel(score: number): string {
|
||||
if (score >= 90) return '健康';
|
||||
if (score >= 70) return '良好';
|
||||
if (score >= 50) return '一般';
|
||||
return '危险';
|
||||
}
|
||||
|
||||
/** 列表筛选:按类型/状态/环境/关键字过滤,与页面原过滤逻辑一致 */
|
||||
export function filterInstances(
|
||||
instances: InstanceItem[],
|
||||
criteria: { activeType: string; filterStatus: string; filterEnv: string; searchQuery: string },
|
||||
): InstanceItem[] {
|
||||
const { activeType, filterStatus, filterEnv, searchQuery } = criteria;
|
||||
return instances.filter(i => {
|
||||
if (activeType !== 'all' && (activeType === 'postgres' ? !['postgres', 'postgresql', 'pg'].includes(i.db_type) : i.db_type !== activeType)) return false;
|
||||
if (!matchesStatusFilter(i.status, filterStatus)) return false;
|
||||
if (!matchesEnvFilter(i.env, filterEnv)) return false;
|
||||
if (searchQuery && !i.name.toLowerCase().includes(searchQuery.toLowerCase()) && !i.host.includes(searchQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* UserDropdown — 审批人下拉多选组件 */
|
||||
/* fixed 定位下拉以避免被父容器 overflow 裁剪 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import { useState, useRef } from 'react';
|
||||
import { ChevronDown, X, CheckCircle } from 'lucide-react';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface UserDropdownProps {
|
||||
users: UserItem[];
|
||||
selectedIds: number[];
|
||||
label: string;
|
||||
onToggle: (uid: number) => void;
|
||||
onToggleAll: () => void;
|
||||
onClear: () => void;
|
||||
getUserName: (uid: number) => string;
|
||||
}
|
||||
|
||||
export function UserDropdown({ users, selectedIds, label, onToggle, onToggleAll, onClear, getUserName }: UserDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const ids = Array.isArray(selectedIds) ? selectedIds : [];
|
||||
const allSelected = ids.length === users.length && users.length > 0;
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
|
||||
const updatePos = () => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!open) updatePos();
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Trigger */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedIds.length === 0
|
||||
? 'bg-[var(--dg-bg-tertiary)] border border-red-500/40 text-[var(--dg-text-muted)]'
|
||||
: 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{ids.length === 0
|
||||
? `${label} * 请选择`
|
||||
: ids.length === 1
|
||||
? getUserName(ids[0])
|
||||
: `${label} (${ids.length}人)`
|
||||
}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Selected tags */}
|
||||
{ids.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{ids.map((uid) => (
|
||||
<span
|
||||
key={uid}
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 text-blue-400"
|
||||
>
|
||||
{getUserName(uid)}
|
||||
<button onClick={() => onToggle(uid)} className="hover:text-red-400 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown — fixed positioning to escape overflow clipping */}
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="fixed z-50 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl max-h-52 overflow-y-auto"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width }}
|
||||
>
|
||||
{/* Select all / Clear */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--dg-border)]">
|
||||
<button
|
||||
onClick={onToggleAll}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
{selectedIds.length > 0 && (
|
||||
<button onClick={onClear} className="text-[11px] text-red-400 hover:text-red-300 transition-colors">
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* User list */}
|
||||
{users.map((u) => {
|
||||
const checked = selectedIds.includes(u.id);
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
onClick={() => onToggle(u.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
checked
|
||||
? 'bg-blue-500 border-blue-500'
|
||||
: 'border-[var(--dg-border)]'
|
||||
}`}>
|
||||
{checked && <CheckCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className={`text-sm flex-1 ${checked ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-secondary)]'}`}>
|
||||
{u.display_name || u.username}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">
|
||||
{u.role}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WorkflowCard — 工作流列表卡片 */
|
||||
/* 展示流程信息、流程图可视化、可展开的步骤详情与操作按钮 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import {
|
||||
Edit2, Eye, ToggleLeft, ToggleRight, GitBranch,
|
||||
ArrowRight, Shield, Trash2, Copy, Star, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { STEP_TYPE_LABELS, STEP_TYPE_COLORS, STEP_TYPE_ICONS } from '../constants';
|
||||
import type { WorkflowDetail } from '../types';
|
||||
|
||||
interface WorkflowCardProps {
|
||||
workflow: WorkflowDetail;
|
||||
isExpanded: boolean;
|
||||
getUserName: (uid: number) => string;
|
||||
onEdit: (wf: WorkflowDetail) => void;
|
||||
onPreview: (wf: WorkflowDetail) => void;
|
||||
onClone: (id: number) => void;
|
||||
onSetDefault: (id: number) => void;
|
||||
onToggleActive: (wf: WorkflowDetail) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onToggleExpand: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export function WorkflowCard({
|
||||
workflow: wf,
|
||||
isExpanded,
|
||||
getUserName,
|
||||
onEdit,
|
||||
onPreview,
|
||||
onClone,
|
||||
onSetDefault,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
onToggleExpand,
|
||||
}: WorkflowCardProps) {
|
||||
return (
|
||||
<Card className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h3 className="text-lg font-semibold">{wf.name}</h3>
|
||||
{wf.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-yellow-500/10 text-yellow-400 text-xs rounded">
|
||||
<Star className="w-3 h-3" /> 默认
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${wf.is_active ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||
{wf.is_active ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-xs rounded">
|
||||
{(wf.steps || []).length} 个阶段
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{wf.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-3">{wf.description}</p>}
|
||||
|
||||
{/* Flow visualization */}
|
||||
<div className="flex items-center gap-1 mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg overflow-x-auto">
|
||||
{(wf.steps || []).map((step, i) => {
|
||||
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
let uids: number[] = [];
|
||||
try { const parsed = step.approver_ids ? JSON.parse(step.approver_ids) : null; if (Array.isArray(parsed)) uids = parsed; } catch {}
|
||||
const stepsArr = wf.steps || [];
|
||||
return (
|
||||
<div key={step.id || i} className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className={`px-3 py-2 rounded-lg border ${STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial} flex items-center gap-2`}>
|
||||
<StepIcon className="w-4 h-4" />
|
||||
<div>
|
||||
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
|
||||
{uids.length > 0 && (
|
||||
<div className="text-[10px] opacity-80 whitespace-nowrap">
|
||||
{uids.map((uid) => getUserName(uid)).join('、')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{i < stepsArr.length - 1 && <ArrowRight className="w-4 h-4 text-[var(--dg-text-muted)] flex-shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-3 pt-4 border-t border-[var(--dg-border)]">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<GitBranch className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
流程步骤详情
|
||||
</h4>
|
||||
{(wf.steps || []).map((step, si) => {
|
||||
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
|
||||
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
|
||||
return (
|
||||
<div key={step.id || si} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
|
||||
<StepIcon className={`w-4 h-4 ${colorClass.split(' ')[1]}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-sm font-medium">{step.name}</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded ${colorClass}`}>
|
||||
{STEP_TYPE_LABELS[step.step_type] || step.step_type}
|
||||
</span>
|
||||
</div>
|
||||
{uids.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">关联人员:</span>
|
||||
{uids.map((uid) => (
|
||||
<span key={uid} className="text-[11px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
|
||||
{getUserName(uid)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 lg:ml-4 shrink-0 flex-wrap">
|
||||
<button
|
||||
onClick={() => onEdit(wf)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{!wf.is_default && (
|
||||
<button onClick={() => onSetDefault(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-yellow-500/10 hover:text-yellow-400 transition-colors" title="设为默认">
|
||||
<Star className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onPreview(wf)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors" title="预览">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onClone(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors" title="克隆">
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onToggleActive(wf)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={wf.is_active ? '禁用' : '启用'}>
|
||||
{wf.is_active ? <ToggleRight className="w-5 h-5 text-green-400" /> : <ToggleLeft className="w-5 h-5 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
<button onClick={() => onDelete(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onToggleExpand(isExpanded ? null : wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="展开">
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WorkflowEditorModal — 创建/编辑审批流程弹框 */
|
||||
/* 包含步骤草稿编辑、审批人按角色筛选(getEligibleUsers)与提交校验 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Plus, X, Shield, Trash2, ArrowUp, ArrowDown,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { workflowApi } from '@/api/workflow';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { STEP_TYPES, STEP_TYPE_COLORS, STEP_TYPE_ICONS } from '../constants';
|
||||
import type { StepDraft, UserItem, WorkflowDetail } from '../types';
|
||||
import { UserDropdown } from './UserDropdown';
|
||||
|
||||
/** 编辑器内部的步骤草稿,附带稳定 key 供动态列表渲染 */
|
||||
type StepDraftEntry = StepDraft & { key: string };
|
||||
|
||||
interface EditorProps {
|
||||
workflow?: WorkflowDetail | null;
|
||||
users: UserItem[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps) {
|
||||
const isEdit = !!workflow;
|
||||
const [name, setName] = useState(workflow?.name || '');
|
||||
const [description, setDescription] = useState(workflow?.description || '');
|
||||
const [steps, setSteps] = useState<StepDraftEntry[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const keySeq = useRef(0);
|
||||
// 生成步骤草稿的稳定 key,避免增删/移动时用下标导致状态错位
|
||||
const nextKey = () => `step-${++keySeq.current}`;
|
||||
|
||||
// 初始化步骤
|
||||
useEffect(() => {
|
||||
if (workflow?.steps?.length) {
|
||||
setSteps(
|
||||
workflow.steps.map((s) => ({
|
||||
key: nextKey(),
|
||||
name: s.name,
|
||||
step_type: (s.step_type === 'serial' || s.step_type === 'parallel' || s.step_type === 'any_one')
|
||||
? 'audit' as const
|
||||
: s.step_type as 'submit' | 'audit' | 'execute',
|
||||
approver_ids: Array.isArray(s.approver_ids) ? s.approver_ids : (s.approver_ids ? ((): number[] => { try { const p = JSON.parse(s.approver_ids as string); return Array.isArray(p) ? p : []; } catch { return []; } })() : []),
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
// 新建默认:提交→执行
|
||||
setSteps([
|
||||
{ key: nextKey(), name: '提交阶段', step_type: 'submit', approver_ids: [] },
|
||||
{ key: nextKey(), name: '执行阶段', step_type: 'execute', approver_ids: [] },
|
||||
]);
|
||||
}
|
||||
}, [workflow]);
|
||||
|
||||
const addStep = (type: 'submit' | 'audit' | 'execute') => {
|
||||
const labels: Record<string, string> = { submit: '提交阶段', audit: '审核阶段', execute: '执行阶段' };
|
||||
const count = steps.filter((s) => s.step_type === type).length;
|
||||
const suffix = count > 0 ? ` ${count + 1}` : '';
|
||||
setSteps([...steps, { key: nextKey(), name: `${labels[type]}${suffix}`, step_type: type, approver_ids: [] }]);
|
||||
};
|
||||
|
||||
const removeStep = (idx: number) => setSteps(steps.filter((_, i) => i !== idx));
|
||||
|
||||
const moveStep = (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= steps.length) return;
|
||||
const next = [...steps];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
setSteps(next);
|
||||
};
|
||||
|
||||
const updateStep = (idx: number, patch: Partial<StepDraft>) => {
|
||||
setSteps(steps.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
|
||||
};
|
||||
|
||||
const toggleUser = (idx: number, uid: number) => {
|
||||
const step = steps[idx];
|
||||
const has = step.approver_ids.includes(uid);
|
||||
updateStep(idx, {
|
||||
approver_ids: has ? step.approver_ids.filter((id) => id !== uid) : [...step.approver_ids, uid],
|
||||
});
|
||||
};
|
||||
|
||||
const getEligibleUsers = (stepType: string) => {
|
||||
const active = users.filter(u => u.is_active);
|
||||
if (stepType === 'audit') return active.filter(u => ['admin', 'superadmin', 'dba', 'developer', 'auditor'].includes(u.role));
|
||||
if (stepType === 'execute') return active.filter(u => ['admin', 'superadmin', 'dba'].includes(u.role));
|
||||
return active;
|
||||
};
|
||||
|
||||
const toggleAllUsers = (idx: number) => {
|
||||
const step = steps[idx];
|
||||
const eligible = getEligibleUsers(step.step_type);
|
||||
const allSelected = step.approver_ids.length === eligible.length && eligible.length > 0;
|
||||
if (allSelected) {
|
||||
updateStep(idx, { approver_ids: [] });
|
||||
} else {
|
||||
updateStep(idx, { approver_ids: eligible.map((u) => u.id) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { setError('请输入流程名称'); return; }
|
||||
if (steps.length === 0) { setError('请至少添加一个步骤'); return; }
|
||||
// 验证非 submit 步骤至少有一个人员
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (steps[i].step_type !== 'submit' && steps[i].approver_ids.length === 0) {
|
||||
setError(`步骤"${steps[i].name}"需要关联至少一个人员`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
steps: steps.map((s, i) => ({
|
||||
step_number: i + 1,
|
||||
name: s.name,
|
||||
step_type: s.step_type,
|
||||
approver_ids: s.approver_ids.length > 0 ? JSON.stringify(s.approver_ids) : null,
|
||||
is_required: true,
|
||||
timeout_hours: 24,
|
||||
auto_approve_on_timeout: false,
|
||||
})),
|
||||
};
|
||||
|
||||
if (isEdit && workflow) {
|
||||
await workflowApi.update(workflow.id, payload);
|
||||
} else {
|
||||
await workflowApi.create(payload);
|
||||
}
|
||||
onSaved();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserName = (uid: number) => {
|
||||
const u = users.find((u) => u.id === uid);
|
||||
return u ? (u.display_name || u.username) : `#${uid}`;
|
||||
};
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors';
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-2xl">
|
||||
<div
|
||||
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 sm:px-5 py-4">
|
||||
<h2 className="text-base font-semibold">{isEdit ? '编辑审批流程' : '新建审批流程'}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 sm:px-5 pb-5 space-y-4">
|
||||
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">流程名称 *</label>
|
||||
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} placeholder="输入流程名称" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">描述</label>
|
||||
<input className={inputCls} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
|
||||
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">流程步骤</label>
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1 sm:pb-0">
|
||||
{STEP_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => addStep(t.value)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => {
|
||||
const typeConf = STEP_TYPES.find((t) => t.value === step.step_type)!;
|
||||
const colorCls = STEP_TYPE_COLORS[step.step_type];
|
||||
const Icon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
return (
|
||||
<div key={step.key} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
{/* Step header */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap sm:flex-nowrap">
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
|
||||
</div>
|
||||
<input
|
||||
className="flex-1 min-w-[160px] px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={step.name}
|
||||
onChange={(e) => updateStep(idx, { name: e.target.value })}
|
||||
placeholder="步骤名称"
|
||||
/>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
|
||||
{typeConf.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 sm:ml-1">
|
||||
<button
|
||||
onClick={() => moveStep(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors"
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveStep(idx, 1)}
|
||||
disabled={idx === steps.length - 1}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors"
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeStep(idx)}
|
||||
className="p-1 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User selection — submit 步骤不需要选人 */}
|
||||
{step.step_type !== 'submit' && (
|
||||
<div className="sm:pl-9 mt-2">
|
||||
<UserDropdown
|
||||
users={getEligibleUsers(step.step_type)}
|
||||
selectedIds={step.approver_ids}
|
||||
label={step.step_type === 'audit' ? '审核人员' : '执行人员'}
|
||||
onToggle={(uid) => toggleUser(idx, uid)}
|
||||
onToggleAll={() => toggleAllUsers(idx)}
|
||||
onClear={() => updateStep(idx, { approver_ids: [] })}
|
||||
getUserName={getUserName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{steps.length === 0 && (
|
||||
<div className="text-center py-4 text-sm text-[var(--dg-text-muted)]">
|
||||
请点击上方按钮添加流程步骤
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end gap-3 px-4 sm:px-5 py-4">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isEdit ? '保存' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WorkflowPreviewModal — 工作流预览弹框 */
|
||||
/* 按步骤顺序展示阶段类型与关联人员 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import { X, Star, ArrowRight, Shield } from 'lucide-react';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { STEP_TYPE_LABELS, STEP_TYPE_COLORS, STEP_TYPE_ICONS } from '../constants';
|
||||
import type { WorkflowDetail } from '../types';
|
||||
|
||||
interface WorkflowPreviewModalProps {
|
||||
workflow: WorkflowDetail;
|
||||
getUserName: (uid: number) => string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WorkflowPreviewModal({ workflow, getUserName, onClose }: WorkflowPreviewModalProps) {
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-2xl">
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-4 sm:p-6 w-full mx-2 sm:mx-4 max-h-[90vh] sm:max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex items-start sm:items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold">{workflow.name}</h3>
|
||||
{workflow.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-yellow-500/10 text-yellow-400 text-xs rounded">
|
||||
<Star className="w-3 h-3" /> 默认
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{workflow.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-4">{workflow.description}</p>}
|
||||
<div className="space-y-4">
|
||||
{workflow.steps.map((step, i) => {
|
||||
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
|
||||
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
|
||||
return (
|
||||
<div key={step.id || i} className="flex items-start gap-3 sm:gap-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
|
||||
<StepIcon className={`w-5 h-5 ${colorClass.split(' ')[1]}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{step.name}</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded ${colorClass}`}>
|
||||
{STEP_TYPE_LABELS[step.step_type] || step.step_type}
|
||||
</span>
|
||||
</div>
|
||||
{uids.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">关联人员:</span>
|
||||
{uids.map((uid) => (
|
||||
<span key={uid} className="text-[11px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
|
||||
{getUserName(uid)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{i < workflow.steps.length - 1 && <ArrowRight className="hidden sm:block w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<button onClick={onClose} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WorkflowStatsGrid — 工作流统计概览卡片 */
|
||||
/* 汇总流程总数/已启用/默认流程/总步骤数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import { GitBranch, CheckCircle, Star, Users } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { WorkflowDetail } from '../types';
|
||||
|
||||
interface WorkflowStatsGridProps {
|
||||
workflows: WorkflowDetail[];
|
||||
}
|
||||
|
||||
export function WorkflowStatsGrid({ workflows: items }: WorkflowStatsGridProps) {
|
||||
const stats = [
|
||||
{ label: '流程总数', value: items.length, icon: GitBranch, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '已启用', value: items.filter((w) => w.is_active).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '默认流程', value: items.filter((w) => w.is_default).length, icon: Star, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
||||
{ label: '总步骤数', value: items.reduce((acc, w) => acc + (w.steps?.length || 0), 0), icon: Users, color: 'text-cyan-400', bg: 'bg-cyan-400/10' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label} className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
||||
<s.icon className={`w-5 h-5 ${s.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{s.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 审批流程领域 — 步骤类型标签/配色/图标常量 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import type { ElementType } from 'react';
|
||||
import { Users, Shield, CheckCircle, Play } from 'lucide-react';
|
||||
|
||||
/** 步骤类型 → 中文标签 */
|
||||
export const STEP_TYPE_LABELS: Record<string, string> = {
|
||||
submit: '提交阶段',
|
||||
serial: '串行审批',
|
||||
parallel: '并行审批',
|
||||
any_one: '任一审批',
|
||||
audit: '审核阶段',
|
||||
execute: '执行阶段',
|
||||
};
|
||||
|
||||
/** 步骤类型 → 徽章配色 */
|
||||
export const STEP_TYPE_COLORS: Record<string, string> = {
|
||||
submit: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
serial: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
||||
parallel: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
any_one: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
audit: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
||||
execute: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
};
|
||||
|
||||
/** 步骤类型 → 图标 */
|
||||
export const STEP_TYPE_ICONS: Record<string, ElementType> = {
|
||||
submit: Users,
|
||||
serial: Shield,
|
||||
parallel: Users,
|
||||
any_one: CheckCircle,
|
||||
audit: Shield,
|
||||
execute: Play,
|
||||
};
|
||||
|
||||
/** 编辑器可选的步骤类型(新建流程用) */
|
||||
export const STEP_TYPES = [
|
||||
{ value: 'submit' as const, label: '提交阶段', desc: '工单提交人' },
|
||||
{ value: 'audit' as const, label: '审核阶段', desc: '审核人员审批' },
|
||||
{ value: 'execute' as const, label: '执行阶段', desc: '执行人员操作' },
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 审批流程领域 — 共享类型定义 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 审批流程步骤(后端返回结构,approver_ids 为 JSON 字符串) */
|
||||
export interface WorkflowStep {
|
||||
id?: number;
|
||||
workflow_id?: number;
|
||||
step_number: number;
|
||||
name: string;
|
||||
step_type: string;
|
||||
approver_role: string | null;
|
||||
approver_ids: string | null;
|
||||
is_required: boolean;
|
||||
timeout_hours: number;
|
||||
auto_approve_on_timeout: boolean;
|
||||
}
|
||||
|
||||
/** 审批流程详情(含步骤列表) */
|
||||
export interface WorkflowDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string | null;
|
||||
steps: WorkflowStep[];
|
||||
}
|
||||
|
||||
/** 用户条目(审批人候选) */
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
role: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/** 流程编辑器中的步骤草稿 */
|
||||
export interface StepDraft {
|
||||
name: string;
|
||||
step_type: 'submit' | 'audit' | 'execute';
|
||||
approver_ids: number[];
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronDown, ChevronLast,
|
||||
ChevronLeft, ChevronRight, ClipboardPaste, Clock, Download, Eye, KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
|
||||
import type { RedisLineEditorState } from '@/features/redis/types';
|
||||
import type { DBType } from '@/lib/queryTemplates';
|
||||
import type { QueryResult, SortState } from '../types';
|
||||
|
||||
interface QueryResultPanelProps {
|
||||
/** 原始查询结果(表头统计、raw 展示用) */
|
||||
result: QueryResult;
|
||||
/** 排序后的结果(表格渲染用) */
|
||||
sortedResult: QueryResult | null;
|
||||
/** 当前列排序状态 */
|
||||
sortState: SortState;
|
||||
/** 归一化后的库类型 */
|
||||
dbType: DBType;
|
||||
/** 当前查询语句(Redis hash 识别等) */
|
||||
sql: string;
|
||||
/** 结果内容是否展开 */
|
||||
showResult: boolean;
|
||||
/** 每页条数 */
|
||||
pageSize: number;
|
||||
/** 当前页码 */
|
||||
currentPage: number;
|
||||
onToggleResult: () => void;
|
||||
onExportResult: () => void;
|
||||
onSort: (column: string) => void;
|
||||
/** 打开 Redis 单行值查看弹窗 */
|
||||
onViewRedisLine: (state: RedisLineEditorState) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
/** 跳转到指定页(重新执行查询) */
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
/** 智能单元格格式化:自动识别 JSON 文本 */
|
||||
function formatCellValue(val: unknown) {
|
||||
const str = String(val ?? '');
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return { text: JSON.stringify(parsed, null, 2), isJson: true };
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return { text: str, isJson: false };
|
||||
}
|
||||
|
||||
/** 查询结果面板:可折叠标题栏 + raw 值查看/标准表格(虚拟滚动)+ 分页 */
|
||||
export function QueryResultPanel({
|
||||
result,
|
||||
sortedResult,
|
||||
sortState,
|
||||
dbType,
|
||||
sql,
|
||||
showResult,
|
||||
pageSize,
|
||||
currentPage,
|
||||
onToggleResult,
|
||||
onExportResult,
|
||||
onSort,
|
||||
onViewRedisLine,
|
||||
onPageSizeChange,
|
||||
onPageChange,
|
||||
}: QueryResultPanelProps) {
|
||||
/* ---- Virtual Scroll ---- */
|
||||
const [rowHeight] = useState(36);
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||
const resultContainerRef = useRef<HTMLDivElement>(null);
|
||||
const handleResultScroll = useCallback(() => {
|
||||
if (!resultContainerRef.current) return;
|
||||
const c = resultContainerRef.current; const start = Math.max(0, Math.floor(c.scrollTop / rowHeight) - 5);
|
||||
const count = Math.ceil(c.clientHeight / rowHeight);
|
||||
setVisibleRange({ start, end: Math.min((sortedResult?.rows?.length || 0), start + count + 10) });
|
||||
}, [sortedResult, rowHeight]);
|
||||
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
|
||||
|
||||
const SortIcon = ({ col }: { col: string }) => {
|
||||
if (sortState.column !== col) return <ArrowUpDown className="w-3 h-3 opacity-30" />;
|
||||
if (sortState.direction === 'asc') return <ArrowUp className="w-3 h-3 text-blue-400" />;
|
||||
return <ArrowDown className="w-3 h-3 text-blue-400" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 px-0 md:px-4 pb-4 overflow-hidden flex flex-col">
|
||||
<div className="dg-card overflow-hidden flex-1 flex flex-col">
|
||||
{/* 结果标题栏 + 折叠按钮 */}
|
||||
<div
|
||||
className="shrink-0 border-b border-[var(--dg-border)] py-2 px-4 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
onClick={onToggleResult}
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm font-semibold flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
查询结果
|
||||
{showResult
|
||||
? <ChevronDown className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-xs dg-text-secondary">
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{result.execution_time_ms || result.cost_ms || 0}ms</span>
|
||||
{result.raw_text !== undefined ? (
|
||||
<span>{result.raw_value ? 'JSON' : 'String'}</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{result.total || result.rows?.length || 0} 总行</span>
|
||||
<span>{result.rows?.length || 0} 行/页</span>
|
||||
<span>{result.columns?.length || 0} 列</span>
|
||||
</>
|
||||
)}
|
||||
{result.rows?.length > 0 && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onExportResult(); }}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||
<Download className="w-3 h-3" />导出 CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果内容(可折叠) */}
|
||||
{showResult && (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-0">
|
||||
{result.raw_text !== undefined ? (
|
||||
/* Raw value display — ARDM-style FormatViewer */
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
{/* Key info bar (ARDM KeyHeader style) */}
|
||||
{result.raw_key && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs">
|
||||
<KeyRound className="w-3 h-3 text-blue-400 shrink-0" />
|
||||
<span className="font-mono text-[var(--dg-text-primary)] truncate">{result.raw_key}</span>
|
||||
{result.raw_type && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/15 text-green-400">{result.raw_type}</span>
|
||||
)}
|
||||
{result.raw_ttl && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-500/15 text-orange-400">{result.raw_ttl}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
result.raw_value ? 'bg-blue-500/15 text-blue-400' : 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-muted)]'
|
||||
}`}>{result.raw_value ? 'JSON' : 'String'}</span>
|
||||
<button onClick={() => navigator.clipboard.writeText(
|
||||
result.raw_value
|
||||
? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })()
|
||||
: String(result.raw_text)
|
||||
)} className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-60 hover:opacity-100">
|
||||
<ClipboardPaste className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Formatted value */}
|
||||
<div className="relative group">
|
||||
<pre className={`text-sm leading-relaxed overflow-auto max-h-[400px] p-4 rounded-lg font-mono ${
|
||||
result.raw_value
|
||||
? 'bg-[var(--dg-bg-primary)] text-blue-300 whitespace-pre-wrap break-all'
|
||||
: 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] whitespace-pre-wrap break-all'
|
||||
}`}>{result.raw_value
|
||||
? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })()
|
||||
: String(result.raw_text)
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : result.columns && result.columns.length > 0 ? (
|
||||
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto">
|
||||
{/* ARDM-style: Hash field/value compact list for Redis HGETALL */}
|
||||
{dbType === 'redis' && result.columns.length === 2 && result.columns[0] === 'field' && result.columns[1] === 'value' ? (
|
||||
<RedisHashQueryResult
|
||||
rows={sortedResult?.rows || []}
|
||||
rawKey={result.raw_key}
|
||||
rawType={result.raw_type}
|
||||
rawTtl={result.raw_ttl}
|
||||
command={sql}
|
||||
onView={onViewRedisLine}
|
||||
/>
|
||||
) : (
|
||||
/* Standard table for other results */
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="dg-table-header">
|
||||
<th className="text-left px-3 py-2 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
|
||||
{result.columns.map(col => (
|
||||
<th key={col} onClick={() => onSort(col)}
|
||||
className={`text-left px-4 py-2 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none ${
|
||||
dbType === 'redis' ? 'min-w-[200px]' : 'whitespace-nowrap'
|
||||
}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
{col} <SortIcon col={col} />
|
||||
{result.masked_columns?.includes(col) && (
|
||||
<span className="ml-1 px-1 py-0.5 bg-yellow-500/10 text-yellow-400 text-[9px] rounded">敏</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style={{ height: visibleRange.start * rowHeight }} />
|
||||
{(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => {
|
||||
const ri = visibleRange.start + i;
|
||||
return (
|
||||
<tr key={ri} className="dg-table-row" style={{ height: rowHeight }}>
|
||||
<td className="px-3 py-2 text-[10px] text-[var(--dg-text-muted)] font-mono">{ri + 1}</td>
|
||||
{result.columns.map(col => {
|
||||
const isMasked = result.masked_columns?.includes(col);
|
||||
const cellValue = row[col] ?? '';
|
||||
const { text: displayText, isJson } = formatCellValue(cellValue);
|
||||
return (
|
||||
<td key={col} className={`px-4 py-2.5 text-xs ${
|
||||
isJson
|
||||
? 'max-w-[600px] whitespace-pre-wrap break-all font-mono text-[12px] leading-relaxed text-blue-300'
|
||||
: dbType === 'redis'
|
||||
? 'whitespace-pre-wrap break-all font-mono text-[12px]'
|
||||
: 'whitespace-pre-wrap break-words'
|
||||
}`} title={String(cellValue)}>
|
||||
{isMasked ? <span className="text-yellow-400 italic">{String(cellValue)}</span> :
|
||||
isJson ? displayText :
|
||||
String(cellValue)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{/* Pagination */}
|
||||
{result.total > pageSize && (
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs dg-text-secondary border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>每页</span>
|
||||
<select value={pageSize} onChange={e => onPageSizeChange(Number(e.target.value))}
|
||||
className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded px-1 py-0.5 text-xs">
|
||||
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<span>条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button disabled={currentPage <= 1} onClick={() => onPageChange(1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3 rotate-180" /></button>
|
||||
<button disabled={currentPage <= 1} onClick={() => onPageChange(currentPage - 1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3" /></button>
|
||||
<span className="px-2">{currentPage} / {Math.ceil(result.total / pageSize)}</span>
|
||||
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => onPageChange(currentPage + 1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3 rotate-180" /></button>
|
||||
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => onPageChange(Math.ceil(result.total / pageSize))}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3" /></button>
|
||||
</div>
|
||||
<span>共 {result.total} 条</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center dg-text-muted flex flex-col items-center gap-2">
|
||||
<Eye className="w-5 h-5" />
|
||||
<span className="text-sm">无结果数据</span>
|
||||
{result.affected_rows > 0 && <span className="text-xs">影响行数: {result.affected_rows}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
ChevronDown, Database, Loader2, PanelRightClose, PanelRightOpen, Play, Sparkles,
|
||||
} from 'lucide-react';
|
||||
import type { DBType } from '@/lib/queryTemplates';
|
||||
import { dbTypeIcon, dbTypeLabel } from '../constants';
|
||||
import type { QueryInstanceOption } from '../types';
|
||||
|
||||
interface QueryTopBarProps {
|
||||
/** 可选实例列表 */
|
||||
instances: QueryInstanceOption[];
|
||||
/** 当前选中实例 id(字符串形式) */
|
||||
instanceId: string;
|
||||
/** 已选实例(用于展示库类型图标) */
|
||||
selectedInstance: QueryInstanceOption | undefined;
|
||||
/** 当前选中数据库 */
|
||||
database: string;
|
||||
/** 可选数据库列表(MySQL/PG) */
|
||||
databases: string[];
|
||||
/** 数据库列表加载中 */
|
||||
loadingDbs: boolean;
|
||||
/** 归一化后的库类型 */
|
||||
dbType: DBType;
|
||||
/** Schema 侧栏是否可见 */
|
||||
showSidebar: boolean;
|
||||
/** 模板面板是否展开 */
|
||||
showTemplates: boolean;
|
||||
/** 历史抽屉是否展开 */
|
||||
showHistory: boolean;
|
||||
/** 是否正在执行查询 */
|
||||
executing: boolean;
|
||||
/** 模板总数 */
|
||||
templateCount: number;
|
||||
/** 历史记录总数 */
|
||||
historyCount: number;
|
||||
onInstanceIdChange: (instanceId: string) => void;
|
||||
onDatabaseChange: (database: string) => void;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleTemplates: () => void;
|
||||
onToggleHistory: () => void;
|
||||
/** 执行查询(等价于第 1 页) */
|
||||
onExecute: () => void;
|
||||
}
|
||||
|
||||
/** 查询页顶栏:实例/数据库选择 + 模板/历史/导航/执行工具按钮 */
|
||||
export function QueryTopBar({
|
||||
instances,
|
||||
instanceId,
|
||||
selectedInstance,
|
||||
database,
|
||||
databases,
|
||||
loadingDbs,
|
||||
dbType,
|
||||
showSidebar,
|
||||
showTemplates,
|
||||
showHistory,
|
||||
executing,
|
||||
templateCount,
|
||||
historyCount,
|
||||
onInstanceIdChange,
|
||||
onDatabaseChange,
|
||||
onToggleSidebar,
|
||||
onToggleTemplates,
|
||||
onToggleHistory,
|
||||
onExecute,
|
||||
}: QueryTopBarProps) {
|
||||
return (
|
||||
<div className="shrink-0 px-0 md:px-5 pt-1 md:pt-4 pb-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-full md:min-w-0 flex-wrap md:flex-nowrap">
|
||||
{selectedInstance && dbTypeIcon[dbType] && (
|
||||
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
|
||||
)}
|
||||
<select value={instanceId} onChange={e => onInstanceIdChange(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[200px]">
|
||||
<option value="">选择实例</option>
|
||||
{instances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({dbTypeLabel[inst.db_type] || inst.db_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{dbType === 'redis' ? (
|
||||
<select value={database || '0'} onChange={e => onDatabaseChange(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-full md:w-24">
|
||||
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<select value={database} onChange={e => onDatabaseChange(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[160px]">
|
||||
<option value="">{loadingDbs ? '加载中...' : '选择数据库'}</option>
|
||||
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧工具按钮组 */}
|
||||
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
|
||||
{instanceId && (
|
||||
<button onClick={onToggleSidebar}
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{showSidebar ? '隐藏' : '导航'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onToggleTemplates}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
||||
模板 ({templateCount})
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button onClick={onToggleHistory}
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
{showHistory ? <PanelRightClose className="w-3.5 h-3.5" /> : <PanelRightOpen className="w-3.5 h-3.5" />}
|
||||
历史 {historyCount > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{historyCount}</span>}
|
||||
</button>
|
||||
<button onClick={onExecute} disabled={executing || !instanceId}
|
||||
className="dg-btn dg-btn-primary ml-auto md:ml-0">
|
||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{executing ? '执行中...' : '执行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { KeyRound, Loader2, Search } from 'lucide-react';
|
||||
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
||||
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
|
||||
import type { RedisKeyDetail, RedisKeyInfo } from '../types';
|
||||
|
||||
interface RedisKeyValuePanelProps {
|
||||
/** key/pattern 搜索词 */
|
||||
pattern: string;
|
||||
/** 类型过滤(string/hash/list/set/zset,空为全部) */
|
||||
typeFilter: string;
|
||||
/** 扫描到的 key 列表 */
|
||||
keys: RedisKeyInfo[];
|
||||
/** 当前选中 key 详情 */
|
||||
selectedKey: RedisKeyDetail | null;
|
||||
/** key 列表加载中 */
|
||||
loading: boolean;
|
||||
/** key 详情加载中 */
|
||||
detailLoading: boolean;
|
||||
/** 结构化值内搜索词 */
|
||||
valueSearch: string;
|
||||
/** Schema 侧栏可见时在宽屏下隐藏本面板 */
|
||||
hideOnWide: boolean;
|
||||
onPatternChange: (pattern: string) => void;
|
||||
onTypeFilterChange: (type: string) => void;
|
||||
/** 触发 key 扫描 */
|
||||
onSearch: () => void;
|
||||
onLoadKeyDetail: (keyName: string) => void;
|
||||
onValueSearchChange: (value: string) => void;
|
||||
onCopy: (text: string) => void;
|
||||
/** 查看单行值(field/value 结构化查看) */
|
||||
onInspect: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
/** Redis 键值可视化面板:pattern 扫描 key 列表 + 结构化值查看 */
|
||||
export function RedisKeyValuePanel({
|
||||
pattern,
|
||||
typeFilter,
|
||||
keys,
|
||||
selectedKey,
|
||||
loading,
|
||||
detailLoading,
|
||||
valueSearch,
|
||||
hideOnWide,
|
||||
onPatternChange,
|
||||
onTypeFilterChange,
|
||||
onSearch,
|
||||
onLoadKeyDetail,
|
||||
onValueSearchChange,
|
||||
onCopy,
|
||||
onInspect,
|
||||
}: RedisKeyValuePanelProps) {
|
||||
return (
|
||||
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${hideOnWide ? 'md:hidden' : ''}`}>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="border-b border-[var(--dg-border)] px-3 py-2">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<KeyRound className="h-4 w-4 text-red-400" />
|
||||
Redis 键值可视化
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_130px_auto] lg:w-[620px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={pattern}
|
||||
onChange={e => onPatternChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onSearch(); }}
|
||||
className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 font-mono text-sm"
|
||||
placeholder="输入 key/pattern,* 扫描全部"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => onTypeFilterChange(e.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="string">String</option>
|
||||
<option value="hash">Hash</option>
|
||||
<option value="list">List</option>
|
||||
<option value="set">Set</option>
|
||||
<option value="zset">ZSet</option>
|
||||
</select>
|
||||
<button onClick={onSearch} disabled={loading || !pattern.trim()} className="dg-btn dg-btn-primary h-9 disabled:opacity-40">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-h-[520px] min-h-[260px] grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="min-h-[180px] overflow-y-auto border-b border-[var(--dg-border)] lg:border-b-0 lg:border-r">
|
||||
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-3 py-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>Keys</span>
|
||||
<span>{keys.length} 条</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }, (_, i) => <div key={i} className="h-10 animate-pulse rounded-md bg-[var(--dg-bg-tertiary)]" />)}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-[var(--dg-text-muted)]">输入 key/pattern 后搜索;输入 * 可扫描全部</div>
|
||||
) : keys.map((item, i) => {
|
||||
// key 名为主、下标兜底,保证重渲染时行身份稳定
|
||||
const keyName = item.key || item.name || '';
|
||||
const active = (selectedKey?.key || selectedKey?.name) === keyName;
|
||||
return (
|
||||
<button
|
||||
key={`${keyName}-${i}`}
|
||||
onClick={() => onLoadKeyDetail(keyName)}
|
||||
className={`block w-full border-b border-[var(--dg-border-light)] px-3 py-2.5 text-left transition-colors ${active ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{keyName}</span>
|
||||
{item.type && <span className="rounded bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 text-[10px] uppercase text-[var(--dg-text-muted)]">{item.type}</span>}
|
||||
</div>
|
||||
{typeof item.ttl === 'number' && <div className="mt-1 text-[11px] text-[var(--dg-text-muted)]">TTL {formatTTL(item.ttl)}</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="m-4 h-52 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||
) : selectedKey ? (
|
||||
<div className="flex h-full min-h-[260px] flex-col">
|
||||
<div className="flex flex-col gap-2 border-b border-[var(--dg-border)] px-3 py-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{selectedKey.key || selectedKey.name}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--dg-text-muted)]">
|
||||
<span className="rounded bg-red-500/10 px-1.5 py-0.5 uppercase text-red-400">{selectedKey.type}</span>
|
||||
<span>TTL {formatTTL(selectedKey.ttl)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||
<input value={valueSearch} onChange={e => onValueSearchChange(e.target.value)} className="h-8 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-8 pr-3 text-xs" placeholder="搜索字段或值" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{selectedKey.type === 'string' ? (
|
||||
<pre className="min-h-[180px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
|
||||
{typeof selectedKey.value === 'string' ? (tryFormatJsonText(selectedKey.value) || selectedKey.value) : JSON.stringify(selectedKey.value, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
|
||||
<RedisStructuredValue
|
||||
type={selectedKey.type}
|
||||
value={selectedKey.value}
|
||||
redisKey={selectedKey.key || selectedKey.name || ''}
|
||||
search={valueSearch}
|
||||
onCopy={onCopy}
|
||||
onInspect={onInspect}
|
||||
onEditHashLine={onInspect}
|
||||
onDeleteHashLine={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[260px] items-center justify-center px-5 text-center text-sm text-[var(--dg-text-muted)]">选择左侧 Key 查看结构化值;命令行查询仍可在下方执行</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import type { DBType } from '@/lib/queryTemplates';
|
||||
import { dbTypeIcon, dbTypeLabel, placeholderMap, queryPolicyText } from '../constants';
|
||||
|
||||
interface SqlEditorSectionProps {
|
||||
/** 当前 SQL / 命令文本 */
|
||||
sql: string;
|
||||
/** 归一化后的库类型 */
|
||||
dbType: DBType;
|
||||
/** 编辑器高亮语言 */
|
||||
editorLang: 'sql' | 'redis' | 'mongodb';
|
||||
/** 库表结构(仅 MySQL/PG 传入编辑器) */
|
||||
schema: Record<string, Record<string, string[]>>;
|
||||
onSqlChange: (sql: string) => void;
|
||||
/** 执行查询(Ctrl+Enter 触发) */
|
||||
onExecute: () => void;
|
||||
}
|
||||
|
||||
/** SQL 编辑器卡片:编辑器主体 + 模式/字数 footer + 查询策略提示 */
|
||||
export function SqlEditorSection({
|
||||
sql,
|
||||
dbType,
|
||||
editorLang,
|
||||
schema,
|
||||
onSqlChange,
|
||||
onExecute,
|
||||
}: SqlEditorSectionProps) {
|
||||
return (
|
||||
<div className="shrink-0 p-0 md:p-4 pb-2">
|
||||
<div className="dg-card overflow-hidden flex flex-col">
|
||||
<SqlEditor value={sql} onChange={onSqlChange} placeholder={placeholderMap[dbType]}
|
||||
language={editorLang} height="200px" onExecute={onExecute}
|
||||
schema={(dbType === 'mysql' || dbType === 'postgresql') ? schema : undefined} />
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
||||
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 执行</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[11px] text-[var(--dg-text-muted)] flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 mt-0.5 shrink-0" />
|
||||
<span>{queryPolicyText[dbType]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DBType } from '@/lib/queryTemplates';
|
||||
|
||||
/** 数据库类型 → 展示名 */
|
||||
export const dbTypeLabel: Record<string, string> = {
|
||||
mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB',
|
||||
};
|
||||
|
||||
/** 数据库类型 → 图标地址 */
|
||||
export const dbTypeIcon: Record<string, string> = {
|
||||
mysql: '/db-logos/mysql.svg',
|
||||
postgres: '/db-logos/postgres.svg',
|
||||
postgresql: '/db-logos/postgres.svg',
|
||||
redis: '/db-logos/redis.svg',
|
||||
mongo: '/db-logos/mongo.svg',
|
||||
mongodb: '/db-logos/mongo.svg',
|
||||
};
|
||||
|
||||
// 脱敏关键词不再硬编码——由后端 applyDataMasking 返回 masked_columns 标记
|
||||
// 后端根据 data_masking_enabled + data_masking_sensitive_fields + 实例 mask_fields 决定
|
||||
|
||||
/** 编辑器占位示例 SQL/命令 */
|
||||
export const placeholderMap: Record<DBType, string> = {
|
||||
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
|
||||
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
|
||||
};
|
||||
|
||||
/** 归一化数据库类型标识(后端别名 → 统一 DBType) */
|
||||
export function normalizeDbType(dbType?: string): DBType {
|
||||
if (dbType === 'postgres' || dbType === 'pg') return 'postgresql';
|
||||
if (dbType === 'mongo') return 'mongodb';
|
||||
if (dbType === 'redis') return 'redis';
|
||||
return 'mysql';
|
||||
}
|
||||
|
||||
/** 各库查询入口的权限策略文案 */
|
||||
export const queryPolicyText: Record<DBType, string> = {
|
||||
mysql: '查询入口仅允许 SELECT / SHOW / DESCRIBE / EXPLAIN;禁止 UPDATE/DELETE/DDL、多语句、SELECT FOR UPDATE。未写 LIMIT 时按平台配置自动限制。',
|
||||
postgresql: '查询入口仅允许 SELECT / EXPLAIN;禁止 EXPLAIN ANALYZE、写入 CTE、多语句和锁定读。未写 LIMIT 时按平台配置自动限制。',
|
||||
redis: 'Redis 查询入口仅允许只读命令;写命令请提交变更工单。集群实例会按 slot 路由并扫描所有 master。',
|
||||
mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。',
|
||||
};
|
||||
@@ -10,3 +10,52 @@ export interface QueryHistoryItem {
|
||||
created_at: string;
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
/** 查询实例下拉选项 */
|
||||
export interface QueryInstanceOption {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
db_type: string;
|
||||
cluster_mode?: boolean;
|
||||
}
|
||||
|
||||
/** 查询结果(含 Redis raw 值展示字段与脱敏列标记) */
|
||||
export interface QueryResult {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
total: number;
|
||||
count: number;
|
||||
affected_rows: number;
|
||||
execution_time_ms: number;
|
||||
cost_ms?: number;
|
||||
raw_value?: boolean;
|
||||
raw_key?: string;
|
||||
raw_text?: string;
|
||||
raw_type?: string;
|
||||
raw_ttl?: string;
|
||||
error?: string;
|
||||
masked_columns?: string[];
|
||||
}
|
||||
|
||||
/** Redis key 扫描列表项 */
|
||||
export interface RedisKeyInfo {
|
||||
key?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
/** Redis key 详情 */
|
||||
export interface RedisKeyDetail {
|
||||
key: string;
|
||||
name?: string;
|
||||
type: string;
|
||||
ttl: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/** 排序方向:null 表示未排序 */
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
export type SortState = { column: string | null; direction: SortDirection };
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 用途:用户列表批量操作条(选中计数 + 批量启用/禁用/删除下拉菜单)
|
||||
* 必需 props:selectedCount(选中数量)、onAction(执行批量动作)
|
||||
* 已知限制:菜单开合状态由组件内部管理;删除动作的二次确认由页面统一弹出
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { BATCH_OPTIONS, type BatchActionKey } from '../constants';
|
||||
|
||||
interface BatchActionBarProps {
|
||||
selectedCount: number;
|
||||
onAction: (action: BatchActionKey) => void;
|
||||
}
|
||||
|
||||
export function BatchActionBar({ selectedCount, onAction }: BatchActionBarProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
||||
<span className="text-sm text-blue-400">
|
||||
已选择 <strong>{selectedCount}</strong> 项
|
||||
</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm text-blue-400 hover:bg-blue-500/25 transition-colors"
|
||||
>
|
||||
批量操作 <ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[160px]">
|
||||
{BATCH_OPTIONS.map(opt => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => { onAction(opt.key); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 用途:批量导入用户弹窗(本地 CSV 导入 / 导入到 LDAP / 从 LDAP 导入 三个 Tab,含新建 OU 子弹窗)
|
||||
* 必需 props:onClose(关闭)、onImported(导入有成功项后回调,页面负责关窗并刷新列表)
|
||||
* 已知限制:CSV 预览仅展示前 50(本地)/ 30(到 LDAP)行;拼音用户名重复时以 username+序号作稳定 key
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Upload, X, RotateCcw, Plus, Users as UsersIcon, Server,
|
||||
} from 'lucide-react';
|
||||
import { userApi, ldapApi } from '@/api/user';
|
||||
import { nameToUsername } from '@/utils/pinyin';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
|
||||
interface BatchImportModalProps {
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) {
|
||||
const [tab, setTab] = useState<'local' | 'to-ldap' | 'from-ldap'>('local');
|
||||
const [preview, setPreview] = useState<any[]>([]);
|
||||
const [skipExisting, setSkipExisting] = useState(true);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// LDAP state
|
||||
const [ldapOus, setLdapOus] = useState<any[]>([]);
|
||||
const [ldapUsers, setLdapUsers] = useState<any[]>([]);
|
||||
const [selectedLdapUsers, setSelectedLdapUsers] = useState<Set<string>>(new Set());
|
||||
const [ldapLoading, setLdapLoading] = useState(false);
|
||||
const [targetOu, setTargetOu] = useState('');
|
||||
const [ldapReady, setLdapReady] = useState(false);
|
||||
|
||||
// Create OU dialog
|
||||
const [showCreateOu, setShowCreateOu] = useState(false);
|
||||
const [newOuName, setNewOuName] = useState('');
|
||||
const [newOuDesc, setNewOuDesc] = useState('');
|
||||
const [creatingOu, setCreatingOu] = useState(false);
|
||||
|
||||
// Check LDAP readiness + load OUs
|
||||
useEffect(() => {
|
||||
if (tab === 'to-ldap' || tab === 'from-ldap') {
|
||||
setLdapLoading(true);
|
||||
ldapApi.getConfig().then((res: any) => {
|
||||
const ready = !!res?.ldap_host;
|
||||
setLdapReady(ready);
|
||||
if (ready && ldapOus.length === 0) {
|
||||
return loadLdapOus();
|
||||
}
|
||||
setLdapLoading(false);
|
||||
}).catch(() => {
|
||||
setLdapReady(false);
|
||||
setLdapLoading(false);
|
||||
});
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
// CSV parse
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = reader.result as string;
|
||||
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
||||
if (lines.length < 2) { toast('error', '文件为空'); return; }
|
||||
|
||||
const header = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const nameIdx = header.findIndex(h => /姓名|name|昵称|显示名/.test(h));
|
||||
const emailIdx = header.findIndex(h => /邮箱|email|mail/.test(h));
|
||||
const roleIdx = header.findIndex(h => /角色|role/.test(h));
|
||||
|
||||
if (nameIdx < 0) { toast('error', 'CSV 需要包含"姓名"列'); return; }
|
||||
|
||||
const rows = lines.slice(1).map(line => {
|
||||
const cols = line.split(',').map(c => c.trim().replace(/^"|"$/g, ''));
|
||||
const name = cols[nameIdx] || '';
|
||||
return {
|
||||
display_name: name,
|
||||
username: nameToUsername(name),
|
||||
password: nameToUsername(name),
|
||||
email: emailIdx >= 0 ? cols[emailIdx] : '',
|
||||
role: roleIdx >= 0 ? cols[roleIdx] || 'developer' : 'developer',
|
||||
};
|
||||
}).filter(r => r.display_name);
|
||||
setPreview(rows);
|
||||
setResult(null);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// LDAP OU loading
|
||||
const loadLdapOus = async () => {
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
const res = await ldapApi.listOus() as any;
|
||||
setLdapOus(Array.isArray(res) ? res : (res?.ous || res?.data || []));
|
||||
} catch { toast('error', '加载 LDAP OU 失败'); }
|
||||
finally { setLdapLoading(false); }
|
||||
};
|
||||
|
||||
const loadLdapUsers = async (baseDn: string) => {
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
const res = await ldapApi.listUsers({ base_dn: baseDn }) as any;
|
||||
setLdapUsers(Array.isArray(res) ? res : (res?.users || res?.data || []));
|
||||
} catch { toast('error', '加载 LDAP 用户失败'); }
|
||||
finally { setLdapLoading(false); }
|
||||
};
|
||||
|
||||
// Create OU
|
||||
const handleCreateOu = async () => {
|
||||
if (!newOuName.trim()) { toast('warning', '请输入 OU 名称'); return; }
|
||||
setCreatingOu(true);
|
||||
try {
|
||||
await ldapApi.createOu({ ou_name: newOuName.trim(), description: newOuDesc.trim() });
|
||||
toast('success', `OU "${newOuName}" 创建成功`);
|
||||
setShowCreateOu(false);
|
||||
setNewOuName('');
|
||||
setNewOuDesc('');
|
||||
await loadLdapOus();
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || '创建 OU 失败');
|
||||
} finally { setCreatingOu(false); }
|
||||
};
|
||||
|
||||
// Import
|
||||
const handleImport = async () => {
|
||||
if (tab === 'local' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
|
||||
if (tab === 'to-ldap' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
|
||||
if (tab === 'to-ldap' && !targetOu) { toast('warning', '请选择目标 OU'); return; }
|
||||
if (tab === 'from-ldap' && selectedLdapUsers.size === 0) { toast('warning', '请选择用户'); return; }
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
let res: any;
|
||||
if (tab === 'local') {
|
||||
res = await userApi.batchImport({ users: preview, import_type: 'local', skip_existing: skipExisting });
|
||||
} else if (tab === 'to-ldap') {
|
||||
res = await ldapApi.batchImportToLdap({ users: preview, ou_dn: targetOu });
|
||||
} else {
|
||||
const users = ldapUsers.filter(u => selectedLdapUsers.has(u.dn));
|
||||
res = await userApi.batchImportLdap({ users });
|
||||
}
|
||||
setResult(res);
|
||||
if (res.success_count > 0 || res.success) onImported();
|
||||
if (tab === 'to-ldap') {
|
||||
toast('success', res.message || `成功 ${res.success_count} 个`);
|
||||
} else {
|
||||
toast('success', `成功导入 ${res.success_count} 个用户`);
|
||||
}
|
||||
} catch { toast('error', '导入失败'); }
|
||||
finally { setImporting(false); }
|
||||
};
|
||||
|
||||
const canImport = () => {
|
||||
if (importing) return false;
|
||||
if (tab === 'local') return preview.length > 0;
|
||||
if (tab === 'to-ldap') return preview.length > 0 && !!targetOu;
|
||||
return selectedLdapUsers.size > 0;
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: 'local' as const, label: '本地导入', icon: Upload },
|
||||
{ key: 'to-ldap' as const, label: '导入到 LDAP', icon: Server },
|
||||
{ key: 'from-ldap' as const, label: '从 LDAP 导入', icon: UsersIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-2xl">
|
||||
<div className="w-full max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<h2 className="text-lg font-semibold">批量导入用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-6 pb-3">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button key={t.key} onClick={() => setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors ${tab === t.key ? 'bg-[var(--dg-blue)] text-white' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||
<Icon className="w-4 h-4" />{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* ── Local Import ── */}
|
||||
{tab === 'local' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Upload className="w-4 h-4" />选择 CSV 文件
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<input type="checkbox" checked={skipExisting} onChange={e => setSkipExisting(e.target.checked)} className="rounded" />
|
||||
跳过已存在用户
|
||||
</label>
|
||||
</div>
|
||||
{preview.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-2 pr-3">姓名</th>
|
||||
<th className="py-2 pr-3">用户名</th>
|
||||
<th className="py-2 pr-3">邮箱</th>
|
||||
<th className="py-2">角色</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.slice(0, 50).map((r, i) => (
|
||||
<tr key={`${r.username}-${i}`} className="border-b border-[var(--dg-border)]/50">
|
||||
<td className="py-2 pr-3">{r.display_name}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{r.email || '-'}</td>
|
||||
<td className="py-2 text-[var(--dg-text-muted)]">{r.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
{preview.length > 50 && (
|
||||
<tr><td colSpan={4} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Import to LDAP ── */}
|
||||
{tab === 'to-ldap' && (
|
||||
<>
|
||||
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
|
||||
{!ldapLoading && !ldapReady && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
|
||||
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && (
|
||||
<>
|
||||
{/* OU selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">目标 OU</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={targetOu}
|
||||
onChange={e => setTargetOu(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
>
|
||||
<option value="">Base DN (根)</option>
|
||||
{ldapOus.map((ou: any) => (
|
||||
<option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={loadLdapOus} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]" title="刷新 OU">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setShowCreateOu(true)} className="flex items-center gap-1 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Plus className="w-3.5 h-3.5" />新建 OU
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* File upload (same CSV format) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Upload className="w-4 h-4" />选择 CSV 文件
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">CSV 需包含"姓名"列,系统自动生成拼音用户名和密码</span>
|
||||
</div>
|
||||
{preview.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-2 pr-3">姓名</th>
|
||||
<th className="py-2 pr-3">用户名(自动)</th>
|
||||
<th className="py-2">密码(自动)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.slice(0, 30).map((r, i) => (
|
||||
<tr key={`${r.username}-${i}`} className="border-b border-[var(--dg-border)]/50">
|
||||
<td className="py-2 pr-3">{r.display_name}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
|
||||
<td className="py-2 text-[var(--dg-text-muted)]">{r.password}</td>
|
||||
</tr>
|
||||
))}
|
||||
{preview.length > 30 && (
|
||||
<tr><td colSpan={3} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── From LDAP Import ── */}
|
||||
{tab === 'from-ldap' && (
|
||||
<>
|
||||
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
|
||||
{!ldapLoading && !ldapReady && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
|
||||
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && ldapOus.length > 0 && ldapUsers.length === 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-2">选择组织单元:</p>
|
||||
{ldapOus.map(ou => (
|
||||
<button key={ou.dn} onClick={() => loadLdapUsers(ou.dn)} className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] flex items-center gap-2">
|
||||
<UsersIcon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<span>{ou.name}</span>
|
||||
{ou.description && <span className="text-[var(--dg-text-muted)]">— {ou.description}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ldapUsers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">{ldapUsers.length} 个用户</p>
|
||||
<button onClick={() => setSelectedLdapUsers(prev => prev.size === ldapUsers.length ? new Set() : new Set(ldapUsers.map(u => u.dn)))} className="text-xs text-[var(--dg-blue)]">
|
||||
{selectedLdapUsers.size === ldapUsers.length ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
{ldapUsers.map(u => (
|
||||
<label key={u.dn} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer">
|
||||
<input type="checkbox" checked={selectedLdapUsers.has(u.dn)} onChange={() => setSelectedLdapUsers(prev => { const n = new Set(prev); n.has(u.dn) ? n.delete(u.dn) : n.add(u.dn); return n; })} />
|
||||
<span className="text-sm font-medium">{u.cn || u.nickname || u.uid}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">@{u.uid || u.username}</span>
|
||||
{u.mail && <span className="text-xs text-[var(--dg-text-muted)]">{u.mail}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && ldapOus.length === 0 && (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] text-center py-4">LDAP 目录中无可用 OU,请先创建 OU 或检查配置</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-sm space-y-1">
|
||||
<p className="text-green-400">成功: {result.success_count ?? (result.success ? 1 : 0)}</p>
|
||||
{result.skipped_count > 0 && <p className="text-yellow-400">跳过: {result.skipped_count}</p>}
|
||||
{result.failed_count > 0 && <p className="text-red-400">失败: {result.failed_count}</p>}
|
||||
{result.failed_users?.map((f: any, i: number) => (
|
||||
<p key={`${f.username}-${i}`} className="text-red-400 text-xs">{f.username}: {f.error}</p>
|
||||
))}
|
||||
{result.message && !result.success_count && <p className="text-[var(--dg-text-secondary)]">{result.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">关闭</button>
|
||||
<button onClick={handleImport} disabled={!canImport()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
|
||||
{importing ? '导入中...' : '确认导入'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create OU sub-dialog */}
|
||||
{showCreateOu && (
|
||||
<ModalOverlay open={showCreateOu} onClose={() => setShowCreateOu(false)} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold">创建新的组织单位 (OU)</h3>
|
||||
<button onClick={() => setShowCreateOu(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">OU 名称 *</label>
|
||||
<input value={newOuName} onChange={e => setNewOuName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="例如:研发部、市场部" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">描述</label>
|
||||
<input value={newOuDesc} onChange={e => setNewOuDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选描述" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={() => setShowCreateOu(false)} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||
<button onClick={handleCreateOu} disabled={creatingOu || !newOuName.trim()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
|
||||
{creatingOu ? '创建中...' : '创建 OU'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 用途:新建用户表单弹窗(用户名/显示名称/密码/邮箱/角色)
|
||||
* 必需 props:onClose(关闭)、onCreated(创建成功后回调,页面负责关窗并刷新列表)
|
||||
* 已知限制:成功 toast 沿用原有反馈点,由本组件内部触发
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { userApi } from '@/api/user';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { validateUserForm } from '../utils';
|
||||
import type { FormErrors } from '../types';
|
||||
|
||||
interface CreateUserModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateUserModal({ onClose, onCreated }: CreateUserModalProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('developer');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validation = validateUserForm({ username, password, email });
|
||||
setErrors(validation);
|
||||
if (Object.keys(validation).length > 0) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
||||
toast('success', '用户创建成功');
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || '创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">新建用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">用户名 *</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={e => { setUsername(e.target.value); if (errors.username) setErrors(p => ({ ...p, username: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.username ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="3-30位字母、数字或下划线"
|
||||
/>
|
||||
{errors.username && <p className="text-xs text-red-400 mt-1">{errors.username}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">显示名称</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="用户的显示名称" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">密码 *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); if (errors.password) setErrors(p => ({ ...p, password: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.password ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="至少6位"
|
||||
/>
|
||||
{errors.password && <p className="text-xs text-red-400 mt-1">{errors.password}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); if (errors.email) setErrors(p => ({ ...p, email: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.email ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="example@company.com"
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-red-400 mt-1">{errors.email}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">角色</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||
<option value="admin">超级管理员</option>
|
||||
<option value="dba">数据库管理员</option>
|
||||
<option value="developer">开发人员</option>
|
||||
<option value="auditor">审计员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 用途:编辑用户弹窗(显示名称/邮箱/角色变更、权限组分配、启用开关)
|
||||
* 必需 props:user(待编辑用户)、onClose(关闭)、onUpdated(保存成功后回调,页面负责关窗并刷新列表)
|
||||
* 已知限制:权限组通过反查实例组成员得到,加载失败时静默为空列表(保持原行为)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { userApi } from '@/api/user';
|
||||
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface EditUserModalProps {
|
||||
user: UserItem;
|
||||
onClose: () => void;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function EditUserModal({ user, onClose, onUpdated }: EditUserModalProps) {
|
||||
const [displayName, setDisplayName] = useState(user.display_name || '');
|
||||
const [email, setEmail] = useState(user.email || '');
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [isActive, setIsActive] = useState(user.is_active);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [groups, setGroups] = useState<InstanceGroup[]>([]);
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
instanceGroupApi.list().then((data: InstanceGroup[]) => {
|
||||
setGroups(data);
|
||||
// Reverse lookup: find which groups contain this user
|
||||
const matched = data
|
||||
.filter(g => g.users?.some(u => u.id === user.id))
|
||||
.map(g => g.id);
|
||||
setSelectedGroupIds(matched);
|
||||
}).catch(() => {});
|
||||
}, [user.id]);
|
||||
|
||||
const toggleGroup = (gid: number) => {
|
||||
setSelectedGroupIds(prev => prev.includes(gid) ? prev.filter(id => id !== gid) : [...prev, gid]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.update(user.id, { display_name: displayName, email: email || undefined, role, is_active: isActive });
|
||||
// Update group associations
|
||||
if (selectedGroupIds.length >= 0) {
|
||||
await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds);
|
||||
}
|
||||
toast('success', '用户更新成功');
|
||||
onUpdated();
|
||||
} catch {
|
||||
toast('error', '更新失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">编辑用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.display_name || user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">显示名称</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">邮箱</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">角色</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||
<option value="admin">超级管理员</option>
|
||||
<option value="dba">数据库管理员</option>
|
||||
<option value="developer">开发人员</option>
|
||||
<option value="auditor">审计员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">权限组</label>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] py-2">暂无可选权限组</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-36 overflow-y-auto">
|
||||
{groups.map(g => (
|
||||
<label key={g.id} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-colors text-sm ${selectedGroupIds.includes(g.id) ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]'}`}>
|
||||
<input type="checkbox" checked={selectedGroupIds.includes(g.id)} onChange={() => toggleGroup(g.id)} className="rounded" />
|
||||
<span className="flex-1 truncate">{g.name}</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">{g.readonly_instance_count || 0}R / {g.write_instance_count || 0}W</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${isActive ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<div className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" style={{ transform: isActive ? 'translateX(20px)' : 'translateX(2px)' }} />
|
||||
</button>
|
||||
<span className="text-sm">{isActive ? '已启用' : '已禁用'}</span>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 用途:重置用户密码弹窗(新密码 + 二次确认)
|
||||
* 必需 props:user(目标用户)、onClose(关闭,成功后也由本组件回调关闭)
|
||||
* 已知限制:仅本地用户可用(LDAP 用户入口在表格行内已禁用)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { XCircle, KeyRound } from 'lucide-react';
|
||||
import { userApi } from '@/api/user';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface ResetPasswordModalProps {
|
||||
user: UserItem;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ResetPasswordModal({ user, onClose }: ResetPasswordModalProps) {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
if (!newPassword) { setError('请输入新密码'); return; }
|
||||
if (newPassword.length < 6) { setError('密码长度至少6位'); return; }
|
||||
if (newPassword !== confirmPassword) { setError('两次输入的密码不一致'); return; }
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.update(user.id, { password: newPassword });
|
||||
toast('success', '密码重置成功');
|
||||
onClose();
|
||||
} catch {
|
||||
toast('error', '密码重置失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-sm">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2"><KeyRound className="w-5 h-5 text-yellow-400" /> 重置密码</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-500/5 border border-yellow-500/15 rounded-lg mb-4">
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">为用户 <strong className="text-[var(--dg-text-primary)]">{user.display_name || user.username}</strong> 设置新密码</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">新密码</label>
|
||||
<input type="password" value={newPassword} onChange={e => { setNewPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="至少6位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">确认密码</label>
|
||||
<input type="password" value={confirmPassword} onChange={e => { setConfirmPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="再次输入" />
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-yellow-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '重置中...' : '确认重置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 用途:用户详情弹窗(基本信息卡片 + 邮箱/创建时间/最后登录/用户ID + 最近活动占位)
|
||||
* 必需 props:user(目标用户)、onClose(关闭)
|
||||
* 已知限制:「最近活动」为静态占位文案,暂未接入活动记录接口
|
||||
*/
|
||||
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { roleNameMap, roleTagStyle } from '../constants';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface UserDetailModalProps {
|
||||
user: UserItem;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 详情弹窗「最近活动」使用的时钟图标 */
|
||||
function Clock(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserDetailModal({ user, onClose }: UserDetailModalProps) {
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-lg">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">用户详情</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="flex items-center gap-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-xl mb-5">
|
||||
<div className="w-14 h-14 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white text-lg font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold">{user.display_name || user.username}</div>
|
||||
<div className="text-sm text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${roleTagStyle[user.role] || ''}`}>
|
||||
{roleNameMap[user.role] || user.role}
|
||||
</span>
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${user.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{user.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Fields */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">邮箱</div>
|
||||
<div className="text-sm font-medium truncate">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">创建时间</div>
|
||||
<div className="text-sm font-medium">{new Date(user.created_at).toLocaleDateString('zh-CN')}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">最后登录</div>
|
||||
<div className="text-sm font-medium">{user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '-'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">用户ID</div>
|
||||
<div className="text-sm font-medium">#{user.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Log */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
最近活动
|
||||
</h3>
|
||||
<div className="text-sm text-[var(--dg-text-muted)] text-center py-6">
|
||||
暂无活动记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 用途:用户列表筛选栏(关键字搜索 + 角色/状态/来源下拉 + 重置入口)
|
||||
* 必需 props:search / roleFilter / statusFilter / sourceFilter 及对应 onChange 回调、onReset
|
||||
* 已知限制:搜索为受控输入,每次按键即触发上层重新请求(保持原页面行为)
|
||||
*/
|
||||
|
||||
import { Search, Filter, RotateCcw } from 'lucide-react';
|
||||
import { roleOptions, statusOptions, sourceOptions } from '../constants';
|
||||
|
||||
interface UserFiltersProps {
|
||||
search: string;
|
||||
roleFilter: string;
|
||||
statusFilter: string;
|
||||
sourceFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onRoleFilterChange: (value: string) => void;
|
||||
onStatusFilterChange: (value: string) => void;
|
||||
onSourceFilterChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function UserFilters({
|
||||
search,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
sourceFilter,
|
||||
onSearchChange,
|
||||
onRoleFilterChange,
|
||||
onStatusFilterChange,
|
||||
onSourceFilterChange,
|
||||
onReset,
|
||||
}: UserFiltersProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20"
|
||||
placeholder="搜索用户名/邮箱/显示名称"
|
||||
value={search}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={roleFilter}
|
||||
onChange={e => onRoleFilterChange(e.target.value)}
|
||||
>
|
||||
{roleOptions.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={statusFilter}
|
||||
onChange={e => onStatusFilterChange(e.target.value)}
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={sourceFilter}
|
||||
onChange={e => onSourceFilterChange(e.target.value)}
|
||||
>
|
||||
{sourceOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
{(search || roleFilter || statusFilter || sourceFilter) && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" /> 重置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 用途:用户列表表格(多选、行内启用/禁用开关、详情/编辑/重置密码/删除入口)+ 分页底栏
|
||||
* 必需 props:users / loading / total / page / pageSize / selectedIds / allSelected 及各事件回调
|
||||
* 已知限制:仅 loading 且列表为空时显示加载态;空态「清除筛选条件」由 showClearFilters 控制显隐
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckSquare, Square, Search, Eye, Edit2, Trash2, KeyRound,
|
||||
ChevronLeft, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { roleNameMap, roleTagStyle } from '../constants';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface UserTableProps {
|
||||
users: UserItem[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
selectedIds: Set<number>;
|
||||
allSelected: boolean;
|
||||
showClearFilters: boolean;
|
||||
onToggleSelectAll: () => void;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onShowDetail: (user: UserItem) => void;
|
||||
onShowEdit: (user: UserItem) => void;
|
||||
onShowResetPassword: (user: UserItem) => void;
|
||||
onToggleActive: (user: UserItem) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onClearFilters: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function UserTable({
|
||||
users,
|
||||
loading,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
selectedIds,
|
||||
allSelected,
|
||||
showClearFilters,
|
||||
onToggleSelectAll,
|
||||
onToggleSelect,
|
||||
onShowDetail,
|
||||
onShowEdit,
|
||||
onShowResetPassword,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
onClearFilters,
|
||||
onPageChange,
|
||||
}: UserTableProps) {
|
||||
return (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{loading && users.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">没有找到匹配的用户</p>
|
||||
{showClearFilters && (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
|
||||
>
|
||||
清除筛选条件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-3 py-3 w-10">
|
||||
<button onClick={onToggleSelectAll} className="hover:opacity-70 transition-opacity">
|
||||
{allSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">用户</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">角色</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">邮箱</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">最后登录</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">来源</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => {
|
||||
const isSelected = selectedIds.has(user.id);
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={`border-b border-[var(--dg-border-light)] transition-colors ${
|
||||
isSelected ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-3.5">
|
||||
<button onClick={() => onToggleSelect(user.id)} className="hover:opacity-70 transition-opacity">
|
||||
{isSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => onShowDetail(user)}>
|
||||
<div>
|
||||
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'}`}>
|
||||
{roleNameMap[user.role] || user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">
|
||||
{user.last_login
|
||||
? new Date(user.last_login).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button
|
||||
onClick={() => onToggleActive(user)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
title={user.is_active ? '点击禁用' : '点击启用'}
|
||||
aria-label={user.is_active ? '禁用用户' : '启用用户'}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all"
|
||||
style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
|
||||
user.auth_source === 'ldap'
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
|
||||
}`}>
|
||||
{user.auth_source === 'ldap' ? 'LDAP' : '本地'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => onShowDetail(user)}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onShowEdit(user)}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => user.auth_source !== 'ldap' && onShowResetPassword(user)}
|
||||
className={`p-1.5 rounded-md transition-colors ${user.auth_source === 'ldap' ? 'opacity-30 cursor-not-allowed' : 'hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
|
||||
title={user.auth_source === 'ldap' ? 'LDAP 用户密码由 LDAP 服务器管理' : '重置密码'}
|
||||
disabled={user.auth_source === 'ldap'}
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded-md hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
|
||||
onClick={() => onDelete(user.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">
|
||||
共 <strong className="text-[var(--dg-text-primary)]">{total}</strong> 条
|
||||
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
|
||||
</span>
|
||||
{total > pageSize && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
||||
{page} / {Math.ceil(total / pageSize)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(Math.ceil(total / pageSize), page + 1))}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* ───────── RBAC 用户管理域常量 ───────── */
|
||||
|
||||
import { UserCheck, UserX, Trash2 } from 'lucide-react';
|
||||
|
||||
/** 角色值 → 中文名 */
|
||||
export const roleNameMap: Record<string, string> = {
|
||||
admin: '超级管理员',
|
||||
dba: '数据库管理员',
|
||||
developer: '开发人员',
|
||||
auditor: '审计员',
|
||||
};
|
||||
|
||||
/** 角色值 → 标签样式 */
|
||||
export const roleTagStyle: Record<string, string> = {
|
||||
admin: 'bg-cyan-500/15 text-cyan-400',
|
||||
dba: 'bg-blue-500/15 text-blue-400',
|
||||
developer: 'bg-green-500/15 text-green-400',
|
||||
auditor: 'bg-orange-500/15 text-orange-400',
|
||||
};
|
||||
|
||||
/** 筛选栏角色下拉选项 */
|
||||
export const roleOptions = [
|
||||
{ value: '', label: '全部角色' },
|
||||
{ value: 'admin', label: '超级管理员' },
|
||||
{ value: 'dba', label: '数据库管理员' },
|
||||
{ value: 'developer', label: '开发人员' },
|
||||
{ value: 'auditor', label: '审计员' },
|
||||
];
|
||||
|
||||
/** 筛选栏状态下拉选项 */
|
||||
export const statusOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'active', label: '启用' },
|
||||
{ value: 'disabled', label: '禁用' },
|
||||
];
|
||||
|
||||
/** 筛选栏来源下拉选项 */
|
||||
export const sourceOptions = [
|
||||
{ value: '', label: '全部来源' },
|
||||
{ value: 'local', label: '本地' },
|
||||
{ value: 'ldap', label: 'LDAP' },
|
||||
];
|
||||
|
||||
/** 批量操作菜单选项 */
|
||||
export const BATCH_OPTIONS = [
|
||||
{ key: 'enable', label: '批量启用', icon: UserCheck },
|
||||
{ key: 'disable', label: '批量禁用', icon: UserX },
|
||||
{ key: 'delete', label: '批量删除', icon: Trash2 },
|
||||
] as const;
|
||||
|
||||
/** 批量操作键类型 */
|
||||
export type BatchActionKey = (typeof BATCH_OPTIONS)[number]['key'];
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 用途:管理用户表格的多选状态(单选切换 / 全选切换 / 选中集合)
|
||||
* 必需参数:visible(当前可见的用户列表,用于计算全选状态)
|
||||
* 已知限制:翻页或刷新后不自动清除选中(与原页面行为一致,由调用方决定何时清空)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
export function useUserSelection(visible: UserItem[]) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const allSelected = visible.length > 0 && visible.every(u => selectedIds.has(u.id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
const next = new Set(selectedIds);
|
||||
visible.forEach(u => next.delete(u.id));
|
||||
setSelectedIds(next);
|
||||
} else {
|
||||
const next = new Set(selectedIds);
|
||||
visible.forEach(u => next.add(u.id));
|
||||
setSelectedIds(next);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
return { selectedIds, setSelectedIds, allSelected, toggleSelectAll, toggleSelect };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 用途:按分页与筛选条件拉取用户列表(参数组装与后端接口保持原样)
|
||||
* 必需参数:page / pageSize / search / roleFilter / statusFilter
|
||||
* 已知限制:来源(sourceFilter)为前端过滤,不参与请求;失败时 toast 必现
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { userApi } from '@/api/user';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
export interface UseUsersListParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
roleFilter: string;
|
||||
statusFilter: string;
|
||||
}
|
||||
|
||||
export function useUsersList({ page, pageSize, search, roleFilter, statusFilter }: UseUsersListParams) {
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: pageSize };
|
||||
if (search) params.search = search;
|
||||
if (roleFilter) params.role = roleFilter;
|
||||
if (statusFilter === 'active') params.is_active = true;
|
||||
else if (statusFilter === 'disabled') params.is_active = false;
|
||||
const res = await userApi.list(params);
|
||||
const data = res as any;
|
||||
setUsers(Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : []);
|
||||
setTotal(data?.total ?? data?.length ?? 0);
|
||||
} catch {
|
||||
toast('error', '获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, search, roleFilter, statusFilter]);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
return { users, total, loading, fetchUsers };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* ───────── RBAC 用户管理域共享类型 ───────── */
|
||||
|
||||
/** 用户条目(列表 / 详情 / 编辑共用) */
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login?: string | null;
|
||||
auth_source?: string; // 'local' | 'ldap'
|
||||
}
|
||||
|
||||
/** 用户动态记录(详情页最近活动预留) */
|
||||
export interface UserActivity {
|
||||
id: string;
|
||||
action: string;
|
||||
target: string;
|
||||
created_at: string;
|
||||
status: 'success' | 'failed';
|
||||
}
|
||||
|
||||
/** 新建用户表单校验错误 */
|
||||
export interface FormErrors {
|
||||
username?: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ───────── RBAC 用户管理域工具函数 ───────── */
|
||||
|
||||
import type { UserItem, FormErrors } from './types';
|
||||
import { roleNameMap } from './constants';
|
||||
|
||||
/** 新建用户表单校验(用户名/密码/邮箱) */
|
||||
export function validateUserForm(data: { username: string; password: string; email: string }): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
if (!data.username.trim()) {
|
||||
errors.username = '用户名不能为空';
|
||||
} else if (!/^[a-zA-Z0-9_]{3,30}$/.test(data.username)) {
|
||||
errors.username = '用户名只能包含字母、数字和下划线,长度3-30';
|
||||
}
|
||||
if (!data.password) {
|
||||
errors.password = '密码不能为空';
|
||||
} else if (data.password.length < 6) {
|
||||
errors.password = '密码长度至少6位';
|
||||
}
|
||||
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.email = '邮箱格式不正确';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 导出用户列表为 CSV(带 BOM,Excel 可直接打开) */
|
||||
export function exportUsersToCSV(users: UserItem[]) {
|
||||
const headers = ['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间'];
|
||||
const rows = users.map(u => [
|
||||
u.username,
|
||||
u.display_name || '',
|
||||
u.email || '',
|
||||
roleNameMap[u.role] || u.role,
|
||||
u.is_active ? '启用' : '禁用',
|
||||
u.created_at,
|
||||
]);
|
||||
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* LDAP 账号验证测试弹框
|
||||
* 输入用户名/密码进行绑定验证,并展示后端返回的用户属性信息
|
||||
* 凭据与结果状态由页面持有,输入变更时清空上次结果(与原页面行为一致)
|
||||
*/
|
||||
import { KeyRound, CheckCircle, XCircle, RefreshCw, Shield } from 'lucide-react';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import type { LdapAccountTestResult } from '../types';
|
||||
|
||||
interface LdapAccountTestDialogProps {
|
||||
testUser: string;
|
||||
testPass: string;
|
||||
result: LdapAccountTestResult | null;
|
||||
testing: boolean;
|
||||
onTestUserChange: (value: string) => void;
|
||||
onTestPassChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
export function LdapAccountTestDialog({
|
||||
testUser,
|
||||
testPass,
|
||||
result,
|
||||
testing,
|
||||
onTestUserChange,
|
||||
onTestPassChange,
|
||||
onClose,
|
||||
onTest,
|
||||
}: LdapAccountTestDialogProps) {
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-5 shadow-xl">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">账号验证测试</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单区 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户名</label>
|
||||
<input
|
||||
value={testUser}
|
||||
onChange={e => onTestUserChange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="LDAP 用户名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={testPass}
|
||||
onChange={e => onTestPassChange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="密码"
|
||||
onKeyDown={e => { if (e.key === 'Enter' && testUser && testPass) onTest(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果区 */}
|
||||
{result && (
|
||||
<div className="mb-4">
|
||||
{result.ok ? (
|
||||
<div className="rounded-lg bg-emerald-500/12 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-emerald-400">验证通过</span>
|
||||
</div>
|
||||
{result.info && (
|
||||
<div className="space-y-1 pl-6 max-h-24 overflow-y-auto">
|
||||
{Object.entries(result.info)
|
||||
.filter(([, v]) => v && String(v).length > 0)
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="flex text-xs gap-2">
|
||||
<span className="text-emerald-400/60 shrink-0">{
|
||||
k === 'Display Name' ? '姓名' :
|
||||
k === 'Email' ? '邮箱' :
|
||||
k === 'User DN' ? 'DN' :
|
||||
k === 'User ID' ? '用户名' : k
|
||||
}</span>
|
||||
<span className="text-emerald-300">{
|
||||
Array.isArray(v) ? v.join(', ') : String(v)
|
||||
}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-red-500/12 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-red-400">验证失败</span>
|
||||
</div>
|
||||
<p className="text-xs text-red-300/80 mt-1 pl-6">{result.msg}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">关闭</button>
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing || !testUser || !testPass}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{testing ? (
|
||||
<><RefreshCw className="w-3.5 h-3.5 animate-spin" />验证中…</>
|
||||
) : (
|
||||
<><Shield className="w-3.5 h-3.5" />测试验证</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 添加 LDAP 用户弹框
|
||||
* 填写用户名 / 密码 / 显示名称 / 邮箱与目标 OU 后创建 LDAP 用户
|
||||
* 表单状态由页面持有(关闭不重置,仅创建成功后重置),与原页面行为一致
|
||||
*/
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import type { AddUserFormState, OuItem } from '../types';
|
||||
|
||||
interface LdapAddUserDialogProps {
|
||||
form: AddUserFormState;
|
||||
onFormChange: (patch: Partial<AddUserFormState>) => void;
|
||||
ouList: OuItem[];
|
||||
baseDn: string;
|
||||
adding: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function LdapAddUserDialog({
|
||||
form,
|
||||
onFormChange,
|
||||
ouList,
|
||||
baseDn,
|
||||
adding,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: LdapAddUserDialogProps) {
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-5 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserPlus className="w-4 h-4 text-emerald-400" />
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">添加用户到 LDAP</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">✕</button>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户名 *</label>
|
||||
<input
|
||||
value={form.username}
|
||||
onChange={e => onFormChange({ username: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="登录名,如 zhangsan"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">密码 *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => onFormChange({ password: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="初始密码"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">显示名称</label>
|
||||
<input
|
||||
value={form.displayName}
|
||||
onChange={e => onFormChange({ displayName: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="如: 张三"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">邮箱</label>
|
||||
<input
|
||||
value={form.email}
|
||||
onChange={e => onFormChange({ email: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">目标 OU</label>
|
||||
<select
|
||||
value={form.ouDn}
|
||||
onChange={e => onFormChange({ ouDn: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
>
|
||||
<option value="">根: {baseDn}</option>
|
||||
{ouList.map(ou => <option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={adding || !form.username.trim() || !form.password.trim()}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{adding ? '创建中…' : '创建用户'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* LDAP 基础配置表单
|
||||
* 包含服务器/管理员/搜索配置与属性映射,以及「测试连接 / 保存配置」操作栏
|
||||
* 受控组件:配置与密码回显状态由页面持有,通过 onConfigChange 回传更新
|
||||
*/
|
||||
import { Save, CheckCircle, XCircle, Shield } from 'lucide-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { LdapConfigData, LdapTestResult } from '../types';
|
||||
|
||||
interface LdapConfigFormProps {
|
||||
config: LdapConfigData;
|
||||
hasStoredPwd: boolean;
|
||||
testing: boolean;
|
||||
saving: boolean;
|
||||
testResult: LdapTestResult | null;
|
||||
onConfigChange: Dispatch<SetStateAction<LdapConfigData>>;
|
||||
onTestServer: () => void;
|
||||
onSave: () => void;
|
||||
onOpenAccountTest: () => void;
|
||||
}
|
||||
|
||||
export function LdapConfigForm({
|
||||
config,
|
||||
hasStoredPwd,
|
||||
testing,
|
||||
saving,
|
||||
testResult,
|
||||
onConfigChange,
|
||||
onTestServer,
|
||||
onSave,
|
||||
onOpenAccountTest,
|
||||
}: LdapConfigFormProps) {
|
||||
/* ── helpers ── */
|
||||
const updateField = <K extends keyof LdapConfigData>(key: K, value: LdapConfigData[K]) => {
|
||||
onConfigChange(prev => {
|
||||
const next = { ...prev, [key]: value };
|
||||
if (key === 'use_ldaps') {
|
||||
if (value && prev.ldap_port === 389) next.ldap_port = 636;
|
||||
if (!value && prev.ldap_port === 636) next.ldap_port = 389;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/* 属性映射字段更新 */
|
||||
const updateMapping = (key: string, value: string) => {
|
||||
onConfigChange(p => ({ ...p, user_mapping: { ...p.user_mapping, [key]: value } }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl p-5 space-y-5">
|
||||
{/* 服务器 */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-6">
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">LDAP 服务器</label>
|
||||
<input
|
||||
value={config.ldap_host}
|
||||
onChange={e => updateField('ldap_host', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="8.217.205.92"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.ldap_port}
|
||||
onChange={e => updateField('ldap_port', parseInt(e.target.value) || 389)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 flex items-end pb-1">
|
||||
<button
|
||||
onClick={() => updateField('use_ldaps', !config.use_ldaps)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors ${config.use_ldaps ? 'bg-emerald-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${config.use_ldaps ? 'translate-x-4.5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] ml-2">LDAPS (SSL/TLS)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 管理员 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">管理员 DN</label>
|
||||
<input
|
||||
value={config.admin_dn}
|
||||
onChange={e => updateField('admin_dn', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="cn=admin,dc=opsx,dc=vip"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">管理员密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={config.admin_password}
|
||||
onChange={e => updateField('admin_password', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder={hasStoredPwd ? '已保存,输入新密码以修改' : '请输入密码'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索配置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Base DN</label>
|
||||
<input
|
||||
value={config.base_dn}
|
||||
onChange={e => updateField('base_dn', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="dc=opsx,dc=vip"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户过滤</label>
|
||||
<input
|
||||
value={config.search_filter}
|
||||
onChange={e => updateField('search_filter', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="(objectClass=inetOrgPerson)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性映射 */}
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">属性映射</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户名</label>
|
||||
<input
|
||||
value={config.user_mapping.username || ''}
|
||||
onChange={e => updateMapping('username', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="uid"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">邮箱</label>
|
||||
<input
|
||||
value={config.user_mapping.email || ''}
|
||||
onChange={e => updateMapping('email', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="mail"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">姓名</label>
|
||||
<input
|
||||
value={config.user_mapping.display_name || ''}
|
||||
onChange={e => updateMapping('display_name', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="cn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作栏:测试 + 保存 */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={onTestServer}
|
||||
disabled={testing || !config.ldap_host || !config.admin_dn}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-blue-500/15 text-blue-400 hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{testing ? '测试中…' : '测试连接'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<span className={`flex items-center gap-1 text-sm ${testResult.ok ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{testResult.ok ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||
{testResult.msg}
|
||||
</span>
|
||||
)}
|
||||
{config.ldap_host && config.base_dn && (
|
||||
<button
|
||||
onClick={onOpenAccountTest}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
测试账号验证
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 新建组织单元(OU)弹框
|
||||
* 填写 OU 名称 / 父级 DN / 描述后调用后端创建
|
||||
* 表单状态由页面持有(关闭不重置,仅创建成功后重置),与原页面行为一致
|
||||
*/
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import type { CreateOuFormState, OuItem } from '../types';
|
||||
|
||||
interface LdapCreateOuDialogProps {
|
||||
form: CreateOuFormState;
|
||||
onFormChange: (patch: Partial<CreateOuFormState>) => void;
|
||||
ouList: OuItem[];
|
||||
baseDn: string;
|
||||
creating: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function LdapCreateOuDialog({
|
||||
form,
|
||||
onFormChange,
|
||||
ouList,
|
||||
baseDn,
|
||||
creating,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: LdapCreateOuDialogProps) {
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-5 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="w-4 h-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">新建组织单元 (OU)</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">✕</button>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">OU 名称 *</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={e => onFormChange({ name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="如: devops, engineering"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">父级 DN</label>
|
||||
<select
|
||||
value={form.parent}
|
||||
onChange={e => onFormChange({ parent: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
>
|
||||
<option value="">根: {baseDn}</option>
|
||||
{ouList.map(ou => <option key={ou.dn} value={ou.dn}>{ou.dn}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">描述</label>
|
||||
<input
|
||||
value={form.desc}
|
||||
onChange={e => onFormChange({ desc: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="可选,如: 研发团队"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={creating || !form.name.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creating ? '创建中…' : '创建 OU'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* LDAP 账号管理折叠面板
|
||||
* 提供组织单元(OU)切换/刷新/新建入口与所选 OU 下的用户浏览区域
|
||||
* 折叠状态与数据均由页面控制,切换 OU 时经回调触发用户列表加载
|
||||
*/
|
||||
import { ChevronRight, ChevronDown, RefreshCw, FolderOpen } from 'lucide-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { OuItem, LdapUser } from '../types';
|
||||
import { LdapUserTable } from './LdapUserTable';
|
||||
|
||||
interface LdapManagePanelProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
baseDn: string;
|
||||
ouList: OuItem[];
|
||||
loadingOu: boolean;
|
||||
selectedOu: string;
|
||||
onSelectedOuChange: (dn: string) => void;
|
||||
onRefreshOus: () => void;
|
||||
onCreateOu: () => void;
|
||||
onAddUser: () => void;
|
||||
ouUsers: LdapUser[];
|
||||
loadingUsers: boolean;
|
||||
selectedDns: Set<string>;
|
||||
onSelectedDnsChange: Dispatch<SetStateAction<Set<string>>>;
|
||||
deleting: boolean;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export function LdapManagePanel({
|
||||
expanded,
|
||||
onToggle,
|
||||
baseDn,
|
||||
ouList,
|
||||
loadingOu,
|
||||
selectedOu,
|
||||
onSelectedOuChange,
|
||||
onRefreshOus,
|
||||
onCreateOu,
|
||||
onAddUser,
|
||||
ouUsers,
|
||||
loadingUsers,
|
||||
selectedDns,
|
||||
onSelectedDnsChange,
|
||||
deleting,
|
||||
onDeleteSelected,
|
||||
}: LdapManagePanelProps) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl overflow-hidden">
|
||||
{/* 折叠头 */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 px-5 py-3.5 hover:bg-[var(--dg-bg-hover)] transition-colors text-left"
|
||||
>
|
||||
{expanded ? <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">LDAP 账号管理</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-orange-500/15 text-orange-400">危险操作</span>
|
||||
</button>
|
||||
|
||||
{/* 折叠内容 */}
|
||||
{expanded && (
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
<div className="px-3 py-2 rounded-lg bg-orange-500/8 text-orange-300 text-xs">
|
||||
此区域包含 LDAP 危险操作,删除不可逆,请谨慎使用。
|
||||
</div>
|
||||
|
||||
{/* OU 选择 + 操作按钮 */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<FolderOpen className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">组织单元 (OU)</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">({ouList.length})</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedOu}
|
||||
onChange={e => onSelectedOuChange(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
>
|
||||
<option value="">选择 OU...</option>
|
||||
<option value={baseDn}>[根] {baseDn}</option>
|
||||
{ouList.map(ou => (
|
||||
<option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={onRefreshOus}
|
||||
disabled={loadingOu}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
title="刷新 OU 列表"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loadingOu ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreateOu}
|
||||
className="px-3 py-2 rounded-lg bg-blue-500/15 text-blue-400 hover:bg-blue-500/25 transition-colors text-sm"
|
||||
>
|
||||
+ 新建 OU
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddUser}
|
||||
className="px-3 py-2 rounded-lg bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25 transition-colors text-sm"
|
||||
>
|
||||
+ 添加用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
{selectedOu && (
|
||||
<div>
|
||||
{loadingUsers ? (
|
||||
<div className="text-center py-8 text-sm text-[var(--dg-text-muted)]">加载用户列表…</div>
|
||||
) : ouUsers.length > 0 ? (
|
||||
<LdapUserTable
|
||||
users={ouUsers}
|
||||
selectedDns={selectedDns}
|
||||
deleting={deleting}
|
||||
onSelectedDnsChange={onSelectedDnsChange}
|
||||
onDeleteSelected={onDeleteSelected}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-[var(--dg-text-muted)]">该 OU 下暂无用户</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* LDAP OU 用户列表
|
||||
* 提供全选/行选、批量删除入口与用户信息表格展示
|
||||
* 选中集合由页面持有,变更通过 onSelectedDnsChange 上抛
|
||||
*/
|
||||
import { Trash2, Users } from 'lucide-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { LdapUser } from '../types';
|
||||
|
||||
interface LdapUserTableProps {
|
||||
users: LdapUser[];
|
||||
selectedDns: Set<string>;
|
||||
deleting: boolean;
|
||||
onSelectedDnsChange: Dispatch<SetStateAction<Set<string>>>;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export function LdapUserTable({
|
||||
users,
|
||||
selectedDns,
|
||||
deleting,
|
||||
onSelectedDnsChange,
|
||||
onDeleteSelected,
|
||||
}: LdapUserTableProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedDns.size > 0 && (
|
||||
<button
|
||||
onClick={onDeleteSelected}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-red-500/15 text-red-400 hover:bg-red-500/25 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{deleting ? '删除中…' : `删除 (${selectedDns.size})`}
|
||||
</button>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-secondary)] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDns.size === users.length && users.length > 0}
|
||||
ref={el => { if (el) el.indeterminate = selectedDns.size > 0 && selectedDns.size < users.length; }}
|
||||
onChange={() => {
|
||||
if (selectedDns.size === users.length) onSelectedDnsChange(new Set());
|
||||
else onSelectedDnsChange(new Set(users.map(u => u.dn)));
|
||||
}}
|
||||
className="rounded border-[var(--dg-border)]"
|
||||
/>
|
||||
全选
|
||||
</label>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
共 {users.length} 个用户,已选 {selectedDns.size} 个
|
||||
</span>
|
||||
</div>
|
||||
<Users className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg overflow-hidden border border-[var(--dg-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="w-10 px-3 py-2 text-left"></th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-muted)] font-medium">用户名</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-muted)] font-medium">显示名称</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-muted)] font-medium">邮箱</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-muted)] font-medium">DN</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<tr key={user.dn} className="border-t border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDns.has(user.dn)}
|
||||
onChange={() => {
|
||||
onSelectedDnsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(user.dn) ? next.delete(user.dn) : next.add(user.dn);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="rounded border-[var(--dg-border)]"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-primary)]">{user.uid}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{user.cn}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{user.mail || '-'}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-muted)] text-xs font-mono">{user.dn}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* LDAP 配置域共享类型与默认配置
|
||||
* 供 LdapConfigPage 页面骨架与 ldap/components 下各组件引用
|
||||
* 密码不回显约定:admin_password 置空 + hasStoredPwd=true 表示沿用后端已存密码(AES 存储不回传明文)
|
||||
*/
|
||||
|
||||
export interface LdapConfigData {
|
||||
ldap_host: string;
|
||||
ldap_port: number;
|
||||
use_ldaps: boolean;
|
||||
admin_dn: string;
|
||||
admin_password: string;
|
||||
search_filter: string;
|
||||
base_dn: string;
|
||||
user_mapping: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface OuItem { dn: string; name: string }
|
||||
export interface LdapUser { dn: string; uid: string; cn: string; mail: string }
|
||||
|
||||
/* 服务器连接测试结果 */
|
||||
export interface LdapTestResult { ok: boolean; msg: string }
|
||||
|
||||
/* 账号验证测试结果(info 为返回的用户属性) */
|
||||
export interface LdapAccountTestResult {
|
||||
ok: boolean;
|
||||
msg: string;
|
||||
info?: Record<string, string>;
|
||||
}
|
||||
|
||||
/* 新建 OU 弹框表单(状态由页面持有,仅创建成功后重置) */
|
||||
export interface CreateOuFormState {
|
||||
name: string;
|
||||
desc: string;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
/* 添加用户弹框表单(状态由页面持有,仅创建成功后重置) */
|
||||
export interface AddUserFormState {
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
ouDn: string;
|
||||
}
|
||||
|
||||
export const defaultLdapConfig: LdapConfigData = {
|
||||
ldap_host: '',
|
||||
ldap_port: 389,
|
||||
use_ldaps: false,
|
||||
admin_dn: '',
|
||||
admin_password: '',
|
||||
search_filter: '(objectClass=inetOrgPerson)',
|
||||
base_dn: '',
|
||||
user_mapping: { username: 'uid', email: 'mail', display_name: 'cn' },
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Shield, CheckCircle, XCircle } from 'lucide-react';
|
||||
import type { ApprovalStep } from './DetailTypes';
|
||||
|
||||
interface DetailApprovalFlowProps {
|
||||
/** 审批流程步骤 */
|
||||
steps: ApprovalStep[];
|
||||
}
|
||||
|
||||
/** 动作状态文案 */
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
pending: '待处理',
|
||||
skipped: '已跳过',
|
||||
};
|
||||
|
||||
/** 动作状态样式 */
|
||||
const ACTION_STYLE: Record<string, { color: string; bg: string }> = {
|
||||
approved: { color: 'text-green-400', bg: 'bg-green-500/15' },
|
||||
rejected: { color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||
pending: { color: 'text-[var(--dg-text-muted)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
|
||||
};
|
||||
|
||||
/** 步骤名称关键字对应的强调色 */
|
||||
const STEP_ACCENT: Record<string, string> = {
|
||||
'提交': 'text-blue-400',
|
||||
'审核': 'text-amber-400',
|
||||
'执行': 'text-cyan-400',
|
||||
};
|
||||
|
||||
/** 按步骤名称关键字取强调色 */
|
||||
function getAccent(name: string): string {
|
||||
for (const key of Object.keys(STEP_ACCENT)) {
|
||||
if (name.includes(key)) return STEP_ACCENT[key];
|
||||
}
|
||||
return 'text-[var(--dg-text-secondary)]';
|
||||
}
|
||||
|
||||
/** 工单详情:审批流程记录 */
|
||||
export default function DetailApprovalFlow({ steps }: DetailApprovalFlowProps) {
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-4">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> 审批流程
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => {
|
||||
const actCfg = ACTION_STYLE[step.action] || ACTION_STYLE.pending;
|
||||
const isCurrent = step.action === 'pending';
|
||||
const isDone = step.action === 'approved' || step.action === 'rejected';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id || idx}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg ${
|
||||
isCurrent
|
||||
? 'bg-blue-500/5 border border-blue-500/20'
|
||||
: 'bg-[var(--dg-bg-primary)]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full ${actCfg.bg} flex items-center justify-center shrink-0 mt-0.5`}>
|
||||
{isDone && step.action === 'approved' ? (
|
||||
<CheckCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
|
||||
) : isDone ? (
|
||||
<XCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
|
||||
) : (
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isCurrent ? 'bg-blue-400 animate-pulse' : 'bg-[var(--dg-text-muted)]'}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${getAccent(step.step_name)}`}>
|
||||
{step.step_name}
|
||||
</span>
|
||||
{step.approver_name && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
{step.approver_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${actCfg.bg} ${actCfg.color} shrink-0`}>
|
||||
{ACTION_LABEL[step.action] || step.action}
|
||||
</span>
|
||||
</div>
|
||||
{step.comment && step.comment !== '提交工单' && (
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-1">{step.comment}</p>
|
||||
)}
|
||||
{step.updated_at && isDone && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
{new Date(step.updated_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
CheckCircle, XCircle, Play, RotateCcw, X,
|
||||
MessageSquare, Loader2, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DetailApprovalPanelProps {
|
||||
/** 是否允许审批(通过/拒绝) */
|
||||
canApprove: boolean;
|
||||
/** 是否允许执行 */
|
||||
canExecute: boolean;
|
||||
/** 是否允许回滚 */
|
||||
canRollback: boolean;
|
||||
/** 是否允许取消工单 */
|
||||
canCancel: boolean;
|
||||
/** 当前执行中的动作标识(approve / reject / execute / rollback / cancel) */
|
||||
actionLoading: string | null;
|
||||
/** 是否展示评论输入框 */
|
||||
commentVisible: boolean;
|
||||
/** 待提交的审批动作(approve / reject) */
|
||||
pendingAction: string | null;
|
||||
/** 评论内容 */
|
||||
comment: string;
|
||||
/** 是否展示回滚二次确认 */
|
||||
rollbackConfirmVisible: boolean;
|
||||
/** 提交动作 */
|
||||
onAction: (action: string) => void;
|
||||
/** 打开评论输入并记录待提交动作 */
|
||||
onStartComment: (action: string) => void;
|
||||
/** 取消评论输入 */
|
||||
onCancelComment: () => void;
|
||||
/** 评论内容变更 */
|
||||
onCommentChange: (value: string) => void;
|
||||
/** 打开回滚二次确认 */
|
||||
onRequestRollback: () => void;
|
||||
/** 取消回滚二次确认 */
|
||||
onCancelRollback: () => void;
|
||||
}
|
||||
|
||||
/** 工单详情:审批/执行操作面板(通过、拒绝、评论、执行、回滚、取消) */
|
||||
export default function DetailApprovalPanel({
|
||||
canApprove,
|
||||
canExecute,
|
||||
canRollback,
|
||||
canCancel,
|
||||
actionLoading,
|
||||
commentVisible,
|
||||
pendingAction,
|
||||
comment,
|
||||
rollbackConfirmVisible,
|
||||
onAction,
|
||||
onStartComment,
|
||||
onCancelComment,
|
||||
onCommentChange,
|
||||
onRequestRollback,
|
||||
onCancelRollback,
|
||||
}: DetailApprovalPanelProps) {
|
||||
// 回滚二次确认条
|
||||
if (rollbackConfirmVisible) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
|
||||
<p className="text-xs text-orange-300/70">回滚操作不可逆,将撤销已执行的 SQL 变更</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAction('rollback')}
|
||||
disabled={!!actionLoading}
|
||||
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded-md text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === 'rollback' ? <Loader2 className="w-4 h-4 animate-spin" /> : '确认回滚'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelRollback}
|
||||
className="px-3 py-1.5 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Approve / Reject */}
|
||||
{canApprove && (
|
||||
<>
|
||||
{!commentVisible ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onStartComment('approve')}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" /> 审批通过
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onStartComment('reject')}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all"
|
||||
>
|
||||
<XCircle className="w-4 h-4" /> 审批拒绝
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 w-full">
|
||||
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={comment}
|
||||
onChange={(e) => onCommentChange(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder={pendingAction === 'reject' ? '请输入拒绝原因...' : '请输入审批备注(可选)...'}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => pendingAction && onAction(pendingAction)}
|
||||
disabled={!!actionLoading}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
pendingAction === 'reject'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{actionLoading === pendingAction ? <Loader2 className="w-4 h-4 animate-spin" /> : pendingAction === 'reject' ? '确认拒绝' : '确认通过'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelComment}
|
||||
className="px-3 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Execute */}
|
||||
{canExecute && (
|
||||
<button
|
||||
onClick={() => onAction('execute')}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-cyan-500/15 border border-cyan-500/30 rounded-lg text-sm font-medium text-cyan-400 hover:bg-cyan-500/25 transition-all"
|
||||
>
|
||||
{actionLoading === 'execute' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
执行
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Rollback */}
|
||||
{canRollback && (
|
||||
<button
|
||||
onClick={onRequestRollback}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" /> 回滚
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={() => onAction('cancel')}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all"
|
||||
>
|
||||
{actionLoading === 'cancel' ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
|
||||
取消工单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react';
|
||||
import { Shield, CheckCircle, XCircle, ChevronUp, ChevronDown, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface DetailAuditResultProps {
|
||||
/** 原始 SQL 审核结果(JSON 字符串或纯文本) */
|
||||
auditResult: string;
|
||||
}
|
||||
|
||||
/** 工单详情:SQL 审核结果(评分 + 问题发现列表,可展开收起) */
|
||||
export default function DetailAuditResult({ auditResult }: DetailAuditResultProps) {
|
||||
// 展开状态仅本组件使用
|
||||
const [showFullAudit, setShowFullAudit] = useState(false);
|
||||
|
||||
// 解析审核结果 JSON(兼容纯文本)
|
||||
let audit: any = null;
|
||||
try { audit = typeof auditResult === 'string' ? JSON.parse(auditResult) : auditResult; } catch { audit = null; }
|
||||
|
||||
// 无结构化 findings:按纯文本展示
|
||||
if (!audit || !audit.findings) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[var(--dg-text-secondary)]">{auditResult}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorCount = audit.findings.filter((f: any) => f.level === 'error').length;
|
||||
const warningCount = audit.findings.filter((f: any) => f.level === 'warning').length;
|
||||
const infoCount = audit.findings.filter((f: any) => f.level === 'info').length;
|
||||
const scoreColor = audit.score >= 90 ? 'text-green-400' : audit.score >= 60 ? 'text-amber-400' : 'text-red-400';
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<button
|
||||
onClick={() => setShowFullAudit(!showFullAudit)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> SQL 审核结果
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xl font-bold ${scoreColor}`}>{audit.score ?? '-'}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">分</span>
|
||||
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
{audit.passed ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-green-500/15 text-green-400">
|
||||
<CheckCircle className="w-3 h-3" /> 审核通过
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-red-500/15 text-red-400">
|
||||
<XCircle className="w-3 h-3" /> 存在阻断项
|
||||
</span>
|
||||
)}
|
||||
{errorCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-red-500/15 text-red-400">{errorCount} 错误</span>}
|
||||
{warningCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-amber-500/15 text-amber-400">{warningCount} 警告</span>}
|
||||
{infoCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-blue-500/15 text-blue-400">{infoCount} 提示</span>}
|
||||
</div>
|
||||
|
||||
{/* Findings table */}
|
||||
{showFullAudit && audit.findings.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{/* 问题项无唯一标识(可能同规则重复),使用索引 key */}
|
||||
{audit.findings.map((f: any, idx: number) => {
|
||||
const levelConfig: Record<string, { label: string; color: string; icon: any }> = {
|
||||
error: { label: '错误', color: 'text-red-400 bg-red-500/15', icon: XCircle },
|
||||
warning: { label: '警告', color: 'text-amber-400 bg-amber-500/15', icon: AlertTriangle },
|
||||
info: { label: '提示', color: 'text-blue-400 bg-blue-500/15', icon: CheckCircle },
|
||||
};
|
||||
const lc = levelConfig[f.level] || levelConfig.info;
|
||||
const LevelIcon = lc.icon;
|
||||
return (
|
||||
<div key={idx} className={`p-3 rounded-lg border border-[var(--dg-border)] ${f.level === 'error' ? 'border-red-500/20' : ''}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LevelIcon className={`w-3.5 h-3.5 ${lc.color.split(' ')[0]}`} />
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${lc.color}`}>{lc.label}</span>
|
||||
{f.rule && <span className="text-xs text-[var(--dg-text-muted)] font-mono">{f.rule}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-primary)] pl-6">{f.message}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary text */}
|
||||
{!showFullAudit && audit.summary && (
|
||||
<p className="mt-2 text-xs text-[var(--dg-text-muted)]">{audit.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Play, CheckCircle, XCircle, Clock, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface DetailExecuteResultProps {
|
||||
/** 原始执行结果(JSON 字符串或纯文本) */
|
||||
executeResult: string;
|
||||
/** 执行时间 */
|
||||
executeTime: string | null;
|
||||
}
|
||||
|
||||
/** 工单详情:执行结果可视化(结构化 JSON 展示,解析失败时退化为纯文本) */
|
||||
export default function DetailExecuteResult({ executeResult, executeTime }: DetailExecuteResultProps) {
|
||||
// 解析执行结果 JSON(兼容纯文本)
|
||||
let execResult: any = null;
|
||||
try { execResult = typeof executeResult === 'string' ? JSON.parse(executeResult) : executeResult; } catch { execResult = null; }
|
||||
|
||||
// 非 JSON 结构:按纯文本展示
|
||||
if (!execResult || typeof execResult !== 'object') {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
||||
</h3>
|
||||
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||
{executeResult}
|
||||
</pre>
|
||||
{executeTime && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
执行时间: {new Date(executeTime).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSuccess = execResult.status === 'success';
|
||||
const StatusIcon = isSuccess ? CheckCircle : XCircle;
|
||||
const statusColor = isSuccess ? 'text-green-400' : 'text-red-400';
|
||||
const statusBg = isSuccess ? 'bg-green-500/15' : 'bg-red-500/15';
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
||||
</h3>
|
||||
|
||||
{/* Status badge + key metrics */}
|
||||
<div className="flex items-center gap-3 flex-wrap mb-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium ${statusBg} ${statusColor}`}>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
{isSuccess ? '执行成功' : '执行失败'}
|
||||
</span>
|
||||
{execResult.affected_rows !== undefined && (
|
||||
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
|
||||
影响行数: <span className="font-medium">{execResult.affected_rows}</span>
|
||||
</span>
|
||||
)}
|
||||
{execResult.duration_ms !== undefined && (
|
||||
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
|
||||
耗时: <span className="font-medium">{execResult.duration_ms}ms</span>
|
||||
</span>
|
||||
)}
|
||||
{execResult.validation_passed !== undefined && (
|
||||
<span className={`px-2.5 py-1 rounded text-xs ${execResult.validation_passed ? 'text-green-400 bg-green-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
|
||||
{execResult.validation_passed ? '✓ 验证通过' : '✗ 验证未通过'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{execResult.errors && execResult.errors.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{/* 错误为纯字符串数组,无唯一标识,使用索引 key */}
|
||||
{execResult.errors.map((err: string, idx: number) => (
|
||||
<div key={idx} className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-xs font-medium text-red-400">错误 {execResult.errors.length > 1 ? `#${idx + 1}` : ''}</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-300 pl-5">{err}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{execResult.warnings && execResult.warnings.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{/* 警告为纯字符串数组,无唯一标识,使用索引 key */}
|
||||
{execResult.warnings.map((w: string, idx: number) => (
|
||||
<div key={idx} className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-400">警告 {execResult.warnings.length > 1 ? `#${idx + 1}` : ''}</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-300 pl-5">{w}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw output (if present) */}
|
||||
{execResult.raw_output && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">原始输出</p>
|
||||
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto border border-[var(--dg-border-light)]">
|
||||
{execResult.raw_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{executeTime && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-3 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
执行时间: {new Date(executeTime).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { Link2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { statusMap, statusColorMap } from '@/lib/constants';
|
||||
import type { RelatedTicket } from './DetailTypes';
|
||||
|
||||
interface DetailRelatedTicketsProps {
|
||||
/** 关联工单列表 */
|
||||
tickets: RelatedTicket[];
|
||||
/** 点击关联工单跳转 */
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
/** 工单详情:同实例关联工单(可折叠) */
|
||||
export default function DetailRelatedTickets({ tickets, onSelect }: DetailRelatedTicketsProps) {
|
||||
// 折叠状态仅本组件使用(默认展开)
|
||||
const [showRelated, setShowRelated] = useState(true);
|
||||
|
||||
if (tickets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<button
|
||||
onClick={() => setShowRelated(!showRelated)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-blue-400" /> 关联工单
|
||||
</h3>
|
||||
{showRelated ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
|
||||
{showRelated && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{tickets.map((rt) => (
|
||||
<button
|
||||
key={rt.id}
|
||||
onClick={() => onSelect(rt.id)}
|
||||
className="w-full flex items-center justify-between p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)] hover:border-blue-500/30 hover:bg-blue-500/5 transition-all text-left"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
TK-{rt.id} · {rt.title}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
由 {rt.applicant_name || '-'} 创建于 {new Date(rt.create_time).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColorMap[rt.status] || ''}`}>
|
||||
{statusMap[rt.status] || rt.status}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
|
||||
interface DetailRollbackSqlCardProps {
|
||||
/** 回滚 SQL 内容 */
|
||||
rollbackSql: string;
|
||||
}
|
||||
|
||||
/** 工单详情:回滚 SQL 卡片(只读展示) */
|
||||
export default function DetailRollbackSqlCard({ rollbackSql }: DetailRollbackSqlCardProps) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<RotateCcw className="w-4 h-4 text-cyan-400" /> 回滚 SQL
|
||||
</h3>
|
||||
<SqlEditor
|
||||
value={rollbackSql}
|
||||
onChange={() => { /* read-only */ }}
|
||||
language="sql"
|
||||
height="120px"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FileCode } from 'lucide-react';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
|
||||
interface DetailSqlCardProps {
|
||||
/** 工单 SQL 内容 */
|
||||
sqlContent: string;
|
||||
}
|
||||
|
||||
/** 工单详情:SQL 内容卡片(只读展示) */
|
||||
export default function DetailSqlCard({ sqlContent }: DetailSqlCardProps) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<FileCode className="w-4 h-4 text-blue-400" /> SQL 内容
|
||||
</h3>
|
||||
<SqlEditor
|
||||
value={sqlContent}
|
||||
onChange={() => { /* read-only */ }}
|
||||
language="sql"
|
||||
height="280px"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Database } from 'lucide-react';
|
||||
import type { TicketData } from './DetailTypes';
|
||||
|
||||
/** 工单信息条目 */
|
||||
interface TicketInfoItem {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ElementType;
|
||||
}
|
||||
|
||||
interface DetailTicketInfoCardProps {
|
||||
/** 工单数据 */
|
||||
ticket: TicketData;
|
||||
}
|
||||
|
||||
/** 工单详情:工单信息卡片 */
|
||||
export default function DetailTicketInfoCard({ ticket }: DetailTicketInfoCardProps) {
|
||||
// 信息条目(审批/执行时间为空时不展示)
|
||||
const infoItems: Array<TicketInfoItem | string | null> = [
|
||||
{ label: '工单号', value: `TK-${ticket.id}` },
|
||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||
{ label: '目标数据库', value: ticket.target_database, icon: Database },
|
||||
{ label: '申请人', value: ticket.applicant_name || '-' },
|
||||
{
|
||||
label: '创建时间',
|
||||
value: new Date(ticket.create_time).toLocaleString(),
|
||||
},
|
||||
ticket.review_time && {
|
||||
label: '审批时间',
|
||||
value: new Date(ticket.review_time).toLocaleString(),
|
||||
},
|
||||
ticket.execute_time && {
|
||||
label: '执行时间',
|
||||
value: new Date(ticket.execute_time).toLocaleString(),
|
||||
},
|
||||
{ label: '变更原因', value: ticket.reason || '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||
<h3 className="text-[15px] font-semibold border-b border-[var(--dg-border)] pb-3">工单信息</h3>
|
||||
<div className="space-y-1">
|
||||
{infoItems
|
||||
.filter((item): item is TicketInfoItem => Boolean(item))
|
||||
.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
// 条目 label 唯一,作为稳定 key
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex flex-col sm:flex-row sm:justify-between gap-1 sm:gap-3 py-2 border-b border-[var(--dg-border-light)] last:border-0"
|
||||
>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
||||
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-sm font-medium break-words sm:text-right">{item.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Clock, CheckCircle, XCircle, Play, RotateCcw, FileCode,
|
||||
Shield, User, Calendar,
|
||||
} from 'lucide-react';
|
||||
import type { TimelineEntry } from './DetailTypes';
|
||||
|
||||
interface DetailTimelineProps {
|
||||
/** 时间线条目列表 */
|
||||
timeline: TimelineEntry[];
|
||||
}
|
||||
|
||||
/** 时间线动作图标配置 */
|
||||
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
CREATE_TICKET: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
|
||||
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
|
||||
EXECUTE_TICKET: { icon: Play, color: 'text-cyan-400', bg: 'bg-cyan-500/15' },
|
||||
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
|
||||
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||
};
|
||||
|
||||
/** 工单详情:操作时间线 */
|
||||
export default function DetailTimeline({ timeline }: DetailTimelineProps) {
|
||||
if (timeline.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-4">
|
||||
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
||||
</h3>
|
||||
<div className="relative pl-8">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
||||
|
||||
{timeline.map((entry, idx) => {
|
||||
const config = ACTION_CONFIG[entry.action] || {
|
||||
icon: CheckCircle,
|
||||
color: 'text-[var(--dg-text-muted)]',
|
||||
bg: 'bg-[var(--dg-bg-tertiary)]',
|
||||
};
|
||||
const Icon = config.icon;
|
||||
const isLast = idx === timeline.length - 1;
|
||||
|
||||
return (
|
||||
// 时间线无独立 id,用「动作 + 时间」组合作为稳定 key(无时间时退化为索引)
|
||||
<div key={`${entry.action}-${entry.time ?? idx}`} className={`relative ${isLast ? '' : 'mb-6'}`}>
|
||||
{/* Icon node */}
|
||||
<div className={`absolute -left-8 top-0.5 w-6 h-6 rounded-full ${config.bg} border-2 border-[var(--dg-border)] flex items-center justify-center z-10`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
<div className="ml-2">
|
||||
{/* Label + User */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{entry.label}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{entry.user}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
{entry.time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(entry.time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail */}
|
||||
{entry.detail && (
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1.5 p-2.5 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border-light)]">
|
||||
{entry.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 工单详情页共享类型 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 工单详情数据 */
|
||||
export interface TicketData {
|
||||
id: number;
|
||||
title: string;
|
||||
db_type: string;
|
||||
instance_id: number;
|
||||
instance_name?: string;
|
||||
target_database: string;
|
||||
sql_content: string;
|
||||
rollback_sql: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
applicant_id: number;
|
||||
applicant_name: string | null;
|
||||
approver_id: number | null;
|
||||
approver_name: string | null;
|
||||
reason: string | null;
|
||||
audit_result: string | null;
|
||||
execute_result: string | null;
|
||||
affected_rows?: number;
|
||||
execute_duration_ms?: number;
|
||||
create_time: string;
|
||||
review_time: string | null;
|
||||
execute_time: string | null;
|
||||
}
|
||||
|
||||
/** 时间线条目 */
|
||||
export interface TimelineEntry {
|
||||
action: string;
|
||||
label: string;
|
||||
user: string;
|
||||
time: string | null;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/** 审批流程步骤 */
|
||||
export interface ApprovalStep {
|
||||
id?: number;
|
||||
step_name: string;
|
||||
action: string;
|
||||
approver_name?: string | null;
|
||||
comment?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
/** 关联工单 */
|
||||
export interface RelatedTicket {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
applicant_name: string | null;
|
||||
create_time: string;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 工单详情弹窗 — 状态流、信息栅格、SQL、执行结果与审批时间线
|
||||
* 打开时拉取完整详情;审批/执行完成后经 onActionDone 通知页面刷新
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Eye, CheckCircle, XCircle, Play,
|
||||
FileCode, Clock, X, Loader2, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { getStatusIndex, parseExecuteResult } from '../utils';
|
||||
import type { ApprovalRecord, TicketItem } from '../types';
|
||||
import { TicketStatusBadge } from './TicketStatusBadge';
|
||||
|
||||
interface TicketDetailDialogProps {
|
||||
ticket: TicketItem;
|
||||
onClose: () => void;
|
||||
canApprove: boolean;
|
||||
canExecute: boolean;
|
||||
onActionDone: () => void;
|
||||
}
|
||||
|
||||
export function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExecute, onActionDone }: TicketDetailDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ticket, setTicket] = useState<TicketItem>(initialTicket);
|
||||
const [approvals, setApprovals] = useState<ApprovalRecord[]>([]);
|
||||
|
||||
// Load full detail on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const detail = await ticketApi.get(ticket.id) as any;
|
||||
setTicket(prev => ({ ...prev, ...detail }));
|
||||
setApprovals(detail.approvals || []);
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, [ticket.id]);
|
||||
|
||||
const execResult = parseExecuteResult(ticket.execute_result);
|
||||
|
||||
const currentStepIndex = getStatusIndex(ticket.status);
|
||||
const statusDisplay = [
|
||||
{ key: 'pending', label: '提交', icon: FileCode },
|
||||
{ key: 'reviewing', label: '审批', icon: Eye },
|
||||
{ key: 'approved', label: '通过', icon: CheckCircle },
|
||||
{ key: 'executing', label: '执行', icon: Loader2 },
|
||||
{ key: 'success', label: '完成', icon: CheckCircle },
|
||||
];
|
||||
|
||||
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await ticketApi.review(ticket.id, { action, comment: action === 'approve' ? '审批通过' : '拒绝' });
|
||||
onActionDone();
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
}, [ticket.id, onActionDone]);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await ticketApi.execute(ticket.id);
|
||||
onActionDone();
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
}, [ticket.id, onActionDone]);
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-3xl" className="border border-[var(--dg-border)] overflow-hidden flex flex-col">
|
||||
<div
|
||||
className="w-full max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 md:px-6 py-4 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleReview('approve')}
|
||||
disabled={loading}
|
||||
className="dg-btn dg-btn-sm bg-green-500/15 text-green-400 border border-green-500/30 hover:bg-green-500/25 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> 通过
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReview('reject')}
|
||||
disabled={loading}
|
||||
className="dg-btn dg-btn-sm bg-red-500/15 text-red-400 border border-red-500/30 hover:bg-red-500/25 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" /> 拒绝
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canExecute && ticket.is_executor && ticket.status === 'approved' && (
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={loading}
|
||||
className="dg-btn dg-btn-sm bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" /> 执行
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors ml-2">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
|
||||
|
||||
{/* Status Flow */}
|
||||
<div className="flex items-center justify-between overflow-x-auto pb-1">
|
||||
{statusDisplay.map((step, idx) => {
|
||||
const StepIcon = step.icon;
|
||||
const isCompleted = idx <= currentStepIndex && currentStepIndex >= 0;
|
||||
const isCurrent = idx === currentStepIndex;
|
||||
const isFailed = ticket.status === 'failed' || ticket.status === 'rejected';
|
||||
const showFailed = isFailed && idx === currentStepIndex;
|
||||
return (
|
||||
<div key={step.key} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center transition-all ${
|
||||
showFailed ? 'bg-red-500/15 text-red-400' :
|
||||
isCurrent ? 'bg-blue-500/15 text-blue-400 ring-2 ring-blue-500/30' :
|
||||
isCompleted ? 'bg-green-500/15 text-green-400' :
|
||||
'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
|
||||
}`}>
|
||||
{showFailed ? <XCircle className="w-4 h-4" /> : <StepIcon className="w-4 h-4" />}
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium ${
|
||||
showFailed ? 'text-red-400' :
|
||||
isCurrent ? 'text-blue-400' :
|
||||
isCompleted ? 'text-green-400' :
|
||||
'text-[var(--dg-text-muted)]'
|
||||
}`}>
|
||||
{showFailed ? (ticket.status === 'rejected' ? '已拒绝' : '失败') : step.label}
|
||||
</span>
|
||||
</div>
|
||||
{idx < statusDisplay.length - 1 && (
|
||||
<div className={`flex-1 h-[2px] mx-2 rounded-full transition-all ${
|
||||
idx < currentStepIndex && currentStepIndex >= 0 ? 'bg-green-400/50' : 'bg-[var(--dg-border)]'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
|
||||
{[
|
||||
{ label: '提交人', value: ticket.applicant_name || '-' },
|
||||
{ label: '当前审核人', value: ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-') },
|
||||
{ label: '实例', value: ticket.instance_name || '-' },
|
||||
{ label: '目标库', value: ticket.target_database },
|
||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex justify-between gap-3 py-1.5">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
||||
<span className="text-xs font-medium text-right break-words">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SQL Content */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<FileCode className="w-4 h-4 text-blue-400" /> SQL 语句
|
||||
</h3>
|
||||
<pre className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto">
|
||||
{ticket.sql_content}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Execute Result — 结构化表格展示 */}
|
||||
{execResult && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
||||
</h3>
|
||||
|
||||
{/* Summary row */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">状态</div>
|
||||
<div className={`text-sm font-medium ${execResult.status === 'success' ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{execResult.status === 'success' ? '执行成功' : '执行失败'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">总耗时</div>
|
||||
<div className="text-sm font-medium">{execResult.total_ms}ms</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">SQL 数量</div>
|
||||
<div className="text-sm font-medium">{execResult.items?.length || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-SQL result table */}
|
||||
{execResult.items && execResult.items.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[var(--dg-text-muted)] text-left">
|
||||
<th className="pb-2 pr-3 font-medium w-6">#</th>
|
||||
<th className="pb-2 pr-3 font-medium">SQL 语句</th>
|
||||
<th className="pb-2 pr-3 font-medium w-16">执行结果</th>
|
||||
<th className="pb-2 pr-3 font-medium w-16">影响行数</th>
|
||||
<th className="pb-2 pr-3 font-medium w-20">执行时间</th>
|
||||
<th className="pb-2 font-medium">错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{execResult.items.map((item, idx) => (
|
||||
<tr key={`${idx}-${item.sql}`} className="border-t border-[var(--dg-border)]">
|
||||
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{idx + 1}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap max-h-[60px] overflow-y-auto">{item.sql}</pre>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={item.status === 'success' ? 'text-green-400' : 'text-red-400'}>
|
||||
{item.status === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3">{item.affected_rows}</td>
|
||||
<td className="py-2 pr-3">{item.duration_ms}ms</td>
|
||||
<td className="py-2">
|
||||
{item.error && (
|
||||
<span className={item.status === 'fail' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}>
|
||||
{item.error.length > 80 ? item.error.substring(0, 80) + '...' : item.error}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-3">
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execute Result — plain text fallback for non-JSON results */}
|
||||
{!execResult && ticket.execute_result && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
||||
</h3>
|
||||
<div className={`p-3 rounded-lg text-xs font-mono whitespace-pre-wrap ${
|
||||
ticket.status === 'failed' ? 'bg-red-500/5 border border-red-500/20 text-red-400' : 'bg-[var(--dg-bg-tertiary)]'
|
||||
}`}>
|
||||
{ticket.execute_result}
|
||||
</div>
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2">
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failure reason for failed/rejected tickets without execute_result */}
|
||||
{(ticket.status === 'failed' || ticket.status === 'rejected') && !ticket.execute_result && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" /> 失败原因
|
||||
</h3>
|
||||
<div className="p-3 bg-red-500/5 border border-red-500/20 rounded-lg text-xs text-red-400">
|
||||
{ticket.status === 'rejected' ? '工单已被拒绝' : '执行失败,请查看审批记录了解详情'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline — approval history */}
|
||||
{approvals.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
||||
</h3>
|
||||
<div className="space-y-0">
|
||||
{/* Submit step */}
|
||||
<div className="flex gap-3 pb-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center">
|
||||
<FileCode className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="w-[2px] flex-1 bg-[var(--dg-border)]" />
|
||||
</div>
|
||||
<div className="flex-1 pb-3">
|
||||
<div className="text-xs font-medium text-green-400">提交申请</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
{ticket.applicant_name || `用户#${ticket.applicant_id}`} · {new Date(ticket.create_time).toLocaleString()}
|
||||
</div>
|
||||
{ticket.reason && (
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 bg-[var(--dg-bg-tertiary)] rounded px-2 py-1">{ticket.reason}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group approvals by step_number */}
|
||||
{Object.values(
|
||||
approvals.reduce((acc, a) => {
|
||||
const key = `${a.step_number}_${a.step_name || ''}`;
|
||||
if (!acc[key]) acc[key] = { step: a, users: [] };
|
||||
acc[key].users.push(a);
|
||||
return acc;
|
||||
}, {} as Record<string, { step: ApprovalRecord; users: ApprovalRecord[] }>)
|
||||
).map(({ step, users }, idx, arr) => {
|
||||
const isCompleted = users.some(u => u.action);
|
||||
const actor = users.find(u => u.actual_approver_id);
|
||||
const StepIcon = step.step_type === 'execute' ? Play : Eye;
|
||||
const isLast = idx === arr.length - 1;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
||||
isCompleted ? 'bg-green-500/15 text-green-400' :
|
||||
step.action === 'reject' ? 'bg-red-500/15 text-red-400' :
|
||||
'bg-blue-500/15 text-blue-400'
|
||||
}`}>
|
||||
<StepIcon className="w-3 h-3" />
|
||||
</div>
|
||||
{!isLast && <div className="w-[2px] flex-1 bg-[var(--dg-border)]" />}
|
||||
</div>
|
||||
<div className={`flex-1 ${!isLast ? 'pb-3' : ''}`}>
|
||||
<div className={`text-xs font-medium ${
|
||||
isCompleted ? 'text-green-400' :
|
||||
step.action === 'reject' ? 'text-red-400' :
|
||||
'text-blue-400'
|
||||
}`}>
|
||||
{step.step_name || (step.step_type === 'execute' ? '执行变更' : '审批')}
|
||||
{!isCompleted && <span className="text-[var(--dg-text-muted)] font-normal ml-1">(待处理)</span>}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
指派人: {users.map(u => u.approver_name).filter(Boolean).join(', ') || '-'}
|
||||
</div>
|
||||
{actor && actor.completed_at && (
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5">
|
||||
{actor.actual_approver_name} · {actor.action === 'approve' ? '通过' : actor.action === 'reject' ? '拒绝' : '执行'} · {new Date(actor.completed_at).toLocaleString()}
|
||||
{actor.comment && <span className="ml-1 text-[var(--dg-text-muted)]">({actor.comment})</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 工单筛选栏 — 关键词搜索、状态快捷筛选与排序切换
|
||||
* 状态全部由 props 注入,自身不持有任何可变状态
|
||||
*/
|
||||
import { Filter, X, ArrowUpDown } from 'lucide-react';
|
||||
|
||||
/** 状态快捷筛选静态序列(全部 + 各状态) */
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待审核' },
|
||||
{ key: 'executing', label: '执行中' },
|
||||
{ key: 'success', label: '完成' },
|
||||
{ key: 'failed', label: '失败' },
|
||||
{ key: 'rejected', label: '拒绝' },
|
||||
];
|
||||
|
||||
interface TicketFilterBarProps {
|
||||
search: string;
|
||||
statusFilter: string;
|
||||
sortAsc: boolean;
|
||||
/** 搜索词变化(父级负责重置页码) */
|
||||
onSearchChange: (value: string) => void;
|
||||
/** 状态筛选变化(父级负责重置页码) */
|
||||
onStatusChange: (status: string) => void;
|
||||
/** 切换创建时间排序方向 */
|
||||
onToggleSort: () => void;
|
||||
}
|
||||
|
||||
export function TicketFilterBar({
|
||||
search, statusFilter, sortAsc, onSearchChange, onStatusChange, onToggleSort,
|
||||
}: TicketFilterBarProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-3 lg:flex-row lg:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="dg-input w-full pl-10 pr-4 py-2.5"
|
||||
placeholder="搜索工单标题或SQL内容..."
|
||||
value={search}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => onSearchChange('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 lg:pb-0">
|
||||
{STATUS_FILTER_OPTIONS.map(item => (
|
||||
<button
|
||||
key={item.key || 'all'}
|
||||
onClick={() => onStatusChange(item.key)}
|
||||
className={`dg-btn dg-btn-sm whitespace-nowrap ${statusFilter === item.key ? 'dg-btn-primary' : 'dg-btn-ghost'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={onToggleSort} className="dg-btn dg-btn-sm dg-btn-ghost justify-center whitespace-nowrap">
|
||||
<ArrowUpDown className="w-3.5 h-3.5" />
|
||||
{sortAsc ? '最早优先' : '最新优先'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 工单移动端卡片列表 — md 以下断点替代表格的紧凑展示
|
||||
* 与桌面表格共享状态徽章与行操作按钮,交互行为一致
|
||||
*/
|
||||
import { dgBadgeDbMap } from '../constants';
|
||||
import type { TicketItem } from '../types';
|
||||
import { TicketRowActions, type TicketRowActionProps } from './TicketRowActions';
|
||||
import { TicketStatusBadge } from './TicketStatusBadge';
|
||||
|
||||
interface TicketMobileListProps extends TicketRowActionProps {
|
||||
tickets: TicketItem[];
|
||||
}
|
||||
|
||||
export function TicketMobileList({ tickets, ...actionProps }: TicketMobileListProps) {
|
||||
return (
|
||||
<div className="md:hidden divide-y divide-[var(--dg-border)]">
|
||||
{tickets.map(ticket => (
|
||||
<div key={ticket.id} className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button onClick={() => actionProps.onViewDetail(ticket)} className="min-w-0 text-left">
|
||||
<div className="font-mono text-xs text-[var(--dg-text-muted)]">{ticket.ticket_no || `TK-${ticket.id}`}</div>
|
||||
<div className="mt-1 text-sm font-medium text-[var(--dg-text-primary)] line-clamp-2">{ticket.title}</div>
|
||||
</button>
|
||||
<TicketStatusBadge status={ticket.status} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||
<div className="text-[var(--dg-text-muted)]">实例</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 min-w-0">
|
||||
<span className={`dg-badge ${dgBadgeDbMap[ticket.db_type] || 'dg-badge-blue'}`}>{ticket.db_type.toUpperCase()}</span>
|
||||
<span className="truncate">{ticket.instance_name || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||
<div className="text-[var(--dg-text-muted)]">提交人</div>
|
||||
<div className="mt-1 truncate">{ticket.applicant_name || '-'}</div>
|
||||
</div>
|
||||
<div className="col-span-2 rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2">
|
||||
<div className="text-[var(--dg-text-muted)]">待处理</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{ticket.pending_steps?.length > 0 ? ticket.pending_steps.map(ps => (
|
||||
<div key={ps.step_number} className="flex gap-1.5">
|
||||
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
|
||||
<span className="font-medium">{ps.approver_names.join(', ')}</span>
|
||||
</div>
|
||||
)) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-[var(--dg-text-muted)]">
|
||||
创建于 {new Date(ticket.create_time).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TicketRowActions ticket={ticket} variant="mobile" {...actionProps} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 工单分页 — 页码窗口(最多展示 5 页)与上/下一页控制
|
||||
* 可见性由父组件控制(仅 totalPages > 1 时渲染)
|
||||
*/
|
||||
|
||||
interface TicketPaginationProps {
|
||||
page: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
/** 页码跳转(父级写入 URL query) */
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function TicketPagination({ page, total, totalPages, onPageChange }: TicketPaginationProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 py-4 border-t border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {total} 条</span>
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let p: number;
|
||||
if (totalPages <= 5) {
|
||||
p = i + 1;
|
||||
} else if (page <= 3) {
|
||||
p = i + 1;
|
||||
} else if (page >= totalPages - 2) {
|
||||
p = totalPages - 4 + i;
|
||||
} else {
|
||||
p = page - 2 + i;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`dg-btn dg-btn-sm dg-btn-ghost ${
|
||||
p === page ? 'bg-blue-500 text-white hover:bg-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 工单行操作按钮 — 查看/详情、通过、拒绝、执行、撤销
|
||||
* 桌面表格与移动卡片共用;API 调用由页面通过回调注入
|
||||
*/
|
||||
import { Eye, CheckCircle, XCircle, Play, Ban } from 'lucide-react';
|
||||
import type { TicketItem } from '../types';
|
||||
|
||||
/** 行操作回调集合 — 供 Table / MobileList 透传 */
|
||||
export interface TicketRowActionProps {
|
||||
canApprove: boolean;
|
||||
canExecute: boolean;
|
||||
currentUserId?: number;
|
||||
onViewDetail: (ticket: TicketItem) => void;
|
||||
onApprove: (ticket: TicketItem) => void;
|
||||
onReject: (ticket: TicketItem) => void;
|
||||
onExecute: (ticket: TicketItem) => void;
|
||||
onCancel: (ticket: TicketItem) => void;
|
||||
}
|
||||
|
||||
interface TicketRowActionsProps extends TicketRowActionProps {
|
||||
ticket: TicketItem;
|
||||
/** desktop: 完成/失败工单显示"详情";mobile: 恒为"查看" */
|
||||
variant?: 'desktop' | 'mobile';
|
||||
}
|
||||
|
||||
export function TicketRowActions({
|
||||
ticket, variant = 'desktop',
|
||||
canApprove, canExecute, currentUserId,
|
||||
onViewDetail, onApprove, onReject, onExecute, onCancel,
|
||||
}: TicketRowActionsProps) {
|
||||
const viewLabel = variant === 'desktop' && (ticket.status === 'success' || ticket.status === 'failed')
|
||||
? '详情'
|
||||
: '查看';
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => onViewDetail(ticket)} className="dg-btn dg-btn-sm dg-btn-ghost">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{viewLabel}
|
||||
</button>
|
||||
|
||||
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onApprove(ticket)}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-green-400 hover:text-green-300"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReject(ticket)}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-red-400 hover:text-red-300"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
拒绝
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canExecute && ticket.is_executor && ticket.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => onExecute(ticket)}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-cyan-400 hover:text-cyan-300"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
执行
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentUserId === ticket.applicant_id && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved') && (
|
||||
<button
|
||||
onClick={() => onCancel(ticket)}
|
||||
className="dg-btn dg-btn-sm dg-btn-ghost text-amber-400 hover:text-amber-300"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" />
|
||||
撤销
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 工单统计卡片 — 待审核/执行中/已完成/失败拒绝四张汇总卡
|
||||
* 统计未加载时渲染骨架;点击卡片按状态筛选,再点同卡取消
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import type { TicketStatistics } from '../types';
|
||||
|
||||
interface TicketStatsCardsProps {
|
||||
stats: TicketStatistics | null;
|
||||
/** 当前生效的状态筛选(用于高亮选中卡片) */
|
||||
activeStatus: string;
|
||||
/** 点击卡片切换状态筛选(status 为目标状态键) */
|
||||
onSelectStatus: (status: string) => void;
|
||||
}
|
||||
|
||||
export function TicketStatsCards({ stats, activeStatus, onSelectStatus }: TicketStatsCardsProps) {
|
||||
/* ---- Status Summary Cards (design system) ---- */
|
||||
const statusSummaryCards = useMemo(() => [
|
||||
{ key: 'pending', label: '待审核', count: (stats?.pending || 0) + (stats?.reviewing || 0) + (stats?.approved || 0), dot: 'dg-status-dot-blue' },
|
||||
{ key: 'executing', label: '执行中', count: stats?.executing || 0, dot: 'dg-status-dot-orange' },
|
||||
{ key: 'success', label: '已完成', count: stats?.success || 0, dot: 'dg-status-dot-green' },
|
||||
{ key: 'failed', label: '失败/拒绝', count: (stats?.failed || 0) + (stats?.rejected || 0), dot: 'dg-status-dot-red' },
|
||||
], [stats]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
|
||||
{stats === null ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="dg-card-section animate-pulse">
|
||||
<div className="dg-card-header">
|
||||
<div className="h-8 w-12 rounded bg-[var(--dg-bg-tertiary)]" />
|
||||
<div className="h-3 w-16 rounded bg-[var(--dg-bg-tertiary)]" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
statusSummaryCards.map(card => (
|
||||
<button
|
||||
key={card.key}
|
||||
onClick={() => onSelectStatus(card.key)}
|
||||
className={`dg-card-section text-left transition-all cursor-pointer hover:ring-1 hover:ring-[var(--dg-border)] ${
|
||||
activeStatus === card.key ? 'ring-1 ring-blue-500/30 bg-blue-500/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="dg-card-header flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">{card.count}</span>
|
||||
<span className={`dg-status-dot ${card.dot}`} />
|
||||
</div>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)] mt-1 block">{card.label}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 工单状态徽章 — 统一状态文案与设计系统徽章配色
|
||||
* 文案兜底顺序:displayStatusMap → 全局 statusMap → 原始状态值
|
||||
*/
|
||||
import { statusMap } from '@/lib/constants';
|
||||
import { displayStatusMap, dgBadgeStatusMap } from '../constants';
|
||||
|
||||
interface TicketStatusBadgeProps {
|
||||
status: string;
|
||||
/** 附加类名(如移动端卡片的 shrink-0) */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TicketStatusBadge({ status, className = '' }: TicketStatusBadgeProps) {
|
||||
return (
|
||||
<span className={`dg-badge ${className} ${dgBadgeStatusMap[status] || 'dg-badge-blue'}`}>
|
||||
{displayStatusMap[status] || statusMap[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 工单表格卡片 — 桌面表格(含加载骨架/空态)+ 移动端卡片列表 + 分页
|
||||
* 仅负责展示与事件透传;数据获取、权限与排序均由页面注入
|
||||
*/
|
||||
import { ArrowUpDown, Users } from 'lucide-react';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { dgBadgeDbMap } from '../constants';
|
||||
import type { TicketItem } from '../types';
|
||||
import { TicketRowActions, type TicketRowActionProps } from './TicketRowActions';
|
||||
import { TicketStatusBadge } from './TicketStatusBadge';
|
||||
import { TicketMobileList } from './TicketMobileList';
|
||||
import { TicketPagination } from './TicketPagination';
|
||||
|
||||
/** 表格行加载骨架(静态序列) */
|
||||
function TableRowSkeleton() {
|
||||
return (
|
||||
<tr className="border-b border-[var(--dg-border-light)]">
|
||||
<td className="px-4 py-3.5"><div className="h-4 w-32 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-4 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-4 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-4 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
<td className="px-4 py-3.5"><div className="h-4 w-20 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表头(创建时间列支持排序切换) */
|
||||
function TableHeader({ onToggleSort }: { onToggleSort: () => void }) {
|
||||
return (
|
||||
<thead>
|
||||
<tr className="dg-table-header">
|
||||
<th className="text-left px-4 py-3 font-semibold">工单编号</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">实例名称</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">提交人</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">待处理</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">
|
||||
<button onClick={onToggleSort} className="flex items-center gap-1 hover:text-blue-400 transition-colors">
|
||||
创建时间 <ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
interface TicketTableProps extends TicketRowActionProps {
|
||||
/** 已按创建时间排序的工单列表 */
|
||||
tickets: TicketItem[];
|
||||
loading: boolean;
|
||||
page: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
onToggleSort: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function TicketTable({
|
||||
tickets, loading, page, total, totalPages,
|
||||
onToggleSort, onPageChange, ...actionProps
|
||||
}: TicketTableProps) {
|
||||
return (
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="p-0">
|
||||
{loading && tickets.length === 0 ? (
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader onToggleSort={onToggleSort} />
|
||||
<tbody>
|
||||
{Array.from({ length: 5 }).map((_, i) => <TableRowSkeleton key={i} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<TableHeader onToggleSort={onToggleSort} />
|
||||
<tbody>
|
||||
{tickets.map(ticket => (
|
||||
<tr
|
||||
key={ticket.id}
|
||||
className="dg-table-row"
|
||||
>
|
||||
{/* 工单编号 */}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs text-[var(--dg-text-primary)]">{ticket.ticket_no || `TK-${ticket.id}`}</span>
|
||||
<span className="text-[11px] text-[var(--dg-text-muted)] mt-0.5 max-w-[180px] truncate">{ticket.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 实例名称 */}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`dg-badge ${dgBadgeDbMap[ticket.db_type] || 'dg-badge-blue'}`}>
|
||||
{ticket.db_type.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm">{ticket.instance_name || '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 提交人 */}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-sm">{ticket.applicant_name || '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 待处理 */}
|
||||
<td className="px-4 py-3.5">
|
||||
{ticket.pending_steps?.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{ticket.pending_steps.map((ps) => (
|
||||
<div key={ps.step_number} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{ps.approver_names.join(', ')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 状态 */}
|
||||
<td className="px-4 py-3.5">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</td>
|
||||
|
||||
{/* 创建时间 */}
|
||||
<td className="px-4 py-3.5 text-xs text-[var(--dg-text-secondary)]">
|
||||
{new Date(ticket.create_time).toLocaleString()}
|
||||
</td>
|
||||
|
||||
{/* 操作 */}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<TicketRowActions ticket={ticket} {...actionProps} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && tickets.length > 0 && (
|
||||
<TicketMobileList tickets={tickets} {...actionProps} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<TicketPagination page={page} total={total} totalPages={totalPages} onPageChange={onPageChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 工单域展示常量 — 状态文案、徽章配色与状态流顺序
|
||||
* 自 TicketsPage.tsx 拆出,供列表与详情弹窗共用
|
||||
*/
|
||||
|
||||
/** 展示用状态映射 — 按用户要求简化 */
|
||||
export const displayStatusMap: Record<string, string> = {
|
||||
pending: '审核中',
|
||||
reviewing: '审核中',
|
||||
approved: '审核中',
|
||||
rejected: '已拒绝',
|
||||
executing: '执行中',
|
||||
success: '完成',
|
||||
failed: '失败',
|
||||
rollback: '已回滚',
|
||||
};
|
||||
|
||||
export const displayStatusColorMap: Record<string, string> = {
|
||||
pending: 'bg-blue-500/15 text-blue-400',
|
||||
reviewing: 'bg-blue-500/15 text-blue-400',
|
||||
approved: 'bg-blue-500/15 text-blue-400',
|
||||
rejected: 'bg-red-500/15 text-red-400',
|
||||
executing: 'bg-cyan-500/15 text-cyan-400',
|
||||
success: 'bg-green-500/15 text-green-400',
|
||||
failed: 'bg-red-500/15 text-red-400',
|
||||
rollback: 'bg-orange-500/15 text-orange-400',
|
||||
};
|
||||
|
||||
/** Design-system badge class per status */
|
||||
export const dgBadgeStatusMap: Record<string, string> = {
|
||||
pending: 'dg-badge-blue',
|
||||
reviewing: 'dg-badge-blue',
|
||||
approved: 'dg-badge-blue',
|
||||
rejected: 'dg-badge-red',
|
||||
executing: 'dg-badge-orange',
|
||||
success: 'dg-badge-green',
|
||||
failed: 'dg-badge-red',
|
||||
rollback: 'dg-badge-orange',
|
||||
};
|
||||
|
||||
/** Design-system badge class per db type */
|
||||
export const dgBadgeDbMap: Record<string, string> = {
|
||||
mysql: 'dg-badge-blue',
|
||||
postgres: 'dg-badge-purple',
|
||||
postgresql: 'dg-badge-purple',
|
||||
redis: 'dg-badge-red',
|
||||
mongo: 'dg-badge-green',
|
||||
mongodb: 'dg-badge-green',
|
||||
};
|
||||
|
||||
/** 工单正向状态流 — 用于详情弹窗进度条定位 */
|
||||
export const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 工单域共享类型 — 列表、审批记录、统计与执行结果
|
||||
* 自 TicketsPage.tsx 拆出,页面与特性组件共用
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export interface PendingStep {
|
||||
step_number: number;
|
||||
step_name: string;
|
||||
step_type: string;
|
||||
approver_names: string[];
|
||||
}
|
||||
|
||||
export interface TicketItem {
|
||||
id: number;
|
||||
ticket_no: string | null;
|
||||
title: string;
|
||||
db_type: string;
|
||||
instance_id: number;
|
||||
instance_name: string | null;
|
||||
target_database: string;
|
||||
sql_content: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
applicant_id: number;
|
||||
applicant_name: string | null;
|
||||
approver_id: number | null;
|
||||
approver_name: string | null;
|
||||
current_approver_name: string | null;
|
||||
pending_steps: PendingStep[];
|
||||
reason: string | null;
|
||||
audit_result: string | null;
|
||||
execute_result: string | null;
|
||||
create_time: string;
|
||||
review_time: string | null;
|
||||
execute_time: string | null;
|
||||
has_audit_step: boolean;
|
||||
has_execute_step: boolean;
|
||||
is_executor: boolean;
|
||||
is_approver: boolean;
|
||||
approvals?: ApprovalRecord[];
|
||||
}
|
||||
|
||||
export interface ApprovalRecord {
|
||||
id: number;
|
||||
step_number: number;
|
||||
step_name: string | null;
|
||||
step_type: string | null;
|
||||
approver_id: number | null;
|
||||
approver_name: string;
|
||||
actual_approver_id: number | null;
|
||||
actual_approver_name: string;
|
||||
action: string | null;
|
||||
comment: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export interface TicketListResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TicketItem[];
|
||||
}
|
||||
|
||||
export interface TicketStatistics {
|
||||
total: number;
|
||||
pending: number;
|
||||
reviewing: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
executing: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface SqlExecItem {
|
||||
sql: string;
|
||||
status: string; // success / fail
|
||||
affected_rows: number;
|
||||
duration_ms: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
status: string; // success / failed
|
||||
total_ms: number;
|
||||
items: SqlExecItem[];
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 工单列表 URL query 参数 — q / status / page / sort 四项筛选分页状态
|
||||
* 值读取复用 useQueryState;筛选变化伴随的页码重置需在同一次导航内原子提交
|
||||
* (react-router 的 setSearchParams 函数式更新基于渲染期闭包求值,同一事件内
|
||||
* 连续两次调用会相互覆盖,故复合变更合并为一次 setSearchParams)
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQueryState, asString, asNumber } from '@/hooks/useQueryState';
|
||||
|
||||
/** 页大小固定值(与拆分前 useState(10) 一致,不进 URL) */
|
||||
export const TICKET_PAGE_SIZE = 10;
|
||||
|
||||
export function useTicketListParams() {
|
||||
const [search] = useQueryState('q', '', asString);
|
||||
const [statusFilter] = useQueryState('status', '', asString);
|
||||
const [page, setPage] = useQueryState('page', 1, asNumber);
|
||||
const [sortParam, setSortParam] = useQueryState('sort', '', asString);
|
||||
const sortAsc = sortParam === 'asc';
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
/** 原子修改多个 query 参数(空值删除,与 useQueryState 默认值语义一致) */
|
||||
const patchParams = useCallback((patch: Record<string, string | null>) => {
|
||||
setSearchParams(prev => {
|
||||
const params = new URLSearchParams(prev);
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null || value === '') params.delete(key);
|
||||
else params.set(key, value);
|
||||
}
|
||||
return params;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
/** 修改搜索词 — 同时重置页码(与拆分前 setSearch + setPage(1) 等价) */
|
||||
const changeSearch = useCallback((value: string) => {
|
||||
patchParams({ q: value, page: null });
|
||||
}, [patchParams]);
|
||||
|
||||
/** 修改状态筛选 — 同时重置页码 */
|
||||
const changeStatus = useCallback((status: string) => {
|
||||
patchParams({ status, page: null });
|
||||
}, [patchParams]);
|
||||
|
||||
/** 切换排序方向:最新优先(默认)/ 最早优先(?sort=asc) */
|
||||
const toggleSort = useCallback(() => {
|
||||
setSortParam(sortAsc ? null : 'asc');
|
||||
}, [sortAsc, setSortParam]);
|
||||
|
||||
/** 跳转页码(下界钳制为 1,防止 URL 手改出非法页码) */
|
||||
const changePage = useCallback((next: number) => {
|
||||
setPage(next >= 1 ? next : 1);
|
||||
}, [setPage]);
|
||||
|
||||
return {
|
||||
search, statusFilter, page, sortAsc,
|
||||
changeSearch, changeStatus, toggleSort, changePage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 工单域工具函数 — 状态流定位与执行结果解析
|
||||
* 自 TicketsPage.tsx 拆出,纯函数无副作用
|
||||
*/
|
||||
import { statusFlow } from './constants';
|
||||
import type { ExecuteResult } from './types';
|
||||
|
||||
/** 获取状态在 statusFlow 中的下标(不在流中返回 -1) */
|
||||
export function getStatusIndex(status: string): number {
|
||||
return statusFlow.indexOf(status);
|
||||
}
|
||||
|
||||
/** 解析 execute_result JSON 字符串,非法/为空返回 null */
|
||||
export function parseExecuteResult(raw: string | null): ExecuteResult | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as ExecuteResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { menuItems, permissionAliasMap, menuIdForPath, type UserRole } from '@/constants/menu';
|
||||
import { menuItems, type UserRole } from '@/constants/menu';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { STORAGE_KEYS, readStorage, writeStorage, removeStorage } from '@/lib/storage';
|
||||
import { canAccessMenu, canPerformAction } from '@/lib/permissions';
|
||||
|
||||
const defaultUser = {
|
||||
id: 0,
|
||||
@@ -111,8 +112,6 @@ const roleWeightMap: Record<UserRole, number> = {
|
||||
auditor: 40,
|
||||
};
|
||||
|
||||
const dataChangePermissionIds = new Set(['tickets', 'workflow']);
|
||||
|
||||
export const actionPermissionMap: Record<string, UserRole[]> = {
|
||||
'ticket.create': ['admin', 'dba', 'developer'],
|
||||
'ticket.approve': ['admin', 'dba', 'developer'],
|
||||
@@ -356,28 +355,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return cache.menu.get(menuId)!;
|
||||
}
|
||||
|
||||
const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId);
|
||||
const allowedRoles = menuItem?.roles;
|
||||
if (!allowedRoles) {
|
||||
cache.menu.set(menuId, false);
|
||||
return false;
|
||||
}
|
||||
const targetMenuId = menuItem?.id || menuId;
|
||||
if (user.role === 'developer' && dataChangePermissionIds.has(targetMenuId) && !hasWritableDataSourceAccess) {
|
||||
cache.menu.set(menuId, false);
|
||||
cache.timestamp = Date.now();
|
||||
return false;
|
||||
}
|
||||
|
||||
const permissionKeys = [
|
||||
menuId,
|
||||
menuItem?.permission,
|
||||
...((permissionAliasMap[menuId] || [])),
|
||||
...((menuItem?.permission && permissionAliasMap[menuItem.permission]) || []),
|
||||
].filter(Boolean) as string[];
|
||||
const customPermission = permissionKeys.find(key => user.customPermissions && key in user.customPermissions);
|
||||
const result = allowedRoles.includes(user.role)
|
||||
&& (customPermission ? Boolean(user.customPermissions?.[customPermission]) : true);
|
||||
const result = canAccessMenu(menuId, {
|
||||
role: user.role,
|
||||
customPermissions: user.customPermissions,
|
||||
hasWritableDataSourceAccess,
|
||||
});
|
||||
cache.menu.set(menuId, result);
|
||||
cache.timestamp = Date.now();
|
||||
return result;
|
||||
@@ -392,27 +374,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return cache.action.get(action)!;
|
||||
}
|
||||
|
||||
if (user.role === 'developer' && ['ticket.create', 'ticket.approve'].includes(String(action)) && !hasWritableDataSourceAccess) {
|
||||
cache.action.set(action, false);
|
||||
cache.timestamp = Date.now();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 优先检查自定义权限
|
||||
if (user.customPermissions && action in user.customPermissions) {
|
||||
const customResult = user.customPermissions[action];
|
||||
cache.action.set(action, customResult);
|
||||
cache.timestamp = Date.now();
|
||||
return customResult;
|
||||
}
|
||||
|
||||
const allowedRoles = actionPermissionMap[action];
|
||||
if (!allowedRoles) {
|
||||
cache.action.set(action, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = allowedRoles.includes(user.role);
|
||||
const result = canPerformAction(action, {
|
||||
role: user.role,
|
||||
customPermissions: user.customPermissions,
|
||||
hasWritableDataSourceAccess,
|
||||
}, actionPermissionMap);
|
||||
cache.action.set(action, result);
|
||||
cache.timestamp = Date.now();
|
||||
return result;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
|
||||
import api from '@/api/request';
|
||||
import { writeStorage } from '@/lib/storage';
|
||||
import { getSafeRedirect } from '@/lib/permissions';
|
||||
|
||||
export interface LoginState {
|
||||
username: string;
|
||||
@@ -15,12 +16,6 @@ export interface LoginState {
|
||||
handleLogin: (e: React.FormEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 只放行同源相对路径(/ 开头且非 // 开头),防开放重定向 */
|
||||
function getSafeRedirect(redirect: string | null): string | null {
|
||||
if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) return null;
|
||||
return redirect;
|
||||
}
|
||||
|
||||
export function useLogin(): LoginState {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* URL query 作为筛选/分页状态的唯一源(指南 03 章:筛选/分页/Tab 状态走 URL query,
|
||||
* 永不复制进 store)。刷新、分享链接、浏览器回退均可恢复状态。
|
||||
*
|
||||
* 用法:
|
||||
* const [page, setPage] = useQueryState('page', 1, asNumber);
|
||||
* const [keyword, setKeyword] = useQueryState('q', '', asString);
|
||||
* 更新走 history replace,不产生历史记录;值等于默认值时从 URL 移除保持简洁。
|
||||
*/
|
||||
export function useQueryState<T extends string | number>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
parse: (v: string) => T
|
||||
): [T, (value: T | null) => void] {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const value = useMemo<T>(() => {
|
||||
const raw = searchParams.get(key);
|
||||
if (raw === null || raw === '') return defaultValue;
|
||||
try {
|
||||
const parsed = parse(raw);
|
||||
return parsed;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}, [searchParams, key, defaultValue, parse]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(next: T | null) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
const resolved = next === null ? defaultValue : next;
|
||||
if (String(resolved) === String(defaultValue)) {
|
||||
params.delete(key);
|
||||
} else {
|
||||
params.set(key, String(resolved));
|
||||
}
|
||||
return params;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
},
|
||||
[setSearchParams, key, defaultValue]
|
||||
);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/** 常用解析器 */
|
||||
export const asString = (v: string) => v;
|
||||
export const asNumber = (v: string) => {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) throw new TypeError(`not a number: ${v}`);
|
||||
return n;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { menuItems } from '@/constants/menu';
|
||||
import { canAccessMenu, canPerformAction, getSafeRedirect } from '@/lib/permissions';
|
||||
import type { UserRole } from '@/constants/menu';
|
||||
|
||||
const ALL_ROLES: UserRole[] = ['admin', 'dba', 'developer', 'auditor'];
|
||||
|
||||
const ACTION_MAP: Record<string, UserRole[]> = {
|
||||
'ticket.approve': ['admin', 'dba', 'developer'],
|
||||
'ticket.execute': ['admin', 'dba'],
|
||||
'ticket.delete': ['admin'],
|
||||
'user.manage': ['admin'],
|
||||
};
|
||||
|
||||
describe('canAccessMenu 纯函数穷举', () => {
|
||||
it('每个菜单项的 roles 与判定一致(全角色 × 全菜单)', () => {
|
||||
for (const item of menuItems) {
|
||||
for (const role of ALL_ROLES) {
|
||||
expect(canAccessMenu(item.id, { role })).toBe(item.roles.includes(role));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('未知菜单 id 一律拒绝', () => {
|
||||
for (const role of ALL_ROLES) {
|
||||
expect(canAccessMenu('no-such-menu', { role })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('developer 无可写数据源时,工单/流程域被收紧', () => {
|
||||
expect(canAccessMenu('tickets', { role: 'developer', hasWritableDataSourceAccess: false })).toBe(false);
|
||||
expect(canAccessMenu('tickets', { role: 'developer', hasWritableDataSourceAccess: true })).toBe(true);
|
||||
// 其他角色不受该开关影响
|
||||
expect(canAccessMenu('tickets', { role: 'dba', hasWritableDataSourceAccess: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('customPermissions 覆盖:显式 false 拒绝,显式 true 放行', () => {
|
||||
expect(canAccessMenu('users', { role: 'admin', customPermissions: { users: false } })).toBe(false);
|
||||
expect(canAccessMenu('queries', { role: 'auditor', customPermissions: { queries: true } })).toBe(false); // 角色不在 roles,覆盖不越权
|
||||
expect(canAccessMenu('queries', { role: 'developer', customPermissions: { queries: true } })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPerformAction 纯函数', () => {
|
||||
it('角色映射表判定', () => {
|
||||
expect(canPerformAction('ticket.execute', { role: 'developer' }, ACTION_MAP)).toBe(false);
|
||||
expect(canPerformAction('ticket.execute', { role: 'dba' }, ACTION_MAP)).toBe(true);
|
||||
expect(canPerformAction('user.manage', { role: 'dba' }, ACTION_MAP)).toBe(false);
|
||||
});
|
||||
|
||||
it('未知 action 拒绝', () => {
|
||||
expect(canPerformAction('nope.manage', { role: 'admin' }, ACTION_MAP)).toBe(false);
|
||||
});
|
||||
|
||||
it('customPermissions 覆盖优先于角色表', () => {
|
||||
expect(canPerformAction('ticket.delete', { role: 'dba', customPermissions: { 'ticket.delete': true } }, ACTION_MAP)).toBe(true);
|
||||
expect(canPerformAction('ticket.execute', { role: 'dba', customPermissions: { 'ticket.execute': false } }, ACTION_MAP)).toBe(false);
|
||||
});
|
||||
|
||||
it('developer 的工单创建/审批受可写数据源约束', () => {
|
||||
expect(canPerformAction('ticket.approve', { role: 'developer', hasWritableDataSourceAccess: false }, ACTION_MAP)).toBe(false);
|
||||
expect(canPerformAction('ticket.approve', { role: 'developer', hasWritableDataSourceAccess: true }, ACTION_MAP)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSafeRedirect 防开放重定向', () => {
|
||||
it('只放行同源相对路径', () => {
|
||||
expect(getSafeRedirect('/queries')).toBe('/queries');
|
||||
expect(getSafeRedirect('/tickets/42?page=1')).toBe('/tickets/42?page=1');
|
||||
});
|
||||
|
||||
it('拒绝外域与协议相对 URL', () => {
|
||||
expect(getSafeRedirect('//evil.com')).toBeNull();
|
||||
expect(getSafeRedirect('https://evil.com')).toBeNull();
|
||||
expect(getSafeRedirect('javascript:alert(1)')).toBeNull();
|
||||
expect(getSafeRedirect('')).toBeNull();
|
||||
expect(getSafeRedirect(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { readStorage, writeStorage, removeStorage, STORAGE_KEYS } from '@/lib/storage';
|
||||
|
||||
describe('storage 命名空间迁移(读旧写新)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('新 key 直接读取', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.token, 't1');
|
||||
expect(readStorage('token')).toBe('t1');
|
||||
});
|
||||
|
||||
it('旧 key 读取时自动迁移到新 key 并清理旧值', () => {
|
||||
localStorage.setItem('token', 'legacy-token');
|
||||
expect(readStorage('token')).toBe('legacy-token');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.token)).toBe('legacy-token');
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
|
||||
it('写只写新 key', () => {
|
||||
writeStorage('theme', 'light-professional');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.theme)).toBe('light-professional');
|
||||
expect(localStorage.getItem('dg-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('删除同时清理新旧 key', () => {
|
||||
localStorage.setItem('dg-theme', 'ocean-deep');
|
||||
readStorage('theme'); // 触发迁移
|
||||
localStorage.setItem('dg-theme', 'again'); // 人为再放旧值
|
||||
removeStorage('theme');
|
||||
expect(localStorage.getItem(STORAGE_KEYS.theme)).toBeNull();
|
||||
expect(localStorage.getItem('dg-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('存储不可用时静默降级', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceeded');
|
||||
});
|
||||
expect(() => writeStorage('theme', 'x')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 权限判定纯函数(指南 04 章 4.4:权限计算要纯函数化,可单测穷举)。
|
||||
* useAuth 的 hasPermission / hasActionPermission 委托到这里;禁止在组件里手写角色比较。
|
||||
*/
|
||||
import { menuItems, permissionAliasMap, type UserRole } from '@/constants/menu';
|
||||
|
||||
export interface PermissionContext {
|
||||
role: UserRole;
|
||||
/** 自定义权限覆盖(后端下发,覆盖角色默认) */
|
||||
customPermissions?: Record<string, boolean>;
|
||||
/** developer 是否有可写数据源(影响工单创建/审批) */
|
||||
hasWritableDataSourceAccess?: boolean;
|
||||
}
|
||||
|
||||
/** developer 的工单类操作需要至少一个可写数据源 */
|
||||
const WRITABLE_GATED_ACTIONS = new Set(['ticket.create', 'ticket.approve']);
|
||||
/** 菜单级同样受可写数据源约束的域 */
|
||||
const WRITABLE_GATED_MENUS = new Set(['tickets', 'workflow']);
|
||||
|
||||
/** 菜单/路由级权限:menuId 命中菜单项后按 roles + 自定义覆盖判定 */
|
||||
export function canAccessMenu(menuId: string, ctx: PermissionContext): boolean {
|
||||
const menuItem = menuItems.find((item) => item.id === menuId || item.permission === menuId);
|
||||
if (!menuItem) return false;
|
||||
|
||||
if (ctx.role === 'developer' && WRITABLE_GATED_MENUS.has(menuItem.id) && !ctx.hasWritableDataSourceAccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const permissionKeys = [
|
||||
menuId,
|
||||
menuItem.permission,
|
||||
...(permissionAliasMap[menuId] || []),
|
||||
...(menuItem.permission ? permissionAliasMap[menuItem.permission] || [] : []),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const customKey = permissionKeys.find((key) => ctx.customPermissions && key in ctx.customPermissions);
|
||||
if (customKey) {
|
||||
return Boolean(ctx.customPermissions?.[customKey]) && menuItem.roles.includes(ctx.role);
|
||||
}
|
||||
return menuItem.roles.includes(ctx.role);
|
||||
}
|
||||
|
||||
/** 操作级权限:自定义覆盖优先,其次角色映射表 */
|
||||
export function canPerformAction(
|
||||
action: string,
|
||||
ctx: PermissionContext,
|
||||
actionPermissionMap: Record<string, UserRole[]>
|
||||
): boolean {
|
||||
if (ctx.role === 'developer' && WRITABLE_GATED_ACTIONS.has(action) && !ctx.hasWritableDataSourceAccess) {
|
||||
return false;
|
||||
}
|
||||
if (ctx.customPermissions && action in ctx.customPermissions) {
|
||||
return Boolean(ctx.customPermissions[action]);
|
||||
}
|
||||
const allowedRoles = actionPermissionMap[action];
|
||||
if (!allowedRoles) return false;
|
||||
return allowedRoles.includes(ctx.role);
|
||||
}
|
||||
|
||||
/** redirect 回跳同源校验:只放行 / 开头且非 // 开头的相对路径,防开放重定向 */
|
||||
export function getSafeRedirect(redirect: string | null): string | null {
|
||||
if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) return null;
|
||||
return redirect;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryState, asString, asNumber } from '@/hooks/useQueryState';
|
||||
import { useState, useEffect, useCallback, useMemo, Fragment } from 'react';
|
||||
import {
|
||||
Search, Download, Filter, ChevronDown, ChevronRight,
|
||||
@@ -129,16 +130,16 @@ function getResultStatus(log: AuditLog): string {
|
||||
export default function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 20;
|
||||
|
||||
// Advanced filters
|
||||
const [filterAction, setFilterAction] = useState('');
|
||||
const [filterTarget, setFilterTarget] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterDateRange, setFilterDateRange] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// 筛选/分页状态走 URL query(刷新与分享链接可恢复)
|
||||
const [page, setPage] = useQueryState('page', 1, asNumber);
|
||||
const [filterAction, setFilterAction] = useQueryState('action', '', asString);
|
||||
const [filterTarget, setFilterTarget] = useQueryState('target', '', asString);
|
||||
const [filterStatus, setFilterStatus] = useQueryState('status', '', asString);
|
||||
const [filterDateRange, setFilterDateRange] = useQueryState('range', '', asString);
|
||||
const [searchQuery, setSearchQuery] = useQueryState('q', '', asString);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Detail expanded
|
||||
@@ -595,14 +596,14 @@ export default function AuditLogsPage() {
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm text-[var(--dg-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm text-[var(--dg-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
|
||||
+122
-1510
File diff suppressed because it is too large.
Load diff
@@ -1,58 +1,40 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '@/api/request';
|
||||
import { FormDialog } from '@/components/FormDialog';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
Save, ChevronRight, ChevronDown, RefreshCw,
|
||||
CheckCircle, XCircle, Trash2, KeyRound, FolderOpen, Users,
|
||||
Shield, Import, UserPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── types ── */
|
||||
interface LdapConfigData {
|
||||
ldap_host: string;
|
||||
ldap_port: number;
|
||||
use_ldaps: boolean;
|
||||
admin_dn: string;
|
||||
admin_password: string;
|
||||
search_filter: string;
|
||||
base_dn: string;
|
||||
user_mapping: Record<string, string>;
|
||||
}
|
||||
|
||||
interface OuItem { dn: string; name: string }
|
||||
interface LdapUser { dn: string; uid: string; cn: string; mail: string }
|
||||
|
||||
const defaultConfig: LdapConfigData = {
|
||||
ldap_host: '',
|
||||
ldap_port: 389,
|
||||
use_ldaps: false,
|
||||
admin_dn: '',
|
||||
admin_password: '',
|
||||
search_filter: '(objectClass=inetOrgPerson)',
|
||||
base_dn: '',
|
||||
user_mapping: { username: 'uid', email: 'mail', display_name: 'cn' },
|
||||
};
|
||||
type LdapConfigData, type OuItem, type LdapUser,
|
||||
type LdapTestResult, type LdapAccountTestResult,
|
||||
type CreateOuFormState, type AddUserFormState,
|
||||
defaultLdapConfig,
|
||||
} from '@/features/system/ldap/types';
|
||||
import { LdapConfigForm } from '@/features/system/ldap/components/LdapConfigForm';
|
||||
import { LdapManagePanel } from '@/features/system/ldap/components/LdapManagePanel';
|
||||
import { LdapCreateOuDialog } from '@/features/system/ldap/components/LdapCreateOuDialog';
|
||||
import { LdapAddUserDialog } from '@/features/system/ldap/components/LdapAddUserDialog';
|
||||
import { LdapAccountTestDialog } from '@/features/system/ldap/components/LdapAccountTestDialog';
|
||||
|
||||
/**
|
||||
* LDAP 配置页(SettingsPage 的 ldap tab)
|
||||
* 页面骨架只做状态与编排:数据加载/API 调用在此,展示与交互在 features/system/ldap/components
|
||||
*/
|
||||
export default function LdapConfigPage() {
|
||||
const [config, setConfig] = useState<LdapConfigData>(defaultConfig);
|
||||
const [config, setConfig] = useState<LdapConfigData>(defaultLdapConfig);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasStoredPwd, setHasStoredPwd] = useState(false);
|
||||
|
||||
/* 连接测试 */
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [testResult, setTestResult] = useState<LdapTestResult | null>(null);
|
||||
const [serverConnected, setServerConnected] = useState(false);
|
||||
|
||||
/* 账号测试弹框 */
|
||||
/* 账号测试弹框(凭据在页面持有,关闭弹框不清空) */
|
||||
const [showAccountTest, setShowAccountTest] = useState(false);
|
||||
const [testUser, setTestUser] = useState('');
|
||||
const [testPass, setTestPass] = useState('');
|
||||
const [testingAccount, setTestingAccount] = useState(false);
|
||||
const [accountResult, setAccountResult] = useState<{ ok: boolean; msg: string; info?: Record<string, string> } | null>(null);
|
||||
const [accountResult, setAccountResult] = useState<LdapAccountTestResult | null>(null);
|
||||
|
||||
/* LDAP 管理(折叠区) */
|
||||
const [showManage, setShowManage] = useState(false);
|
||||
@@ -63,25 +45,19 @@ export default function LdapConfigPage() {
|
||||
const [loadingOu, setLoadingOu] = useState(false);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
|
||||
/* 创建 OU / 添加用户 / 删除用户 */
|
||||
/* 创建 OU / 添加用户弹框(表单状态提升到页面,关闭不重置,仅成功后重置) */
|
||||
const [showCreateOu, setShowCreateOu] = useState(false);
|
||||
const [newOuName, setNewOuName] = useState('');
|
||||
const [newOuDesc, setNewOuDesc] = useState('');
|
||||
const [newOuParent, setNewOuParent] = useState('');
|
||||
const [creatingOu, setCreatingOu] = useState(false);
|
||||
const [createOuForm, setCreateOuForm] = useState<CreateOuFormState>({ name: '', desc: '', parent: '' });
|
||||
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [addUsername, setAddUsername] = useState('');
|
||||
const [addDisplayName, setAddDisplayName] = useState('');
|
||||
const [addEmail, setAddEmail] = useState('');
|
||||
const [addPassword, setAddPassword] = useState('');
|
||||
const [addOuDn, setAddOuDn] = useState('');
|
||||
const [addingUser, setAddingUser] = useState(false);
|
||||
const [addUserForm, setAddUserForm] = useState<AddUserFormState>({ username: '', displayName: '', email: '', password: '', ouDn: '' });
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
/* ── 加载配置 ── */
|
||||
/* ── 加载配置(密码不回显,仅记录后端是否已存密码) ── */
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -94,11 +70,11 @@ export default function LdapConfigPage() {
|
||||
use_ldaps: data.use_ldaps ?? false,
|
||||
admin_dn: data.admin_dn || '',
|
||||
admin_password: '',
|
||||
search_filter: data.search_filter || defaultConfig.search_filter,
|
||||
search_filter: data.search_filter || defaultLdapConfig.search_filter,
|
||||
base_dn: data.base_dn || '',
|
||||
user_mapping: typeof data.user_mapping === 'string'
|
||||
? JSON.parse(data.user_mapping)
|
||||
: data.user_mapping || defaultConfig.user_mapping,
|
||||
: data.user_mapping || defaultLdapConfig.user_mapping,
|
||||
});
|
||||
setHasStoredPwd(!!data.admin_password);
|
||||
}
|
||||
@@ -111,18 +87,6 @@ export default function LdapConfigPage() {
|
||||
|
||||
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
||||
|
||||
/* ── helpers ── */
|
||||
const updateField = <K extends keyof LdapConfigData>(key: K, value: LdapConfigData[K]) => {
|
||||
setConfig(prev => {
|
||||
const next = { ...prev, [key]: value };
|
||||
if (key === 'use_ldaps') {
|
||||
if (value && prev.ldap_port === 389) next.ldap_port = 636;
|
||||
if (!value && prev.ldap_port === 636) next.ldap_port = 389;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/* ── 测试服务器连接(先测后存) ── */
|
||||
const handleTestServer = async () => {
|
||||
setTesting(true);
|
||||
@@ -225,19 +189,17 @@ export default function LdapConfigPage() {
|
||||
|
||||
/* ── 创建 OU ── */
|
||||
const handleCreateOu = async () => {
|
||||
if (!newOuName.trim()) return;
|
||||
if (!createOuForm.name.trim()) return;
|
||||
setCreatingOu(true);
|
||||
try {
|
||||
await api.post('/ldap-config/create-ou', {
|
||||
ou_name: newOuName.trim(),
|
||||
parent_dn: newOuParent || config.base_dn,
|
||||
description: newOuDesc,
|
||||
ou_name: createOuForm.name.trim(),
|
||||
parent_dn: createOuForm.parent || config.base_dn,
|
||||
description: createOuForm.desc,
|
||||
});
|
||||
toast('success', `OU "${newOuName}" 创建成功`);
|
||||
toast('success', `OU "${createOuForm.name}" 创建成功`);
|
||||
setShowCreateOu(false);
|
||||
setNewOuName('');
|
||||
setNewOuDesc('');
|
||||
setNewOuParent('');
|
||||
setCreateOuForm({ name: '', desc: '', parent: '' });
|
||||
fetchOus();
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || '创建 OU 失败');
|
||||
@@ -248,25 +210,21 @@ export default function LdapConfigPage() {
|
||||
|
||||
/* ── 添加用户到 LDAP ── */
|
||||
const handleAddUser = async () => {
|
||||
if (!addUsername.trim() || !addPassword.trim()) return;
|
||||
if (!addUserForm.username.trim() || !addUserForm.password.trim()) return;
|
||||
setAddingUser(true);
|
||||
try {
|
||||
await api.post('/ldap-config/create-user', {
|
||||
username: addUsername.trim(),
|
||||
display_name: addDisplayName.trim() || addUsername.trim(),
|
||||
email: addEmail.trim(),
|
||||
password: addPassword,
|
||||
ou_dn: addOuDn || selectedOu || config.base_dn,
|
||||
username: addUserForm.username.trim(),
|
||||
display_name: addUserForm.displayName.trim() || addUserForm.username.trim(),
|
||||
email: addUserForm.email.trim(),
|
||||
password: addUserForm.password,
|
||||
ou_dn: addUserForm.ouDn || selectedOu || config.base_dn,
|
||||
});
|
||||
toast('success', `用户 "${addUsername}" 创建成功`);
|
||||
toast('success', `用户 "${addUserForm.username}" 创建成功`);
|
||||
setShowAddUser(false);
|
||||
setAddUsername('');
|
||||
setAddDisplayName('');
|
||||
setAddEmail('');
|
||||
setAddPassword('');
|
||||
setAddOuDn('');
|
||||
setAddUserForm({ username: '', displayName: '', email: '', password: '', ouDn: '' });
|
||||
if (selectedOu) fetchUsers(selectedOu);
|
||||
else fetchUsers(addOuDn || config.base_dn);
|
||||
else fetchUsers(addUserForm.ouDn || config.base_dn);
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || '创建用户失败');
|
||||
} finally {
|
||||
@@ -298,11 +256,42 @@ export default function LdapConfigPage() {
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
/* ── 弹框/面板编排回调 ── */
|
||||
const toggleManage = () => {
|
||||
if (!showManage && ouList.length === 0) fetchOus();
|
||||
setShowManage(!showManage);
|
||||
};
|
||||
|
||||
const handleSelectedOuChange = (dn: string) => {
|
||||
setSelectedOu(dn);
|
||||
if (dn) fetchUsers(dn);
|
||||
};
|
||||
|
||||
/* 打开添加用户弹框:预填当前 OU(与原行为一致) */
|
||||
const openAddUser = () => {
|
||||
setAddUserForm(prev => ({ ...prev, ouDn: selectedOu || config.base_dn }));
|
||||
setShowAddUser(true);
|
||||
};
|
||||
|
||||
const patchCreateOuForm = (patch: Partial<CreateOuFormState>) =>
|
||||
setCreateOuForm(prev => ({ ...prev, ...patch }));
|
||||
const patchAddUserForm = (patch: Partial<AddUserFormState>) =>
|
||||
setAddUserForm(prev => ({ ...prev, ...patch }));
|
||||
|
||||
/* 账号测试:输入变更清空上次结果,关闭时清空结果但保留凭据 */
|
||||
const handleTestUserChange = (value: string) => {
|
||||
setTestUser(value);
|
||||
setAccountResult(null);
|
||||
};
|
||||
const handleTestPassChange = (value: string) => {
|
||||
setTestPass(value);
|
||||
setAccountResult(null);
|
||||
};
|
||||
const closeAccountTest = () => {
|
||||
setShowAccountTest(false);
|
||||
setAccountResult(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-20 text-[var(--dg-text-muted)] text-sm">加载中…</div>;
|
||||
}
|
||||
@@ -314,535 +303,79 @@ export default function LdapConfigPage() {
|
||||
配置 LDAP 服务器后,用户可以使用域账号登录系统。填写完配置后请先<strong>测试连接</strong>,确认无误后再保存。
|
||||
</div>
|
||||
|
||||
{/* ── 配置表单 ── */}
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl p-5 space-y-5">
|
||||
{/* 服务器 */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-6">
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">LDAP 服务器</label>
|
||||
<input
|
||||
value={config.ldap_host}
|
||||
onChange={e => updateField('ldap_host', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="8.217.205.92"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.ldap_port}
|
||||
onChange={e => updateField('ldap_port', parseInt(e.target.value) || 389)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 flex items-end pb-1">
|
||||
<button
|
||||
onClick={() => updateField('use_ldaps', !config.use_ldaps)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors ${config.use_ldaps ? 'bg-emerald-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${config.use_ldaps ? 'translate-x-4.5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] ml-2">LDAPS (SSL/TLS)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 管理员 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">管理员 DN</label>
|
||||
<input
|
||||
value={config.admin_dn}
|
||||
onChange={e => updateField('admin_dn', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="cn=admin,dc=opsx,dc=vip"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">管理员密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={config.admin_password}
|
||||
onChange={e => updateField('admin_password', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder={hasStoredPwd ? '已保存,输入新密码以修改' : '请输入密码'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索配置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Base DN</label>
|
||||
<input
|
||||
value={config.base_dn}
|
||||
onChange={e => updateField('base_dn', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="dc=opsx,dc=vip"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户过滤</label>
|
||||
<input
|
||||
value={config.search_filter}
|
||||
onChange={e => updateField('search_filter', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="(objectClass=inetOrgPerson)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性映射 */}
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">属性映射</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户名</label>
|
||||
<input
|
||||
value={config.user_mapping.username || ''}
|
||||
onChange={e => setConfig(p => ({ ...p, user_mapping: { ...p.user_mapping, username: e.target.value } }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="uid"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">邮箱</label>
|
||||
<input
|
||||
value={config.user_mapping.email || ''}
|
||||
onChange={e => setConfig(p => ({ ...p, user_mapping: { ...p.user_mapping, email: e.target.value } }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="mail"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">姓名</label>
|
||||
<input
|
||||
value={config.user_mapping.display_name || ''}
|
||||
onChange={e => setConfig(p => ({ ...p, user_mapping: { ...p.user_mapping, display_name: e.target.value } }))}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="cn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作栏:测试 + 保存 */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleTestServer}
|
||||
disabled={testing || !config.ldap_host || !config.admin_dn}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-blue-500/15 text-blue-400 hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{testing ? '测试中…' : '测试连接'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<span className={`flex items-center gap-1 text-sm ${testResult.ok ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{testResult.ok ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||
{testResult.msg}
|
||||
</span>
|
||||
)}
|
||||
{config.ldap_host && config.base_dn && (
|
||||
<button
|
||||
onClick={() => setShowAccountTest(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
测试账号验证
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* ── 基础配置表单 ── */}
|
||||
<LdapConfigForm
|
||||
config={config}
|
||||
hasStoredPwd={hasStoredPwd}
|
||||
testing={testing}
|
||||
saving={saving}
|
||||
testResult={testResult}
|
||||
onConfigChange={setConfig}
|
||||
onTestServer={handleTestServer}
|
||||
onSave={handleSave}
|
||||
onOpenAccountTest={() => setShowAccountTest(true)}
|
||||
/>
|
||||
|
||||
{/* ── LDAP 账号管理(折叠区,连接成功后显示) ── */}
|
||||
{config.ldap_host && config.base_dn && (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl overflow-hidden">
|
||||
{/* 折叠头 */}
|
||||
<button
|
||||
onClick={toggleManage}
|
||||
className="w-full flex items-center gap-3 px-5 py-3.5 hover:bg-[var(--dg-bg-hover)] transition-colors text-left"
|
||||
>
|
||||
{showManage ? <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">LDAP 账号管理</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-orange-500/15 text-orange-400">危险操作</span>
|
||||
</button>
|
||||
|
||||
{/* 折叠内容 */}
|
||||
{showManage && (
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
<div className="px-3 py-2 rounded-lg bg-orange-500/8 text-orange-300 text-xs">
|
||||
此区域包含 LDAP 危险操作,删除不可逆,请谨慎使用。
|
||||
</div>
|
||||
|
||||
{/* OU 选择 + 操作按钮 */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<FolderOpen className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">组织单元 (OU)</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">({ouList.length})</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedOu}
|
||||
onChange={e => { setSelectedOu(e.target.value); if (e.target.value) fetchUsers(e.target.value); }}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400"
|
||||
>
|
||||
<option value="">选择 OU...</option>
|
||||
<option value={config.base_dn}>[根] {config.base_dn}</option>
|
||||
{ouList.map(ou => (
|
||||
<option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchOus}
|
||||
disabled={loadingOu}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
title="刷新 OU 列表"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loadingOu ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateOu(true)}
|
||||
className="px-3 py-2 rounded-lg bg-blue-500/15 text-blue-400 hover:bg-blue-500/25 transition-colors text-sm"
|
||||
>
|
||||
+ 新建 OU
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setAddOuDn(selectedOu || config.base_dn); setShowAddUser(true); }}
|
||||
className="px-3 py-2 rounded-lg bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25 transition-colors text-sm"
|
||||
>
|
||||
+ 添加用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
{selectedOu && (
|
||||
<div>
|
||||
{loadingUsers ? (
|
||||
<div className="text-center py-8 text-sm text-[var(--dg-text-muted)]">加载用户列表…</div>
|
||||
) : ouUsers.length > 0 ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedDns.size > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-red-500/15 text-red-400 hover:bg-red-500/25 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{deleting ? '删除中…' : `删除 (${selectedDns.size})`}
|
||||
</button>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-secondary)] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDns.size === ouUsers.length && ouUsers.length > 0}
|
||||
ref={el => { if (el) el.indeterminate = selectedDns.size > 0 && selectedDns.size < ouUsers.length; }}
|
||||
onChange={() => {
|
||||
if (selectedDns.size === ouUsers.length) setSelectedDns(new Set());
|
||||
else setSelectedDns(new Set(ouUsers.map(u => u.dn)));
|
||||
}}
|
||||
className="rounded border-[var(--dg-border)]"
|
||||
/>
|
||||
全选
|
||||
</label>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
共 {ouUsers.length} 个用户,已选 {selectedDns.size} 个
|
||||
</span>
|
||||
</div>
|
||||
<Users className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg overflow-hidden border border-[var(--dg-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="w-10 px-3 py-2 text-left"></th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-muted)] font-medium">用户名</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-muted)] font-medium">显示名称</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-muted)] font-medium">邮箱</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-muted)] font-medium">DN</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ouUsers.map(user => (
|
||||
<tr key={user.dn} className="border-t border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDns.has(user.dn)}
|
||||
onChange={() => {
|
||||
setSelectedDns(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(user.dn) ? next.delete(user.dn) : next.add(user.dn);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="rounded border-[var(--dg-border)]"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-primary)]">{user.uid}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{user.cn}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{user.mail || '-'}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-muted)] text-xs font-mono">{user.dn}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-[var(--dg-text-muted)]">该 OU 下暂无用户</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LdapManagePanel
|
||||
expanded={showManage}
|
||||
onToggle={toggleManage}
|
||||
baseDn={config.base_dn}
|
||||
ouList={ouList}
|
||||
loadingOu={loadingOu}
|
||||
selectedOu={selectedOu}
|
||||
onSelectedOuChange={handleSelectedOuChange}
|
||||
onRefreshOus={fetchOus}
|
||||
onCreateOu={() => setShowCreateOu(true)}
|
||||
onAddUser={openAddUser}
|
||||
ouUsers={ouUsers}
|
||||
loadingUsers={loadingUsers}
|
||||
selectedDns={selectedDns}
|
||||
onSelectedDnsChange={setSelectedDns}
|
||||
deleting={deleting}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 创建 OU 弹框 ── */}
|
||||
{showCreateOu && (
|
||||
<ModalOverlay open={true} onClose={() => setShowCreateOu(false)} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-5 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="w-4 h-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">新建组织单元 (OU)</h3>
|
||||
</div>
|
||||
<button onClick={() => setShowCreateOu(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">✕</button>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">OU 名称 *</label>
|
||||
<input
|
||||
value={newOuName}
|
||||
onChange={e => setNewOuName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="如: devops, engineering"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">父级 DN</label>
|
||||
<select
|
||||
value={newOuParent}
|
||||
onChange={e => setNewOuParent(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
>
|
||||
<option value="">根: {config.base_dn}</option>
|
||||
{ouList.map(ou => <option key={ou.dn} value={ou.dn}>{ou.dn}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">描述</label>
|
||||
<input
|
||||
value={newOuDesc}
|
||||
onChange={e => setNewOuDesc(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="可选,如: 研发团队"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setShowCreateOu(false)} className="px-4 py-2 text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
||||
<button
|
||||
onClick={handleCreateOu}
|
||||
disabled={creatingOu || !newOuName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creatingOu ? '创建中…' : '创建 OU'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
<LdapCreateOuDialog
|
||||
form={createOuForm}
|
||||
onFormChange={patchCreateOuForm}
|
||||
ouList={ouList}
|
||||
baseDn={config.base_dn}
|
||||
creating={creatingOu}
|
||||
onClose={() => setShowCreateOu(false)}
|
||||
onConfirm={handleCreateOu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 添加用户弹框 ── */}
|
||||
{showAddUser && (
|
||||
<ModalOverlay open={true} onClose={() => setShowAddUser(false)} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-5 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserPlus className="w-4 h-4 text-emerald-400" />
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">添加用户到 LDAP</h3>
|
||||
</div>
|
||||
<button onClick={() => setShowAddUser(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">✕</button>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户名 *</label>
|
||||
<input
|
||||
value={addUsername}
|
||||
onChange={e => setAddUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="登录名,如 zhangsan"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">密码 *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={addPassword}
|
||||
onChange={e => setAddPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="初始密码"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">显示名称</label>
|
||||
<input
|
||||
value={addDisplayName}
|
||||
onChange={e => setAddDisplayName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="如: 张三"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">邮箱</label>
|
||||
<input
|
||||
value={addEmail}
|
||||
onChange={e => setAddEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">目标 OU</label>
|
||||
<select
|
||||
value={addOuDn}
|
||||
onChange={e => setAddOuDn(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
>
|
||||
<option value="">根: {config.base_dn}</option>
|
||||
{ouList.map(ou => <option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setShowAddUser(false)} className="px-4 py-2 text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
disabled={addingUser || !addUsername.trim() || !addPassword.trim()}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{addingUser ? '创建中…' : '创建用户'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
<LdapAddUserDialog
|
||||
form={addUserForm}
|
||||
onFormChange={patchAddUserForm}
|
||||
ouList={ouList}
|
||||
baseDn={config.base_dn}
|
||||
adding={addingUser}
|
||||
onClose={() => setShowAddUser(false)}
|
||||
onConfirm={handleAddUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 账号测试弹框 ── */}
|
||||
{/* ── 账号测试弹框 ── */}
|
||||
{showAccountTest && (
|
||||
<ModalOverlay open={true} onClose={() => { setShowAccountTest(false); setAccountResult(null); }} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-5 shadow-xl">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">账号验证测试</h3>
|
||||
</div>
|
||||
<button onClick={() => { setShowAccountTest(false); setAccountResult(null); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单区 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户名</label>
|
||||
<input
|
||||
value={testUser}
|
||||
onChange={e => { setTestUser(e.target.value); setAccountResult(null); }}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="LDAP 用户名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={testPass}
|
||||
onChange={e => { setTestPass(e.target.value); setAccountResult(null); }}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="密码"
|
||||
onKeyDown={e => { if (e.key === 'Enter' && testUser && testPass) handleTestAccount(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果区 */}
|
||||
{accountResult && (
|
||||
<div className="mb-4">
|
||||
{accountResult.ok ? (
|
||||
<div className="rounded-lg bg-emerald-500/12 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-emerald-400">验证通过</span>
|
||||
</div>
|
||||
{accountResult.info && (
|
||||
<div className="space-y-1 pl-6 max-h-24 overflow-y-auto">
|
||||
{Object.entries(accountResult.info)
|
||||
.filter(([, v]) => v && String(v).length > 0)
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="flex text-xs gap-2">
|
||||
<span className="text-emerald-400/60 shrink-0">{
|
||||
k === 'Display Name' ? '姓名' :
|
||||
k === 'Email' ? '邮箱' :
|
||||
k === 'User DN' ? 'DN' :
|
||||
k === 'User ID' ? '用户名' : k
|
||||
}</span>
|
||||
<span className="text-emerald-300">{
|
||||
Array.isArray(v) ? v.join(', ') : String(v)
|
||||
}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-red-500/12 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-red-400">验证失败</span>
|
||||
</div>
|
||||
<p className="text-xs text-red-300/80 mt-1 pl-6">{accountResult.msg}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => { setShowAccountTest(false); setAccountResult(null); }} className="px-4 py-2 text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">关闭</button>
|
||||
<button
|
||||
onClick={handleTestAccount}
|
||||
disabled={testingAccount || !testUser || !testPass}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{testingAccount ? (
|
||||
<><RefreshCw className="w-3.5 h-3.5 animate-spin" />验证中…</>
|
||||
) : (
|
||||
<><Shield className="w-3.5 h-3.5" />测试验证</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
<LdapAccountTestDialog
|
||||
testUser={testUser}
|
||||
testPass={testPass}
|
||||
result={accountResult}
|
||||
testing={testingAccount}
|
||||
onTestUserChange={handleTestUserChange}
|
||||
onTestPassChange={handleTestPassChange}
|
||||
onClose={closeAccountTest}
|
||||
onTest={handleTestAccount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 删除确认弹框 ── */}
|
||||
|
||||
@@ -1,106 +1,28 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
AlertTriangle, CheckCircle, Play, Loader2,
|
||||
ChevronDown, ChevronRight, Clock, FileText, Sparkles,
|
||||
Download, Search, ArrowUpDown, Database, Table2,
|
||||
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
|
||||
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
|
||||
PanelRightClose, PanelRightOpen, ChevronLeft, ChevronLast,
|
||||
} from 'lucide-react';
|
||||
// Card components replaced by design system utility classes
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||
import { queryApi } from '@/api/query';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { redisApi } from '@/api/redis';
|
||||
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
||||
import { instanceDetailApi } from '@/api/instanceDetail';
|
||||
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
||||
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
|
||||
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
||||
import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog';
|
||||
import type { RedisLineEditorState } from '@/features/redis/types';
|
||||
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
|
||||
import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer';
|
||||
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
|
||||
import type { QueryHistoryItem } from '@/features/query/types';
|
||||
import { downloadCSV, exportToCSV } from '@/features/query/utils';
|
||||
import { QueryTopBar } from '@/features/query/components/QueryTopBar';
|
||||
import { SqlEditorSection } from '@/features/query/components/SqlEditorSection';
|
||||
import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePanel';
|
||||
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
|
||||
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
|
||||
import type {
|
||||
QueryHistoryItem, QueryInstanceOption, QueryResult,
|
||||
RedisKeyDetail, RedisKeyInfo, SortDirection, SortState,
|
||||
} from '@/features/query/types';
|
||||
import api from '@/api/request';
|
||||
import { readStorage, writeStorage } from '@/lib/storage';
|
||||
|
||||
interface QueryResult {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
total: number;
|
||||
count: number;
|
||||
affected_rows: number;
|
||||
execution_time_ms: number;
|
||||
cost_ms?: number;
|
||||
raw_value?: boolean;
|
||||
raw_key?: string;
|
||||
raw_text?: string;
|
||||
raw_type?: string;
|
||||
raw_ttl?: string;
|
||||
error?: string;
|
||||
masked_columns?: string[];
|
||||
}
|
||||
|
||||
interface RedisKeyInfo {
|
||||
key?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
interface RedisKeyDetail {
|
||||
key: string;
|
||||
name?: string;
|
||||
type: string;
|
||||
ttl: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc' | null;
|
||||
type SortState = { column: string | null; direction: SortDirection };
|
||||
|
||||
const dbTypeLabel: Record<string, string> = {
|
||||
mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB',
|
||||
};
|
||||
|
||||
const dbTypeIcon: Record<string, string> = {
|
||||
mysql: '/db-logos/mysql.svg',
|
||||
postgres: '/db-logos/postgres.svg',
|
||||
postgresql: '/db-logos/postgres.svg',
|
||||
redis: '/db-logos/redis.svg',
|
||||
mongo: '/db-logos/mongo.svg',
|
||||
mongodb: '/db-logos/mongo.svg',
|
||||
};
|
||||
|
||||
// 脱敏关键词不再硬编码——由后端 applyDataMasking 返回 masked_columns 标记
|
||||
// 后端根据 data_masking_enabled + data_masking_sensitive_fields + 实例 mask_fields 决定
|
||||
|
||||
const placeholderMap: Record<DBType, string> = {
|
||||
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
|
||||
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
|
||||
};
|
||||
|
||||
function normalizeDbType(dbType?: string): DBType {
|
||||
if (dbType === 'postgres' || dbType === 'pg') return 'postgresql';
|
||||
if (dbType === 'mongo') return 'mongodb';
|
||||
if (dbType === 'redis') return 'redis';
|
||||
return 'mysql';
|
||||
}
|
||||
|
||||
const queryPolicyText: Record<DBType, string> = {
|
||||
mysql: '查询入口仅允许 SELECT / SHOW / DESCRIBE / EXPLAIN;禁止 UPDATE/DELETE/DDL、多语句、SELECT FOR UPDATE。未写 LIMIT 时按平台配置自动限制。',
|
||||
postgresql: '查询入口仅允许 SELECT / EXPLAIN;禁止 EXPLAIN ANALYZE、写入 CTE、多语句和锁定读。未写 LIMIT 时按平台配置自动限制。',
|
||||
redis: 'Redis 查询入口仅允许只读命令;写命令请提交变更工单。集群实例会按 slot 路由并扫描所有 master。',
|
||||
mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。',
|
||||
};
|
||||
|
||||
export default function QueriesPage() {
|
||||
// -- 编辑器 state --
|
||||
const [sql, setSql] = useState('');
|
||||
@@ -108,7 +30,7 @@ export default function QueriesPage() {
|
||||
const [database, setDatabase] = useState('');
|
||||
const [databases, setDatabases] = useState<string[]>([]);
|
||||
const [loadingDbs, setLoadingDbs] = useState(false);
|
||||
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number; db_type: string; cluster_mode?: boolean }>>([]);
|
||||
const [instances, setInstances] = useState<QueryInstanceOption[]>([]);
|
||||
const [editorSchema, setEditorSchema] = useState<Record<string, Record<string, string[]>>>({});
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -134,9 +56,6 @@ export default function QueriesPage() {
|
||||
const [showResult, setShowResult] = useState(true);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
|
||||
const [rowHeight] = useState(36);
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||
const resultContainerRef = useRef<HTMLDivElement>(null);
|
||||
// -- Sidebar 拖拽宽度 --
|
||||
const [sideWidth, setSideWidth] = useState(() => Number(readStorage('querySideWidth')) || 280);
|
||||
const sidebarDragRef = useRef<HTMLDivElement>(null);
|
||||
@@ -238,6 +157,10 @@ export default function QueriesPage() {
|
||||
navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const handleInspectRedisValue = useCallback((field: string, value: unknown) => {
|
||||
setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
|
||||
if (/^\d+$/.test(database)) return;
|
||||
@@ -314,21 +237,6 @@ export default function QueriesPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const SortIcon = ({ col }: { col: string }) => {
|
||||
if (sortState.column !== col) return <ArrowUpDown className="w-3 h-3 opacity-30" />;
|
||||
if (sortState.direction === 'asc') return <ArrowUp className="w-3 h-3 text-blue-400" />;
|
||||
return <ArrowDown className="w-3 h-3 text-blue-400" />;
|
||||
};
|
||||
|
||||
/* ---- Virtual Scroll ---- */
|
||||
const handleResultScroll = useCallback(() => {
|
||||
if (!resultContainerRef.current) return;
|
||||
const c = resultContainerRef.current; const start = Math.max(0, Math.floor(c.scrollTop / rowHeight) - 5);
|
||||
const count = Math.ceil(c.clientHeight / rowHeight);
|
||||
setVisibleRange({ start, end: Math.min((sortedResult?.rows?.length || 0), start + count + 10) });
|
||||
}, [sortedResult, rowHeight]);
|
||||
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
|
||||
|
||||
const handleExportResult = useCallback(async () => {
|
||||
if (!sql || !instanceId) return;
|
||||
try {
|
||||
@@ -348,17 +256,12 @@ export default function QueriesPage() {
|
||||
}
|
||||
}, [sql, instanceId, database]);
|
||||
|
||||
/* ---- Smart cell value formatter (detect JSON) ---- */
|
||||
const formatCellValue = useCallback((val: unknown) => {
|
||||
const str = String(val ?? '');
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return { text: JSON.stringify(parsed, null, 2), isJson: true };
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return { text: str, isJson: false };
|
||||
}, []);
|
||||
// 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包)
|
||||
const handlePageSizeChange = useCallback((size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
handleExecute(1);
|
||||
}, [handleExecute]);
|
||||
|
||||
/* ---- History ---- */
|
||||
const toggleFavorite = useCallback((id: number) => {
|
||||
@@ -403,70 +306,38 @@ export default function QueriesPage() {
|
||||
return r;
|
||||
}, [history, favFilter, searchHistory]);
|
||||
|
||||
const handleToggleSidebar = useCallback(() => setShowSidebar(v => !v), []);
|
||||
const handleToggleTemplates = useCallback(() => setShowTemplates(v => !v), []);
|
||||
const handleToggleHistory = useCallback(() => setShowHistory(v => !v), []);
|
||||
const handleToggleResult = useCallback(() => setShowResult(v => !v), []);
|
||||
|
||||
return (
|
||||
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
|
||||
{/* ====== 主区域 ====== */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
|
||||
|
||||
{/* -- 顶栏: 实例+库选择 -- */}
|
||||
<div className="shrink-0 px-0 md:px-5 pt-1 md:pt-4 pb-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-full md:min-w-0 flex-wrap md:flex-nowrap">
|
||||
{selectedInstance && dbTypeIcon[dbType] && (
|
||||
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
|
||||
)}
|
||||
<select value={instanceId} onChange={e => setInstanceId(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[200px]">
|
||||
<option value="">选择实例</option>
|
||||
{instances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({dbTypeLabel[inst.db_type] || inst.db_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{dbType === 'redis' ? (
|
||||
<select value={database || '0'} onChange={e => setDatabase(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-full md:w-24">
|
||||
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<select value={database} onChange={e => setDatabase(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[160px]">
|
||||
<option value="">{loadingDbs ? '加载中...' : '选择数据库'}</option>
|
||||
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧工具按钮组 */}
|
||||
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
|
||||
{instanceId && (
|
||||
<button onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{showSidebar ? '隐藏' : '导航'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowTemplates(!showTemplates)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
||||
模板 ({templates.length})
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => setShowHistory(!showHistory)}
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
{showHistory ? <PanelRightClose className="w-3.5 h-3.5" /> : <PanelRightOpen className="w-3.5 h-3.5" />}
|
||||
历史 {history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
|
||||
</button>
|
||||
<button onClick={handleExecute} disabled={executing || !instanceId}
|
||||
className="dg-btn dg-btn-primary ml-auto md:ml-0">
|
||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{executing ? '执行中...' : '执行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<QueryTopBar
|
||||
instances={instances}
|
||||
instanceId={instanceId}
|
||||
selectedInstance={selectedInstance}
|
||||
database={database}
|
||||
databases={databases}
|
||||
loadingDbs={loadingDbs}
|
||||
dbType={dbType}
|
||||
showSidebar={showSidebar}
|
||||
showTemplates={showTemplates}
|
||||
showHistory={showHistory}
|
||||
executing={executing}
|
||||
templateCount={templates.length}
|
||||
historyCount={history.length}
|
||||
onInstanceIdChange={setInstanceId}
|
||||
onDatabaseChange={setDatabase}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
onToggleTemplates={handleToggleTemplates}
|
||||
onToggleHistory={handleToggleHistory}
|
||||
onExecute={handleExecute}
|
||||
/>
|
||||
|
||||
{showTemplates && (
|
||||
<QueryTemplatePanel
|
||||
@@ -512,145 +383,34 @@ export default function QueriesPage() {
|
||||
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{dbType === 'redis' && instanceId && (
|
||||
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${showSidebar ? 'md:hidden' : ''}`}>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="border-b border-[var(--dg-border)] px-3 py-2">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<KeyRound className="h-4 w-4 text-red-400" />
|
||||
Redis 键值可视化
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_130px_auto] lg:w-[620px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={redisPattern}
|
||||
onChange={e => setRedisPattern(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRedisSearch(); }}
|
||||
className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 font-mono text-sm"
|
||||
placeholder="输入 key/pattern,* 扫描全部"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={redisType}
|
||||
onChange={e => setRedisType(e.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="string">String</option>
|
||||
<option value="hash">Hash</option>
|
||||
<option value="list">List</option>
|
||||
<option value="set">Set</option>
|
||||
<option value="zset">ZSet</option>
|
||||
</select>
|
||||
<button onClick={handleRedisSearch} disabled={redisLoading || !redisPattern.trim()} className="dg-btn dg-btn-primary h-9 disabled:opacity-40">
|
||||
{redisLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-h-[520px] min-h-[260px] grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="min-h-[180px] overflow-y-auto border-b border-[var(--dg-border)] lg:border-b-0 lg:border-r">
|
||||
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-3 py-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>Keys</span>
|
||||
<span>{redisKeys.length} 条</span>
|
||||
</div>
|
||||
{redisLoading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }, (_, i) => <div key={i} className="h-10 animate-pulse rounded-md bg-[var(--dg-bg-tertiary)]" />)}
|
||||
</div>
|
||||
) : redisKeys.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-[var(--dg-text-muted)]">输入 key/pattern 后搜索;输入 * 可扫描全部</div>
|
||||
) : redisKeys.map((item, i) => {
|
||||
const keyName = item.key || item.name || '';
|
||||
const active = (redisSelectedKey?.key || redisSelectedKey?.name) === keyName;
|
||||
return (
|
||||
<button
|
||||
key={keyName + i}
|
||||
onClick={() => loadRedisKeyDetail(keyName)}
|
||||
className={`block w-full border-b border-[var(--dg-border-light)] px-3 py-2.5 text-left transition-colors ${active ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{keyName}</span>
|
||||
{item.type && <span className="rounded bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 text-[10px] uppercase text-[var(--dg-text-muted)]">{item.type}</span>}
|
||||
</div>
|
||||
{typeof item.ttl === 'number' && <div className="mt-1 text-[11px] text-[var(--dg-text-muted)]">TTL {formatTTL(item.ttl)}</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-auto">
|
||||
{redisDetailLoading ? (
|
||||
<div className="m-4 h-52 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||
) : redisSelectedKey ? (
|
||||
<div className="flex h-full min-h-[260px] flex-col">
|
||||
<div className="flex flex-col gap-2 border-b border-[var(--dg-border)] px-3 py-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{redisSelectedKey.key || redisSelectedKey.name}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--dg-text-muted)]">
|
||||
<span className="rounded bg-red-500/10 px-1.5 py-0.5 uppercase text-red-400">{redisSelectedKey.type}</span>
|
||||
<span>TTL {formatTTL(redisSelectedKey.ttl)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||
<input value={redisValueSearch} onChange={e => setRedisValueSearch(e.target.value)} className="h-8 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-8 pr-3 text-xs" placeholder="搜索字段或值" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{redisSelectedKey.type === 'string' ? (
|
||||
<pre className="min-h-[180px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
|
||||
{typeof redisSelectedKey.value === 'string' ? (tryFormatJsonText(redisSelectedKey.value) || redisSelectedKey.value) : JSON.stringify(redisSelectedKey.value, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
|
||||
<RedisStructuredValue
|
||||
type={redisSelectedKey.type}
|
||||
value={redisSelectedKey.value}
|
||||
redisKey={redisSelectedKey.key || redisSelectedKey.name || ''}
|
||||
search={redisValueSearch}
|
||||
onCopy={copyRedisText}
|
||||
onInspect={(title, value) => setRedisLineViewer({ open: true, field: title, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
|
||||
onEditHashLine={(field, value) => setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
|
||||
onDeleteHashLine={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[260px] items-center justify-center px-5 text-center text-sm text-[var(--dg-text-muted)]">选择左侧 Key 查看结构化值;命令行查询仍可在下方执行</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RedisKeyValuePanel
|
||||
pattern={redisPattern}
|
||||
typeFilter={redisType}
|
||||
keys={redisKeys}
|
||||
selectedKey={redisSelectedKey}
|
||||
loading={redisLoading}
|
||||
detailLoading={redisDetailLoading}
|
||||
valueSearch={redisValueSearch}
|
||||
hideOnWide={showSidebar}
|
||||
onPatternChange={setRedisPattern}
|
||||
onTypeFilterChange={setRedisType}
|
||||
onSearch={handleRedisSearch}
|
||||
onLoadKeyDetail={loadRedisKeyDetail}
|
||||
onValueSearchChange={setRedisValueSearch}
|
||||
onCopy={copyRedisText}
|
||||
onInspect={handleInspectRedisValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SQL 编辑器 */}
|
||||
<div className="shrink-0 p-0 md:p-4 pb-2">
|
||||
<div className="dg-card overflow-hidden flex flex-col">
|
||||
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
||||
language={editorLang} height="200px" onExecute={handleExecute}
|
||||
schema={(dbType === 'mysql' || dbType === 'postgresql') ? editorSchema : undefined} />
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
||||
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 执行</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[11px] text-[var(--dg-text-muted)] flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 mt-0.5 shrink-0" />
|
||||
<span>{queryPolicyText[dbType]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SqlEditorSection
|
||||
sql={sql}
|
||||
dbType={dbType}
|
||||
editorLang={editorLang}
|
||||
schema={editorSchema}
|
||||
onSqlChange={setSql}
|
||||
onExecute={handleExecute}
|
||||
/>
|
||||
|
||||
{/* -- 错误提示 -- */}
|
||||
{error && (
|
||||
@@ -661,190 +421,25 @@ export default function QueriesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
|
||||
{/* -- 查询结果 (编辑器下方,独立滚动,含虚拟滚动表格/分页/raw 展示) -- */}
|
||||
{result && (
|
||||
<div className="flex-1 min-h-0 px-0 md:px-4 pb-4 overflow-hidden flex flex-col">
|
||||
<div className="dg-card overflow-hidden flex-1 flex flex-col">
|
||||
{/* 结果标题栏 + 折叠按钮 */}
|
||||
<div
|
||||
className="shrink-0 border-b border-[var(--dg-border)] py-2 px-4 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
onClick={() => setShowResult(!showResult)}
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm font-semibold flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
查询结果
|
||||
{showResult
|
||||
? <ChevronDown className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-xs dg-text-secondary">
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{result.execution_time_ms || result.cost_ms || 0}ms</span>
|
||||
{result.raw_text !== undefined ? (
|
||||
<span>{result.raw_value ? 'JSON' : 'String'}</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{result.total || result.rows?.length || 0} 总行</span>
|
||||
<span>{result.rows?.length || 0} 行/页</span>
|
||||
<span>{result.columns?.length || 0} 列</span>
|
||||
</>
|
||||
)}
|
||||
{result.rows?.length > 0 && (
|
||||
<button onClick={(e) => { e.stopPropagation(); handleExportResult(); }}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||
<Download className="w-3 h-3" />导出 CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果内容(可折叠) */}
|
||||
{showResult && (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-0">
|
||||
{result.raw_text !== undefined ? (
|
||||
/* Raw value display — ARDM-style FormatViewer */
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
{/* Key info bar (ARDM KeyHeader style) */}
|
||||
{result.raw_key && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs">
|
||||
<KeyRound className="w-3 h-3 text-blue-400 shrink-0" />
|
||||
<span className="font-mono text-[var(--dg-text-primary)] truncate">{result.raw_key}</span>
|
||||
{result.raw_type && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/15 text-green-400">{result.raw_type}</span>
|
||||
)}
|
||||
{result.raw_ttl && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-500/15 text-orange-400">{result.raw_ttl}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
result.raw_value ? 'bg-blue-500/15 text-blue-400' : 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-muted)]'
|
||||
}`}>{result.raw_value ? 'JSON' : 'String'}</span>
|
||||
<button onClick={() => navigator.clipboard.writeText(
|
||||
result.raw_value
|
||||
? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })()
|
||||
: String(result.raw_text)
|
||||
)} className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-60 hover:opacity-100">
|
||||
<ClipboardPaste className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Formatted value */}
|
||||
<div className="relative group">
|
||||
<pre className={`text-sm leading-relaxed overflow-auto max-h-[400px] p-4 rounded-lg font-mono ${
|
||||
result.raw_value
|
||||
? 'bg-[var(--dg-bg-primary)] text-blue-300 whitespace-pre-wrap break-all'
|
||||
: 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] whitespace-pre-wrap break-all'
|
||||
}`}>{result.raw_value
|
||||
? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })()
|
||||
: String(result.raw_text)
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : result.columns && result.columns.length > 0 ? (
|
||||
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto">
|
||||
{/* ARDM-style: Hash field/value compact list for Redis HGETALL */}
|
||||
{dbType === 'redis' && result.columns.length === 2 && result.columns[0] === 'field' && result.columns[1] === 'value' ? (
|
||||
<RedisHashQueryResult
|
||||
rows={sortedResult?.rows || []}
|
||||
rawKey={result.raw_key}
|
||||
rawType={result.raw_type}
|
||||
rawTtl={result.raw_ttl}
|
||||
command={sql}
|
||||
onView={setRedisLineViewer}
|
||||
/>
|
||||
) : (
|
||||
/* Standard table for other results */
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="dg-table-header">
|
||||
<th className="text-left px-3 py-2 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
|
||||
{result.columns.map(col => (
|
||||
<th key={col} onClick={() => handleSort(col)}
|
||||
className={`text-left px-4 py-2 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none ${
|
||||
dbType === 'redis' ? 'min-w-[200px]' : 'whitespace-nowrap'
|
||||
}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
{col} <SortIcon col={col} />
|
||||
{result.masked_columns?.includes(col) && (
|
||||
<span className="ml-1 px-1 py-0.5 bg-yellow-500/10 text-yellow-400 text-[9px] rounded">敏</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style={{ height: visibleRange.start * rowHeight }} />
|
||||
{(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => {
|
||||
const ri = visibleRange.start + i;
|
||||
return (
|
||||
<tr key={ri} className="dg-table-row" style={{ height: rowHeight }}>
|
||||
<td className="px-3 py-2 text-[10px] text-[var(--dg-text-muted)] font-mono">{ri + 1}</td>
|
||||
{result.columns.map(col => {
|
||||
const isMasked = result.masked_columns?.includes(col);
|
||||
const cellValue = row[col] ?? '';
|
||||
const { text: displayText, isJson } = formatCellValue(cellValue);
|
||||
return (
|
||||
<td key={col} className={`px-4 py-2.5 text-xs ${
|
||||
isJson
|
||||
? 'max-w-[600px] whitespace-pre-wrap break-all font-mono text-[12px] leading-relaxed text-blue-300'
|
||||
: dbType === 'redis'
|
||||
? 'whitespace-pre-wrap break-all font-mono text-[12px]'
|
||||
: 'whitespace-pre-wrap break-words'
|
||||
}`} title={String(cellValue)}>
|
||||
{isMasked ? <span className="text-yellow-400 italic">{String(cellValue)}</span> :
|
||||
isJson ? displayText :
|
||||
String(cellValue)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{/* Pagination */}
|
||||
{result.total > pageSize && (
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs dg-text-secondary border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>每页</span>
|
||||
<select value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setCurrentPage(1); handleExecute(1); }}
|
||||
className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded px-1 py-0.5 text-xs">
|
||||
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<span>条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button disabled={currentPage <= 1} onClick={() => handleExecute(1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3 rotate-180" /></button>
|
||||
<button disabled={currentPage <= 1} onClick={() => handleExecute(currentPage - 1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3" /></button>
|
||||
<span className="px-2">{currentPage} / {Math.ceil(result.total / pageSize)}</span>
|
||||
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => handleExecute(currentPage + 1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3 rotate-180" /></button>
|
||||
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => handleExecute(Math.ceil(result.total / pageSize))}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3" /></button>
|
||||
</div>
|
||||
<span>共 {result.total} 条</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center dg-text-muted flex flex-col items-center gap-2">
|
||||
<Eye className="w-5 h-5" />
|
||||
<span className="text-sm">无结果数据</span>
|
||||
{result.affected_rows > 0 && <span className="text-xs">影响行数: {result.affected_rows}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<QueryResultPanel
|
||||
result={result}
|
||||
sortedResult={sortedResult}
|
||||
sortState={sortState}
|
||||
dbType={dbType}
|
||||
sql={sql}
|
||||
showResult={showResult}
|
||||
pageSize={pageSize}
|
||||
currentPage={currentPage}
|
||||
onToggleResult={handleToggleResult}
|
||||
onExportResult={handleExportResult}
|
||||
onSort={handleSort}
|
||||
onViewRedisLine={setRedisLineViewer}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onPageChange={handleExecute}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* -- 安全提示 -- */}
|
||||
<div className="shrink-0 px-4 pb-3">
|
||||
|
||||
@@ -45,6 +45,11 @@ export default function SettingsPage() {
|
||||
{activeTab === 'platform' && <PlatformConfigPage />}
|
||||
{activeTab === 'ldap' && <LdapConfigPage />}
|
||||
</div>
|
||||
|
||||
{/* 关于:构建指纹(排障时确认部署版本) */}
|
||||
<div className="text-xs text-[var(--dg-text-muted)] border-t border-[var(--dg-border-light)] pt-3">
|
||||
OpsxSQL · 版本 {__APP_VERSION__}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +1,25 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChevronRight, FileCode, Clock, CheckCircle, XCircle,
|
||||
Play, RotateCcw, X, Shield, User, Calendar,
|
||||
MessageSquare, Loader2, AlertTriangle, Database,
|
||||
BarChart3, ChevronDown, ChevronUp, Link2,
|
||||
} from 'lucide-react';
|
||||
import { ChevronRight, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { statusMap, statusColorMap } from '@/lib/constants';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface TicketData {
|
||||
id: number;
|
||||
title: string;
|
||||
db_type: string;
|
||||
instance_id: number;
|
||||
instance_name?: string;
|
||||
target_database: string;
|
||||
sql_content: string;
|
||||
rollback_sql: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
applicant_id: number;
|
||||
applicant_name: string | null;
|
||||
approver_id: number | null;
|
||||
approver_name: string | null;
|
||||
reason: string | null;
|
||||
audit_result: string | null;
|
||||
execute_result: string | null;
|
||||
affected_rows?: number;
|
||||
execute_duration_ms?: number;
|
||||
create_time: string;
|
||||
review_time: string | null;
|
||||
execute_time: string | null;
|
||||
}
|
||||
|
||||
interface TimelineEntry {
|
||||
action: string;
|
||||
label: string;
|
||||
user: string;
|
||||
time: string | null;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
interface RelatedTicket {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
applicant_name: string | null;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ACTION_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
CREATE_TICKET: { icon: FileCode, color: 'text-blue-400', bg: 'bg-blue-500/15' },
|
||||
REVIEW_TICKET: { icon: Shield, color: 'text-green-400', bg: 'bg-green-500/15' },
|
||||
EXECUTE_TICKET: { icon: Play, color: 'text-cyan-400', bg: 'bg-cyan-500/15' },
|
||||
CANCEL_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||
ROLLBACK_TICKET: { icon: RotateCcw, color: 'text-orange-400', bg: 'bg-orange-500/15' },
|
||||
REJECT_TICKET: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||
};
|
||||
|
||||
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
|
||||
high: { label: '高', color: 'text-red-400 bg-red-500/15' },
|
||||
medium: { label: '中', color: 'text-amber-400 bg-amber-500/15' },
|
||||
low: { label: '低', color: 'text-green-400 bg-green-500/15' },
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import type { TicketData, TimelineEntry, ApprovalStep, RelatedTicket } from '@/features/ticket/components/DetailTypes';
|
||||
import DetailApprovalPanel from '@/features/ticket/components/DetailApprovalPanel';
|
||||
import DetailApprovalFlow from '@/features/ticket/components/DetailApprovalFlow';
|
||||
import DetailTimeline from '@/features/ticket/components/DetailTimeline';
|
||||
import DetailAuditResult from '@/features/ticket/components/DetailAuditResult';
|
||||
import DetailExecuteResult from '@/features/ticket/components/DetailExecuteResult';
|
||||
import DetailTicketInfoCard from '@/features/ticket/components/DetailTicketInfoCard';
|
||||
import DetailSqlCard from '@/features/ticket/components/DetailSqlCard';
|
||||
import DetailRollbackSqlCard from '@/features/ticket/components/DetailRollbackSqlCard';
|
||||
import DetailRelatedTickets from '@/features/ticket/components/DetailRelatedTickets';
|
||||
|
||||
/**
|
||||
* 工单详情页(页面骨架)
|
||||
* 仅负责数据获取、动作处理与子组件编排;
|
||||
* 展示逻辑拆分至 src/features/ticket/components/ 下的 Detail* 组件
|
||||
*/
|
||||
export default function TicketDetailPage() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -88,7 +29,7 @@ export default function TicketDetailPage() {
|
||||
// -- Data --
|
||||
const [ticket, setTicket] = useState<TicketData | null>(null);
|
||||
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
||||
const [approvalSteps, setApprovalSteps] = useState<any[]>([]);
|
||||
const [approvalSteps, setApprovalSteps] = useState<ApprovalStep[]>([]);
|
||||
const [relatedTickets, setRelatedTickets] = useState<RelatedTicket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -100,10 +41,6 @@ export default function TicketDetailPage() {
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [showRollbackConfirm, setShowRollbackConfirm] = useState(false);
|
||||
|
||||
// -- UI expansion --
|
||||
const [showFullAudit, setShowFullAudit] = useState(false);
|
||||
const [showRelated, setShowRelated] = useState(true);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch ticket data */
|
||||
/* ---------------------------------------------------------------- */
|
||||
@@ -236,10 +173,6 @@ export default function TicketDetailPage() {
|
||||
};
|
||||
}, [ticket, auth.user.id, hasActionPermission]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Parse execute result for visualization */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Loading & Error states */
|
||||
/* ---------------------------------------------------------------- */
|
||||
@@ -268,325 +201,6 @@ export default function TicketDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const priorityInfo = PRIORITY_MAP[ticket.priority] || PRIORITY_MAP.medium;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Render helpers */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const renderActionButtons = () => {
|
||||
if (showRollbackConfirm) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-orange-300">确认回滚操作</p>
|
||||
<p className="text-xs text-orange-300/70">回滚操作不可逆,将撤销已执行的 SQL 变更</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAction('rollback')}
|
||||
disabled={!!actionLoading}
|
||||
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded-md text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === 'rollback' ? <Loader2 className="w-4 h-4 animate-spin" /> : '确认回滚'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRollbackConfirm(false)}
|
||||
className="px-3 py-1.5 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Approve / Reject */}
|
||||
{canApprove && (
|
||||
<>
|
||||
{!showCommentInput ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setPendingAction('approve'); setShowCommentInput(true); }}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" /> 审批通过
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPendingAction('reject'); setShowCommentInput(true); }}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all"
|
||||
>
|
||||
<XCircle className="w-4 h-4" /> 审批拒绝
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 w-full">
|
||||
<MessageSquare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
||||
placeholder={pendingAction === 'reject' ? '请输入拒绝原因...' : '请输入审批备注(可选)...'}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => pendingAction && handleAction(pendingAction)}
|
||||
disabled={!!actionLoading}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
pendingAction === 'reject'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{actionLoading === pendingAction ? <Loader2 className="w-4 h-4 animate-spin" /> : pendingAction === 'reject' ? '确认拒绝' : '确认通过'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCommentInput(false); setComment(''); setPendingAction(null); }}
|
||||
className="px-3 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Execute */}
|
||||
{canExecute && (
|
||||
<button
|
||||
onClick={() => handleAction('execute')}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-cyan-500/15 border border-cyan-500/30 rounded-lg text-sm font-medium text-cyan-400 hover:bg-cyan-500/25 transition-all"
|
||||
>
|
||||
{actionLoading === 'execute' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
执行
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Rollback */}
|
||||
{canRollback && (
|
||||
<button
|
||||
onClick={() => setShowRollbackConfirm(true)}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" /> 回滚
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={() => handleAction('cancel')}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all"
|
||||
>
|
||||
{actionLoading === 'cancel' ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
|
||||
取消工单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderApprovalFlow = () => {
|
||||
if (approvalSteps.length === 0) return null;
|
||||
|
||||
const actionLabel: Record<string, string> = {
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
pending: '待处理',
|
||||
skipped: '已跳过',
|
||||
};
|
||||
|
||||
const actionStyle: Record<string, { color: string; bg: string }> = {
|
||||
approved: { color: 'text-green-400', bg: 'bg-green-500/15' },
|
||||
rejected: { color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||
pending: { color: 'text-[var(--dg-text-muted)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
|
||||
};
|
||||
|
||||
const stepAccent: Record<string, string> = {
|
||||
'提交': 'text-blue-400',
|
||||
'审核': 'text-amber-400',
|
||||
'执行': 'text-cyan-400',
|
||||
};
|
||||
|
||||
const getAccent = (name: string) => {
|
||||
for (const key of Object.keys(stepAccent)) {
|
||||
if (name.includes(key)) return stepAccent[key];
|
||||
}
|
||||
return 'text-[var(--dg-text-secondary)]';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-4">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> 审批流程
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{approvalSteps.map((step, idx) => {
|
||||
const actCfg = actionStyle[step.action] || actionStyle.pending;
|
||||
const isCurrent = step.action === 'pending';
|
||||
const isDone = step.action === 'approved' || step.action === 'rejected';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id || idx}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg ${
|
||||
isCurrent
|
||||
? 'bg-blue-500/5 border border-blue-500/20'
|
||||
: 'bg-[var(--dg-bg-primary)]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full ${actCfg.bg} flex items-center justify-center shrink-0 mt-0.5`}>
|
||||
{isDone && step.action === 'approved' ? (
|
||||
<CheckCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
|
||||
) : isDone ? (
|
||||
<XCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
|
||||
) : (
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isCurrent ? 'bg-blue-400 animate-pulse' : 'bg-[var(--dg-text-muted)]'}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${getAccent(step.step_name)}`}>
|
||||
{step.step_name}
|
||||
</span>
|
||||
{step.approver_name && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
{step.approver_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${actCfg.bg} ${actCfg.color} shrink-0`}>
|
||||
{actionLabel[step.action] || step.action}
|
||||
</span>
|
||||
</div>
|
||||
{step.comment && step.comment !== '提交工单' && (
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-1">{step.comment}</p>
|
||||
)}
|
||||
{step.updated_at && isDone && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
{new Date(step.updated_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTimeline = () => {
|
||||
if (timeline.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-4">
|
||||
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
||||
</h3>
|
||||
<div className="relative pl-8">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
||||
|
||||
{timeline.map((entry, idx) => {
|
||||
const config = ACTION_CONFIG[entry.action] || {
|
||||
icon: CheckCircle,
|
||||
color: 'text-[var(--dg-text-muted)]',
|
||||
bg: 'bg-[var(--dg-bg-tertiary)]',
|
||||
};
|
||||
const Icon = config.icon;
|
||||
const isLast = idx === timeline.length - 1;
|
||||
|
||||
return (
|
||||
<div key={idx} className={`relative ${isLast ? '' : 'mb-6'}`}>
|
||||
{/* Icon node */}
|
||||
<div className={`absolute -left-8 top-0.5 w-6 h-6 rounded-full ${config.bg} border-2 border-[var(--dg-border)] flex items-center justify-center z-10`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
<div className="ml-2">
|
||||
{/* Label + User */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{entry.label}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{entry.user}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
{entry.time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(entry.time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail */}
|
||||
{entry.detail && (
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1.5 p-2.5 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border-light)]">
|
||||
{entry.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRelatedTickets = () => {
|
||||
if (relatedTickets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<button
|
||||
onClick={() => setShowRelated(!showRelated)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-blue-400" /> 关联工单
|
||||
</h3>
|
||||
{showRelated ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
|
||||
{showRelated && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{relatedTickets.map((rt) => (
|
||||
<button
|
||||
key={rt.id}
|
||||
onClick={() => navigate(`/tickets/${rt.id}`)}
|
||||
className="w-full flex items-center justify-between p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)] hover:border-blue-500/30 hover:bg-blue-500/5 transition-all text-left"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
TK-{rt.id} · {rt.title}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
由 {rt.applicant_name || '-'} 创建于 {new Date(rt.create_time).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColorMap[rt.status] || ''}`}>
|
||||
{statusMap[rt.status] || rt.status}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Main render */
|
||||
/* ---------------------------------------------------------------- */
|
||||
@@ -624,288 +238,50 @@ export default function TicketDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{renderActionButtons()}
|
||||
{/* 审批/执行操作面板 */}
|
||||
<DetailApprovalPanel
|
||||
canApprove={canApprove}
|
||||
canExecute={canExecute}
|
||||
canRollback={canRollback}
|
||||
canCancel={canCancel}
|
||||
actionLoading={actionLoading}
|
||||
commentVisible={showCommentInput}
|
||||
pendingAction={pendingAction}
|
||||
comment={comment}
|
||||
rollbackConfirmVisible={showRollbackConfirm}
|
||||
onAction={handleAction}
|
||||
onStartComment={(action) => { setPendingAction(action); setShowCommentInput(true); }}
|
||||
onCancelComment={() => { setShowCommentInput(false); setComment(''); setPendingAction(null); }}
|
||||
onCommentChange={setComment}
|
||||
onRequestRollback={() => setShowRollbackConfirm(true)}
|
||||
onCancelRollback={() => setShowRollbackConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info cards */}
|
||||
{/* Info cards: 工单信息 + SQL 内容 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Ticket Info */}
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||
<h3 className="text-[15px] font-semibold border-b border-[var(--dg-border)] pb-3">工单信息</h3>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ label: '工单号', value: `TK-${ticket.id}` },
|
||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||
{ label: '目标数据库', value: ticket.target_database, icon: Database },
|
||||
{ label: '申请人', value: ticket.applicant_name || '-' },
|
||||
{
|
||||
label: '创建时间',
|
||||
value: new Date(ticket.create_time).toLocaleString(),
|
||||
},
|
||||
ticket.review_time && {
|
||||
label: '审批时间',
|
||||
value: new Date(ticket.review_time).toLocaleString(),
|
||||
},
|
||||
ticket.execute_time && {
|
||||
label: '执行时间',
|
||||
value: new Date(ticket.execute_time).toLocaleString(),
|
||||
},
|
||||
{ label: '变更原因', value: ticket.reason || '-' },
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((item, idx) => {
|
||||
if (!item) return null;
|
||||
const ItemIcon = (item as { icon?: React.ElementType }).icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col sm:flex-row sm:justify-between gap-1 sm:gap-3 py-2 border-b border-[var(--dg-border-light)] last:border-0"
|
||||
>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
||||
{ItemIcon && <ItemIcon className="w-3.5 h-3.5" />}
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-sm font-medium break-words sm:text-right">{item.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SQL Content (SqlEditor read-only) */}
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<FileCode className="w-4 h-4 text-blue-400" /> SQL 内容
|
||||
</h3>
|
||||
<SqlEditor
|
||||
value={ticket.sql_content}
|
||||
onChange={() => { /* read-only */ }}
|
||||
language="sql"
|
||||
height="280px"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<DetailTicketInfoCard ticket={ticket} />
|
||||
<DetailSqlCard sqlContent={ticket.sql_content} />
|
||||
</div>
|
||||
|
||||
{/* Audit result */}
|
||||
{ticket.audit_result && (() => {
|
||||
let audit: any = null;
|
||||
try { audit = typeof ticket.audit_result === 'string' ? JSON.parse(ticket.audit_result) : ticket.audit_result; } catch { audit = null; }
|
||||
{/* SQL 审核结果 */}
|
||||
{ticket.audit_result && <DetailAuditResult auditResult={ticket.audit_result} />}
|
||||
|
||||
if (!audit || !audit.findings) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[var(--dg-text-secondary)]">{ticket.audit_result}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{/* 回滚 SQL */}
|
||||
{ticket.rollback_sql && <DetailRollbackSqlCard rollbackSql={ticket.rollback_sql} />}
|
||||
|
||||
const errorCount = audit.findings.filter((f: any) => f.level === 'error').length;
|
||||
const warningCount = audit.findings.filter((f: any) => f.level === 'warning').length;
|
||||
const infoCount = audit.findings.filter((f: any) => f.level === 'info').length;
|
||||
const scoreColor = audit.score >= 90 ? 'text-green-400' : audit.score >= 60 ? 'text-amber-400' : 'text-red-400';
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<button
|
||||
onClick={() => setShowFullAudit(!showFullAudit)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> SQL 审核结果
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xl font-bold ${scoreColor}`}>{audit.score ?? '-'}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">分</span>
|
||||
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
{audit.passed ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-green-500/15 text-green-400">
|
||||
<CheckCircle className="w-3 h-3" /> 审核通过
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-red-500/15 text-red-400">
|
||||
<XCircle className="w-3 h-3" /> 存在阻断项
|
||||
</span>
|
||||
)}
|
||||
{errorCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-red-500/15 text-red-400">{errorCount} 错误</span>}
|
||||
{warningCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-amber-500/15 text-amber-400">{warningCount} 警告</span>}
|
||||
{infoCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-blue-500/15 text-blue-400">{infoCount} 提示</span>}
|
||||
</div>
|
||||
|
||||
{/* Findings table */}
|
||||
{showFullAudit && audit.findings.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{audit.findings.map((f: any, idx: number) => {
|
||||
const levelConfig: Record<string, { label: string; color: string; icon: any }> = {
|
||||
error: { label: '错误', color: 'text-red-400 bg-red-500/15', icon: XCircle },
|
||||
warning: { label: '警告', color: 'text-amber-400 bg-amber-500/15', icon: AlertTriangle },
|
||||
info: { label: '提示', color: 'text-blue-400 bg-blue-500/15', icon: CheckCircle },
|
||||
};
|
||||
const lc = levelConfig[f.level] || levelConfig.info;
|
||||
const LevelIcon = lc.icon;
|
||||
return (
|
||||
<div key={idx} className={`p-3 rounded-lg border border-[var(--dg-border)] ${f.level === 'error' ? 'border-red-500/20' : ''}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LevelIcon className={`w-3.5 h-3.5 ${lc.color.split(' ')[0]}`} />
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${lc.color}`}>{lc.label}</span>
|
||||
{f.rule && <span className="text-xs text-[var(--dg-text-muted)] font-mono">{f.rule}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-primary)] pl-6">{f.message}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary text */}
|
||||
{!showFullAudit && audit.summary && (
|
||||
<p className="mt-2 text-xs text-[var(--dg-text-muted)]">{audit.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Rollback SQL */}
|
||||
{ticket.rollback_sql && (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<RotateCcw className="w-4 h-4 text-cyan-400" /> 回滚 SQL
|
||||
</h3>
|
||||
<SqlEditor
|
||||
value={ticket.rollback_sql}
|
||||
onChange={() => { /* read-only */ }}
|
||||
language="sql"
|
||||
height="120px"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
{/* 执行结果 */}
|
||||
{ticket.execute_result && (
|
||||
<DetailExecuteResult executeResult={ticket.execute_result} executeTime={ticket.execute_time} />
|
||||
)}
|
||||
|
||||
{/* Execute result */}
|
||||
{ticket.execute_result && (() => {
|
||||
let execResult: any = null;
|
||||
try { execResult = typeof ticket.execute_result === 'string' ? JSON.parse(ticket.execute_result) : ticket.execute_result; } catch { execResult = null; }
|
||||
|
||||
if (!execResult || typeof execResult !== 'object') {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
||||
</h3>
|
||||
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||
{ticket.execute_result}
|
||||
</pre>
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSuccess = execResult.status === 'success';
|
||||
const StatusIcon = isSuccess ? CheckCircle : XCircle;
|
||||
const statusColor = isSuccess ? 'text-green-400' : 'text-red-400';
|
||||
const statusBg = isSuccess ? 'bg-green-500/15' : 'bg-red-500/15';
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||
<Play className="w-4 h-4 text-cyan-400" /> 执行结果
|
||||
</h3>
|
||||
|
||||
{/* Status badge + key metrics */}
|
||||
<div className="flex items-center gap-3 flex-wrap mb-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium ${statusBg} ${statusColor}`}>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
{isSuccess ? '执行成功' : '执行失败'}
|
||||
</span>
|
||||
{execResult.affected_rows !== undefined && (
|
||||
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
|
||||
影响行数: <span className="font-medium">{execResult.affected_rows}</span>
|
||||
</span>
|
||||
)}
|
||||
{execResult.duration_ms !== undefined && (
|
||||
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
|
||||
耗时: <span className="font-medium">{execResult.duration_ms}ms</span>
|
||||
</span>
|
||||
)}
|
||||
{execResult.validation_passed !== undefined && (
|
||||
<span className={`px-2.5 py-1 rounded text-xs ${execResult.validation_passed ? 'text-green-400 bg-green-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
|
||||
{execResult.validation_passed ? '✓ 验证通过' : '✗ 验证未通过'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{execResult.errors && execResult.errors.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{execResult.errors.map((err: string, idx: number) => (
|
||||
<div key={idx} className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-xs font-medium text-red-400">错误 {execResult.errors.length > 1 ? `#${idx + 1}` : ''}</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-300 pl-5">{err}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{execResult.warnings && execResult.warnings.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{execResult.warnings.map((w: string, idx: number) => (
|
||||
<div key={idx} className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-400">警告 {execResult.warnings.length > 1 ? `#${idx + 1}` : ''}</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-300 pl-5">{w}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw output (if present) */}
|
||||
{execResult.raw_output && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">原始输出</p>
|
||||
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto border border-[var(--dg-border-light)]">
|
||||
{execResult.raw_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.execute_time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-3 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Approval Flow */}
|
||||
{renderApprovalFlow()}
|
||||
|
||||
{/* Timeline */}
|
||||
{renderTimeline()}
|
||||
|
||||
{/* Related tickets */}
|
||||
{renderRelatedTickets()}
|
||||
{/* 审批流程 / 时间线 / 关联工单 */}
|
||||
<DetailApprovalFlow steps={approvalSteps} />
|
||||
<DetailTimeline timeline={timeline} />
|
||||
<DetailRelatedTickets
|
||||
tickets={relatedTickets}
|
||||
onSelect={(id) => navigate(`/tickets/${id}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+78
-1148
File diff suppressed because it is too large.
Load diff
@@ -1,494 +1,16 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Plus, Edit2, Eye, ToggleLeft, ToggleRight, GitBranch, Users, CheckCircle,
|
||||
ArrowRight, Shield, Trash2, X, ChevronDown, ChevronUp,
|
||||
Save, Play, Copy, Loader2, Star, ArrowUp, ArrowDown, UserPlus,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, GitBranch, Loader2 } from 'lucide-react';
|
||||
import { workflowApi } from '@/api/workflow';
|
||||
import { userApi } from '@/api/user';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import type { UserItem, WorkflowDetail } from '@/features/governance/workflow/types';
|
||||
import { WorkflowCard } from '@/features/governance/workflow/components/WorkflowCard';
|
||||
import { WorkflowStatsGrid } from '@/features/governance/workflow/components/WorkflowStatsGrid';
|
||||
import { WorkflowPreviewModal } from '@/features/governance/workflow/components/WorkflowPreviewModal';
|
||||
import { WorkflowEditorModal } from '@/features/governance/workflow/components/WorkflowEditorModal';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface WorkflowStep {
|
||||
id?: number;
|
||||
workflow_id?: number;
|
||||
step_number: number;
|
||||
name: string;
|
||||
step_type: string;
|
||||
approver_role: string | null;
|
||||
approver_ids: string | null;
|
||||
is_required: boolean;
|
||||
timeout_hours: number;
|
||||
auto_approve_on_timeout: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string | null;
|
||||
steps: WorkflowStep[];
|
||||
}
|
||||
|
||||
interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
role: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface StepDraft {
|
||||
name: string;
|
||||
step_type: 'submit' | 'audit' | 'execute';
|
||||
approver_ids: number[];
|
||||
}
|
||||
|
||||
const STEP_TYPE_LABELS: Record<string, string> = {
|
||||
submit: '提交阶段',
|
||||
serial: '串行审批',
|
||||
parallel: '并行审批',
|
||||
any_one: '任一审批',
|
||||
audit: '审核阶段',
|
||||
execute: '执行阶段',
|
||||
};
|
||||
|
||||
const STEP_TYPE_COLORS: Record<string, string> = {
|
||||
submit: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
serial: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
||||
parallel: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
any_one: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
audit: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
||||
execute: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
};
|
||||
|
||||
const STEP_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
submit: Users,
|
||||
serial: Shield,
|
||||
parallel: Users,
|
||||
any_one: CheckCircle,
|
||||
audit: Shield,
|
||||
execute: Play,
|
||||
};
|
||||
|
||||
const STEP_TYPES = [
|
||||
{ value: 'submit' as const, label: '提交阶段', desc: '工单提交人' },
|
||||
{ value: 'audit' as const, label: '审核阶段', desc: '审核人员审批' },
|
||||
{ value: 'execute' as const, label: '执行阶段', desc: '执行人员操作' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* UserDropdown — 下拉多选用户 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface UserDropdownProps {
|
||||
users: UserItem[];
|
||||
selectedIds: number[];
|
||||
label: string;
|
||||
onToggle: (uid: number) => void;
|
||||
onToggleAll: () => void;
|
||||
onClear: () => void;
|
||||
getUserName: (uid: number) => string;
|
||||
}
|
||||
|
||||
function UserDropdown({ users, selectedIds, label, onToggle, onToggleAll, onClear, getUserName }: UserDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const ids = Array.isArray(selectedIds) ? selectedIds : [];
|
||||
const allSelected = ids.length === users.length && users.length > 0;
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
|
||||
const updatePos = () => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!open) updatePos();
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Trigger */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedIds.length === 0
|
||||
? 'bg-[var(--dg-bg-tertiary)] border border-red-500/40 text-[var(--dg-text-muted)]'
|
||||
: 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{ids.length === 0
|
||||
? `${label} * 请选择`
|
||||
: ids.length === 1
|
||||
? getUserName(ids[0])
|
||||
: `${label} (${ids.length}人)`
|
||||
}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Selected tags */}
|
||||
{ids.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{ids.map((uid) => (
|
||||
<span
|
||||
key={uid}
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 text-blue-400"
|
||||
>
|
||||
{getUserName(uid)}
|
||||
<button onClick={() => onToggle(uid)} className="hover:text-red-400 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown — fixed positioning to escape overflow clipping */}
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="fixed z-50 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl max-h-52 overflow-y-auto"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width }}
|
||||
>
|
||||
{/* Select all / Clear */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--dg-border)]">
|
||||
<button
|
||||
onClick={onToggleAll}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
{selectedIds.length > 0 && (
|
||||
<button onClick={onClear} className="text-[11px] text-red-400 hover:text-red-300 transition-colors">
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* User list */}
|
||||
{users.map((u) => {
|
||||
const checked = selectedIds.includes(u.id);
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
onClick={() => onToggle(u.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
checked
|
||||
? 'bg-blue-500 border-blue-500'
|
||||
: 'border-[var(--dg-border)]'
|
||||
}`}>
|
||||
{checked && <CheckCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className={`text-sm flex-1 ${checked ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-secondary)]'}`}>
|
||||
{u.display_name || u.username}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">
|
||||
{u.role}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WorkflowEditorModal — 创建/编辑流程弹框 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface EditorProps {
|
||||
workflow?: WorkflowDetail | null;
|
||||
users: UserItem[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps) {
|
||||
const isEdit = !!workflow;
|
||||
const [name, setName] = useState(workflow?.name || '');
|
||||
const [description, setDescription] = useState(workflow?.description || '');
|
||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 初始化步骤
|
||||
useEffect(() => {
|
||||
if (workflow?.steps?.length) {
|
||||
setSteps(
|
||||
workflow.steps.map((s) => ({
|
||||
name: s.name,
|
||||
step_type: (s.step_type === 'serial' || s.step_type === 'parallel' || s.step_type === 'any_one')
|
||||
? 'audit' as const
|
||||
: s.step_type as 'submit' | 'audit' | 'execute',
|
||||
approver_ids: Array.isArray(s.approver_ids) ? s.approver_ids : (s.approver_ids ? ((): number[] => { try { const p = JSON.parse(s.approver_ids as string); return Array.isArray(p) ? p : []; } catch { return []; } })() : []),
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
// 新建默认:提交→执行
|
||||
setSteps([
|
||||
{ name: '提交阶段', step_type: 'submit', approver_ids: [] },
|
||||
{ name: '执行阶段', step_type: 'execute', approver_ids: [] },
|
||||
]);
|
||||
}
|
||||
}, [workflow]);
|
||||
|
||||
const addStep = (type: 'submit' | 'audit' | 'execute') => {
|
||||
const labels: Record<string, string> = { submit: '提交阶段', audit: '审核阶段', execute: '执行阶段' };
|
||||
const count = steps.filter((s) => s.step_type === type).length;
|
||||
const suffix = count > 0 ? ` ${count + 1}` : '';
|
||||
setSteps([...steps, { name: `${labels[type]}${suffix}`, step_type: type, approver_ids: [] }]);
|
||||
};
|
||||
|
||||
const removeStep = (idx: number) => setSteps(steps.filter((_, i) => i !== idx));
|
||||
|
||||
const moveStep = (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= steps.length) return;
|
||||
const next = [...steps];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
setSteps(next);
|
||||
};
|
||||
|
||||
const updateStep = (idx: number, patch: Partial<StepDraft>) => {
|
||||
setSteps(steps.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
|
||||
};
|
||||
|
||||
const toggleUser = (idx: number, uid: number) => {
|
||||
const step = steps[idx];
|
||||
const has = step.approver_ids.includes(uid);
|
||||
updateStep(idx, {
|
||||
approver_ids: has ? step.approver_ids.filter((id) => id !== uid) : [...step.approver_ids, uid],
|
||||
});
|
||||
};
|
||||
|
||||
const getEligibleUsers = (stepType: string) => {
|
||||
const active = users.filter(u => u.is_active);
|
||||
if (stepType === 'audit') return active.filter(u => ['admin', 'superadmin', 'dba', 'developer', 'auditor'].includes(u.role));
|
||||
if (stepType === 'execute') return active.filter(u => ['admin', 'superadmin', 'dba'].includes(u.role));
|
||||
return active;
|
||||
};
|
||||
|
||||
const toggleAllUsers = (idx: number) => {
|
||||
const step = steps[idx];
|
||||
const eligible = getEligibleUsers(step.step_type);
|
||||
const allSelected = step.approver_ids.length === eligible.length && eligible.length > 0;
|
||||
if (allSelected) {
|
||||
updateStep(idx, { approver_ids: [] });
|
||||
} else {
|
||||
updateStep(idx, { approver_ids: eligible.map((u) => u.id) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { setError('请输入流程名称'); return; }
|
||||
if (steps.length === 0) { setError('请至少添加一个步骤'); return; }
|
||||
// 验证非 submit 步骤至少有一个人员
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (steps[i].step_type !== 'submit' && steps[i].approver_ids.length === 0) {
|
||||
setError(`步骤"${steps[i].name}"需要关联至少一个人员`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
steps: steps.map((s, i) => ({
|
||||
step_number: i + 1,
|
||||
name: s.name,
|
||||
step_type: s.step_type,
|
||||
approver_ids: s.approver_ids.length > 0 ? JSON.stringify(s.approver_ids) : null,
|
||||
is_required: true,
|
||||
timeout_hours: 24,
|
||||
auto_approve_on_timeout: false,
|
||||
})),
|
||||
};
|
||||
|
||||
if (isEdit && workflow) {
|
||||
await workflowApi.update(workflow.id, payload);
|
||||
} else {
|
||||
await workflowApi.create(payload);
|
||||
}
|
||||
onSaved();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserName = (uid: number) => {
|
||||
const u = users.find((u) => u.id === uid);
|
||||
return u ? (u.display_name || u.username) : `#${uid}`;
|
||||
};
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors';
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-2xl">
|
||||
<div
|
||||
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 sm:px-5 py-4">
|
||||
<h2 className="text-base font-semibold">{isEdit ? '编辑审批流程' : '新建审批流程'}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 sm:px-5 pb-5 space-y-4">
|
||||
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">流程名称 *</label>
|
||||
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} placeholder="输入流程名称" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">描述</label>
|
||||
<input className={inputCls} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
|
||||
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">流程步骤</label>
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1 sm:pb-0">
|
||||
{STEP_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => addStep(t.value)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => {
|
||||
const typeConf = STEP_TYPES.find((t) => t.value === step.step_type)!;
|
||||
const colorCls = STEP_TYPE_COLORS[step.step_type];
|
||||
const Icon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
return (
|
||||
<div key={idx} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
{/* Step header */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap sm:flex-nowrap">
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
|
||||
</div>
|
||||
<input
|
||||
className="flex-1 min-w-[160px] px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={step.name}
|
||||
onChange={(e) => updateStep(idx, { name: e.target.value })}
|
||||
placeholder="步骤名称"
|
||||
/>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
|
||||
{typeConf.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 sm:ml-1">
|
||||
<button
|
||||
onClick={() => moveStep(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors"
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveStep(idx, 1)}
|
||||
disabled={idx === steps.length - 1}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors"
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeStep(idx)}
|
||||
className="p-1 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User selection — submit 步骤不需要选人 */}
|
||||
{step.step_type !== 'submit' && (
|
||||
<div className="sm:pl-9 mt-2">
|
||||
<UserDropdown
|
||||
users={getEligibleUsers(step.step_type)}
|
||||
selectedIds={step.approver_ids}
|
||||
label={step.step_type === 'audit' ? '审核人员' : '执行人员'}
|
||||
onToggle={(uid) => toggleUser(idx, uid)}
|
||||
onToggleAll={() => toggleAllUsers(idx)}
|
||||
onClear={() => updateStep(idx, { approver_ids: [] })}
|
||||
getUserName={getUserName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{steps.length === 0 && (
|
||||
<div className="text-center py-4 text-sm text-[var(--dg-text-muted)]">
|
||||
请点击上方按钮添加流程步骤
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end gap-3 px-4 sm:px-5 py-4">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isEdit ? '保存' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main Page */
|
||||
/* Main Page — 审批流程配置页(数据获取与组件编排) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function WorkflowPage() {
|
||||
@@ -531,7 +53,7 @@ export default function WorkflowPage() {
|
||||
const toggleWorkflow = useCallback(async (wf: WorkflowDetail) => {
|
||||
try {
|
||||
await workflowApi.update(wf.id, { is_active: !wf.is_active });
|
||||
setItems((prev) => prev.map((w) => (w.id === wf.id ? { ...w, is_active: !w.is_active } : w)));
|
||||
setItems((prev) => prev.map((w) => (w.id === wf.id ? { ...w, is_active: !wf.is_active } : w)));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
@@ -617,217 +139,36 @@ export default function WorkflowPage() {
|
||||
) : (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '流程总数', value: items.length, icon: GitBranch, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '已启用', value: items.filter((w) => w.is_active).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '默认流程', value: items.filter((w) => w.is_default).length, icon: Star, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
||||
{ label: '总步骤数', value: items.reduce((acc, w) => acc + (w.steps?.length || 0), 0), icon: Users, color: 'text-cyan-400', bg: 'bg-cyan-400/10' },
|
||||
].map((s, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
||||
<s.icon className={`w-5 h-5 ${s.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{s.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<WorkflowStatsGrid workflows={items} />
|
||||
|
||||
{/* Workflow list */}
|
||||
<div className="space-y-4">
|
||||
{items.map((wf) => {
|
||||
const isExpanded = expandedId === wf.id;
|
||||
return (
|
||||
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h3 className="text-lg font-semibold">{wf.name}</h3>
|
||||
{wf.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-yellow-500/10 text-yellow-400 text-xs rounded">
|
||||
<Star className="w-3 h-3" /> 默认
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${wf.is_active ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||
{wf.is_active ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-xs rounded">
|
||||
{(wf.steps || []).length} 个阶段
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{wf.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-3">{wf.description}</p>}
|
||||
|
||||
{/* Flow visualization */}
|
||||
<div className="flex items-center gap-1 mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg overflow-x-auto">
|
||||
{(wf.steps || []).map((step, i) => {
|
||||
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
let uids: number[] = [];
|
||||
try { const parsed = step.approver_ids ? JSON.parse(step.approver_ids) : null; if (Array.isArray(parsed)) uids = parsed; } catch {}
|
||||
const stepsArr = wf.steps || [];
|
||||
return (
|
||||
<div key={step.id || i} className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className={`px-3 py-2 rounded-lg border ${STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial} flex items-center gap-2`}>
|
||||
<StepIcon className="w-4 h-4" />
|
||||
<div>
|
||||
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
|
||||
{uids.length > 0 && (
|
||||
<div className="text-[10px] opacity-80 whitespace-nowrap">
|
||||
{uids.map((uid) => getUserName(uid)).join('、')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{i < stepsArr.length - 1 && <ArrowRight className="w-4 h-4 text-[var(--dg-text-muted)] flex-shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-3 pt-4 border-t border-[var(--dg-border)]">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<GitBranch className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
流程步骤详情
|
||||
</h4>
|
||||
{(wf.steps || []).map((step, si) => {
|
||||
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
|
||||
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
|
||||
return (
|
||||
<div key={step.id || si} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
|
||||
<StepIcon className={`w-4 h-4 ${colorClass.split(' ')[1]}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-sm font-medium">{step.name}</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded ${colorClass}`}>
|
||||
{STEP_TYPE_LABELS[step.step_type] || step.step_type}
|
||||
</span>
|
||||
</div>
|
||||
{uids.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">关联人员:</span>
|
||||
{uids.map((uid) => (
|
||||
<span key={uid} className="text-[11px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
|
||||
{getUserName(uid)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 lg:ml-4 shrink-0 flex-wrap">
|
||||
<button
|
||||
onClick={() => setEditorWorkflow(wf)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{!wf.is_default && (
|
||||
<button onClick={() => setDefault(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-yellow-500/10 hover:text-yellow-400 transition-colors" title="设为默认">
|
||||
<Star className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setPreviewWorkflow(wf)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors" title="预览">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleClone(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors" title="克隆">
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => toggleWorkflow(wf)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={wf.is_active ? '禁用' : '启用'}>
|
||||
{wf.is_active ? <ToggleRight className="w-5 h-5 text-green-400" /> : <ToggleLeft className="w-5 h-5 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setExpandedId(isExpanded ? null : wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="展开">
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{items.map((wf) => (
|
||||
<WorkflowCard
|
||||
key={wf.id}
|
||||
workflow={wf}
|
||||
isExpanded={expandedId === wf.id}
|
||||
getUserName={getUserName}
|
||||
onEdit={setEditorWorkflow}
|
||||
onPreview={setPreviewWorkflow}
|
||||
onClone={handleClone}
|
||||
onSetDefault={setDefault}
|
||||
onToggleActive={toggleWorkflow}
|
||||
onDelete={handleDelete}
|
||||
onToggleExpand={setExpandedId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{previewWorkflow && (
|
||||
<ModalOverlay open={!!previewWorkflow} onClose={() => setPreviewWorkflow(null)} width="max-w-2xl">
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-4 sm:p-6 w-full mx-2 sm:mx-4 max-h-[90vh] sm:max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex items-start sm:items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold">{previewWorkflow.name}</h3>
|
||||
{previewWorkflow.is_default && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-yellow-500/10 text-yellow-400 text-xs rounded">
|
||||
<Star className="w-3 h-3" /> 默认
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setPreviewWorkflow(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{previewWorkflow.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-4">{previewWorkflow.description}</p>}
|
||||
<div className="space-y-4">
|
||||
{previewWorkflow.steps.map((step, i) => {
|
||||
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
|
||||
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
|
||||
return (
|
||||
<div key={step.id || i} className="flex items-start gap-3 sm:gap-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
|
||||
<StepIcon className={`w-5 h-5 ${colorClass.split(' ')[1]}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{step.name}</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded ${colorClass}`}>
|
||||
{STEP_TYPE_LABELS[step.step_type] || step.step_type}
|
||||
</span>
|
||||
</div>
|
||||
{uids.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">关联人员:</span>
|
||||
{uids.map((uid) => (
|
||||
<span key={uid} className="text-[11px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
|
||||
{getUserName(uid)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{i < previewWorkflow.steps.length - 1 && <ArrowRight className="hidden sm:block w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<button onClick={() => setPreviewWorkflow(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
<WorkflowPreviewModal
|
||||
workflow={previewWorkflow}
|
||||
getUserName={getUserName}
|
||||
onClose={() => setPreviewWorkflow(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Editor Modal */}
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** 构建指纹:版本+commit+时间(vite define 注入,SettingsPage「关于」展示) */
|
||||
declare const __APP_VERSION__: string
|
||||
@@ -31,4 +31,5 @@
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
, "exclude": ["src/**/*.test.*"]
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
import { execSync } from "child_process"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// 构建指纹:版本 + commit hash + 构建时间(设置页「关于」展示,排障救命)
|
||||
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, "package.json"), "utf-8"))
|
||||
const gitHash = (() => {
|
||||
try { return execSync("git rev-parse --short HEAD").toString().trim() } catch { return "unknown" }
|
||||
})()
|
||||
const buildTime = new Date().toISOString()
|
||||
const APP_VERSION = `${pkg.version}+${gitHash} (${buildTime.slice(0, 16)})`
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
||||
},
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@@ -19,6 +32,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:9000',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import path from "path"
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user