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:
Logan.Li committed 2026-10-26 17:54:15 +08:00
1 parent ac563d1c5e
commit ac498c7ba2
92 files changed
+8224 -6146

No files matched your search

@@ -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 };
}
+40
View File
@@ -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;
}
+120
View File
@@ -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>
);
}
+43
View File
@@ -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 等只读操作;写操作请提交变更工单。',
};
+49
View File
@@ -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>
);
}
+52
View File
@@ -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 };
}
+30
View File
@@ -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;
}
+44
View File
@@ -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>
);
}
+52
View File
@@ -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'];
+92
View File
@@ -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,
};
}
+21
View File
@@ -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;
}
}
+12 -46
View File
@@ -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;
+1 -6
View File
@@ -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();
+59
View File
@@ -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();
});
});
+64
View File
@@ -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;
}
+10 -9
View File
@@ -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"
>
File diff suppressed because it is too large. Load diff
+129 -596
View File
@@ -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}
/>
)}
{/* ── 删除确认弹框 ── */}
+91 -496
View File
@@ -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">
+5
View File
@@ -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>
);
}
+52 -676
View File
@@ -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
File diff suppressed because it is too large. Load diff
+30 -689
View File
@@ -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 */}
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
/** 构建指纹:版本+commit+时间(vite define 注入,SettingsPage「关于」展示) */
declare const __APP_VERSION__: string