refactor(ui): unify all dialogs on radix base per guide ch.6/7

- base dialog: z raised to 300/301 (was below sidebar z-200 - dialogs
  appeared sunken under navigation), overlay bg-black/45 + 2px blur,
  unified close button with hover/focus-visible states, footer pt-4
- FormDialog normalized: width presets sm/md/lg/xl + optional
  description, single scrollable body (header/footer fixed), unified
  top-right close from base
- ModalOverlay rewritten on radix kernel, same props (+title/noScroll):
  portal/focus trap/scroll lock/ESC with proper nesting (fixes
  chain-close when ticket detail opens quick-review), 200ms animation,
  z-300 layering - was a bare div with no a11y, no animation, ESC
  via document listener that leaked to nested dialogs
- killed box-in-box: all 14 ModalOverlay callers stripped their inner
  card wrapper (double border/shadow/radius/padding gone) and duplicate
  scroll containers; EditInstanceDialog (4th standalone system) migrated
  to ModalOverlay, gains ESC/animation/portal
- visual unification per guide: dialog title text-base semibold
  (was 4 sizes), close button one form (was 5), footer buttons on
  dg-btn system (secondary/primary/danger, was 4 systems), primary
  color var(--dg-blue) everywhere (yellow/emerald/blue-500/600 mixed
  before), widths 4 presets (was 7 arbitrary values)
- dead code removed: 3 orphaned LDAP dialog components, leftover
  create-OU state/handler in BatchImportModal, unused icon imports

verified in browser: users create dialog shows single card (0 inner
cards), 12px radius, z-301/300, overlay blur, sidebar correctly
covered; tsc/build/audit gates green
This commit is contained in:
Logan.Li committed 2026-10-27 16:35:32 +08:00
1 parent 36724bba94
commit 48c8fddbe9
20 files changed
+1645 -1258

No files matched your search

@@ -1,21 +1,29 @@
# LDAP:借鉴 aiops 的 AD 兼容与配置门控 + 连不上根因说明
# 统一全部弹框(对照指南 06/07 章)
研究结论:LDAP 参数本身没问题(本机对 8.217.205.92:389 实测连接/导入/登录全通,aiops 种子配置与 opsxsql 同源)。「连不了」的根因在**你正在跑的旧后端实例**:① 其 sqlite 只读(实测报 `attempt to write a readonly database`,LDAP 配置根本存不进去);② 旧二进制还有 CORS 白名单 bug(非 5173 端口的前端所有写请求空 403)。需重启到新构建后端+可写 DB。本次按 aiops 补两块真差距:
探索结论:「奇特」的可解释根因——① ModalOverlay 系 14 处全部「弹窗里再套一张卡片」(双边框/双阴影/双圆角/双倍内边距);② 双滚动条;③ **侧栏/顶栏 z-200 浮在弹窗 z-100 之上**(弹窗像陷进布局里、导航不被遮罩仍可点);④ 关闭钮 5 种形态、footer 按钮 4 套体系、主色 5 种、标题字号 4 档、宽度 7 档;⑤ ModalOverlay 系无动画/无 ESC 之外的可达性;⑥ TicketDetailDialog 内嵌弹窗 ESC 会连锁关闭外框;⑦ EditInstanceDialog 独立第四体系(无 ESC/动画/portal);⑧ LDAP 三弹窗疑似孤儿 + BatchImportModal 残留死代码。
## 1. 后端借鉴 aiops(controller/ldap.go)——AD/OpenLDAP 双兼容
opsxsql 只支持 OpenLDAP(objectClass=inetOrgPerson + uid),AD 域(sAMAccountName)会搜不到。移植 aiops 的:
- **多轮过滤器**(ldapUserFilter/ldapADUserFilter/ldapUserListFilters):配置过滤器优先 → `(objectClass=inetOrgPerson)` → AD `(&(objectClass=user)(|(sAMAccountName=*)...))` 兜底;登录时 OpenLDAP 过滤器 + AD 过滤器两轮尝试
- **属性回退链**(ldapEntryAttr):uid→sAMAccountName→userPrincipalName、cn→displayName、mail、telephoneNumber→mobile,映射解析按回退取值
- 应用于 tryLDAPAuth(handler/auth.go)、ListLDAPUsers、BatchImportLDAP
- 保留 opsxsql 的 AES 密码加密(aiops 是明文,不倒退)
## 1. 基座统一(改 3 个基础组件,全站生效)
- `ui/dialog.tsx`:遮罩/面板 z-index 提到 **300/301**(压过侧栏 200,指南 modal=300 档);遮罩 `bg-black/45 + backdrop-blur-[2px]`;保留 fade+zoom 200ms 动画
- `FormDialog` 规范化:统一头(标题 text-base font-semibold + 可选描述)+ **右上角统一关闭钮**(absolute X 16px,替换现在 5 种形态)、内容区单滚动(flex-col,header/footer 固定,max-h-85vh)、footer 右对齐 `gap-2 pt-4`;类名用 cn() 合并
- `ConfirmDialog`:标题加 font-semibold,与 FormDialog 对齐(shadcn Button 保留)
## 2. 前端配置门控(aiops `{ldapConfig && <Section/>}` 模式)
- **LdapConfigPage**:GetLDAPConfig 返回 null(未配置)时,隐藏「LDAP 账号管理」(LdapManagePanel/LdapUserTable/建 OU/建用户/账号测试),显示"未配置,先保存基础连接"引导态;保存成功后管理区出现
- **BatchImportModal**(UsersPage):未配置 LDAP 时隐藏「导入到 LDAP / 从 LDAP 导入」两个 tab,仅保留本地 CSV 导入
## 2. ModalOverlay 重写为 Radix 内核(同 props,14 处零改调用签名)
`open/onClose/width/className` 接口不变,内部改渲染 Radix Dialog → 免费获得 portal/焦点陷阱/滚动锁定/ESC(含嵌套正确分层,修复连锁关闭)/200ms 动画/z-300;面板策略改「header 固定 + 内容区滚动」。**修 ESC 连锁关闭 bug**
## 3. 实测验证(测试后端,独立 DB)
- 全新库未配置态:LdapConfigPage 管理区隐藏 + 导入弹窗无 LDAP tab
- 写入配置(8.217.205.92:389/dc=opsx,dc=vip/admin):管理区出现 → OU(devops/test)/用户列表(OpenLDAP 过滤器)→ 批量导入 → logan LDAP 登录复测
- 全量门禁(tsc/build/28 测试)后提交
## 3. 消灭「框中框」与双滚动(14 + 1 处调用点机械清理)
- 各 ModalOverlay 调用点删除内层 `bg/border/rounded/shadow/p-6` 卡片包装,内容直挂(保留各自布局)
- 删除内层第二个 `max-h/overflow-y-auto`(BatchImportModal/TicketDetailDialog/InstanceGroupPage/UserDetailModal)→ 单滚动条
- `EditInstanceDialog` 迁到 ModalOverlay(同体系,获得 ESC/动画/portal)
不改 aiops 项目;测试数据仍在独立临时库,结束清理。
## 4. 视觉统一(按指南 06 数值层)
- 弹窗圆角统一 rounded-xl(12px);阴影单层 shadow-2xl、去边框(描边/阴影二选一)
- 标题统一 text-base font-semibold;关闭钮统一形态
- footer 按钮统一 **dg-btn 体系**(`dg-btn-sm dg-btn-secondary` 取消 / `dg-btn-primary` 主操作 / `dg-btn-danger` 危险),替换裸 button 四种写法;主色统一 var(--dg-blue)(ResetPasswordModal 的 yellow-500、LDAP 弹窗的 emerald-500 一并收敛);宽度归档 sm/md/lg/xl 四档(内容宽度语义保留,消除随意值)
## 5. 死代码清理
- 验证 LdapCreateOuDialog/LdapAddUserDialog/LdapAccountTestDialog 无引用后删除;BatchImportModal 残留的建 OU 嵌套状态/函数清除
## 6. 验证
- tsc/build/audit:pages 全绿
- 浏览器实测 6 类弹框:用户新建/编辑(rbac)、批量导入、实例编辑(EditInstanceDialog)、工单详情+内嵌快捷审批(验证 ESC 不再连锁、z-index 压过侧栏)、Redis 行编辑、各页删除确认——确认单卡片/单滚动/统一关闭钮与按钮/动画一致
- 提交并推送远程
+145 -57
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import { X, Loader2, Zap, CheckCircle, XCircle } from 'lucide-react';
import { Loader2, Zap, CheckCircle, XCircle } from 'lucide-react';
import { ModalOverlay } from '@/components/ModalOverlay';
import { instanceApi } from '@/api/instance';
import { workflowApi } from '@/api/workflow';
@@ -59,13 +60,20 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
// excluded_databases: comma-separated input
const [excludedDbs, setExcludedDbs] = useState(
Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '')
Array.isArray(instance.excluded_databases)
? (instance.excluded_databases as string[]).join(', ')
: instance.excluded_databases || '',
);
// mask_fields: comma-separated field names
const [maskFields, setMaskFields] = useState(instance.mask_fields || '');
const [submitting, setSubmitting] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
version?: string;
cluster_mode?: boolean;
} | null>(null);
const [error, setError] = useState('');
/* Sync when instance changes */
@@ -81,7 +89,9 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
setClusterMode(toBool(instance.cluster_mode));
setWorkflowId(instance.workflow_id || null);
setExcludedDbs(
Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '')
Array.isArray(instance.excluded_databases)
? (instance.excluded_databases as string[]).join(', ')
: instance.excluded_databases || '',
);
setMaskFields(instance.mask_fields || '');
setTestResult(null);
@@ -91,20 +101,33 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
/* Load workflows when modal opens */
useEffect(() => {
if (open) {
workflowApi.list().then((data: any) => {
const items = Array.isArray(data) ? data : (data?.items || []);
setWorkflows(items.filter((w: WorkflowItem) => w.is_active !== false));
}).catch(() => setWorkflows([]));
workflowApi
.list()
.then((data: any) => {
const items = Array.isArray(data) ? data : data?.items || [];
setWorkflows(items.filter((w: WorkflowItem) => w.is_active !== false));
})
.catch(() => setWorkflows([]));
}
}, [open]);
const handleSubmit = useCallback(async () => {
if (!name.trim()) { setError('请填写名称'); return; }
if (!host.trim()) { setError('请填写地址'); return; }
setSubmitting(true); setError('');
if (!name.trim()) {
setError('请填写名称');
return;
}
if (!host.trim()) {
setError('请填写地址');
return;
}
setSubmitting(true);
setError('');
try {
const parsedExcluded = excludedDbs.trim()
? excludedDbs.split(',').map(s => s.trim()).filter(Boolean)
? excludedDbs
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const payload: Record<string, unknown> = {
@@ -140,7 +163,23 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
} finally {
setSubmitting(false);
}
}, [name, host, port, username, password, database, env, accessMode, clusterMode, workflowId, excludedDbs, maskFields, instance.id, instance.db_type, onSaved]);
}, [
name,
host,
port,
username,
password,
database,
env,
accessMode,
clusterMode,
workflowId,
excludedDbs,
maskFields,
instance.id,
instance.db_type,
onSaved,
]);
const handleTest = useCallback(async () => {
setTesting(true);
@@ -156,18 +195,20 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
database: database.trim() || undefined,
cluster_mode: instance.db_type === 'redis' ? clusterMode : undefined,
};
const res = mode === 'edit' && !password
? await instanceApi.testConnection(instance.id)
: await instanceApi.testNewConnection(payload);
const res =
mode === 'edit' && !password
? await instanceApi.testConnection(instance.id)
: await instanceApi.testNewConnection(payload);
const result = res as { success: boolean; message: string; version?: string; cluster_mode?: boolean };
if (instance.db_type === 'redis' && result.success) {
setClusterMode(toBool(result.cluster_mode));
}
setTestResult(result);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail
|| (e as { response?: { data?: { message?: string } } })?.response?.data?.message
|| '连接失败';
const msg =
(e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail ||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
'连接失败';
setTestResult({ success: false, message: msg });
} finally {
setTesting(false);
@@ -176,43 +217,43 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
if (!open) return null;
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';
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';
const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1';
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40">
<div
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<div>
<h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{instance.db_type.toUpperCase()} · {instance.host}:{instance.port}</p>
</div>
<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>
<ModalOverlay open={true} onClose={onClose} width="md">
<div className="w-full">
{/* Header(关闭钮由弹窗基座右上角提供) */}
<div className="pt-1 pb-3">
<h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
<p className="mt-1 text-xs text-[var(--dg-text-muted)]">
{instance.db_type.toUpperCase()} · {instance.host}:{instance.port}
</p>
</div>
{/* Body */}
<div className="px-5 pb-5 space-y-4">
<div className="space-y-4 pb-4">
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
<div>
<label className={labelCls}>名称</label>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} placeholder="实例名称" />
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} placeholder="实例名称" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className={labelCls}>地址</label>
<input className={inputCls} value={host} onChange={e => setHost(e.target.value)} placeholder="127.0.0.1" />
<input
className={inputCls}
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="127.0.0.1"
/>
</div>
<div>
<label className={labelCls}>端口</label>
<input className={inputCls} value={port} onChange={e => setPort(e.target.value)} placeholder="3306" />
<input className={inputCls} value={port} onChange={(e) => setPort(e.target.value)} placeholder="3306" />
</div>
</div>
@@ -220,19 +261,37 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
<div>
<label className={labelCls}>
用户名
{instance.db_type === 'redis' && <span className="font-normal text-[var(--dg-text-muted)]">(Redis 6.0+ ACL 可选)</span>}
{instance.db_type === 'redis' && (
<span className="font-normal text-[var(--dg-text-muted)]">(Redis 6.0+ ACL 可选)</span>
)}
</label>
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} placeholder={instance.db_type === 'redis' ? '可选,留空则仅密码认证' : '用户名'} />
<input
className={inputCls}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={instance.db_type === 'redis' ? '可选,留空则仅密码认证' : '用户名'}
/>
</div>
<div>
<label className={labelCls}>{mode === 'clone' ? '密码' : '密码(留空不修改)'}</label>
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={mode === 'edit' ? '不修改请留空' : '••••••'} />
<input
className={inputCls}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === 'edit' ? '不修改请留空' : '••••••'}
/>
</div>
</div>
<div>
<label className={labelCls}>默认数据库</label>
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder={instance.db_type === 'redis' ? 'Redis 数据库编号,默认 0' : '可选'} />
<input
className={inputCls}
value={database}
onChange={(e) => setDatabase(e.target.value)}
placeholder={instance.db_type === 'redis' ? 'Redis 数据库编号,默认 0' : '可选'}
/>
</div>
{instance.db_type === 'redis' && (
@@ -242,11 +301,14 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
{[
{ value: false, label: '单节点', desc: '普通 Redis 或哨兵入口' },
{ value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由' },
].map(item => (
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => { setClusterMode(item.value); setTestResult(null); }}
onClick={() => {
setClusterMode(item.value);
setTestResult(null);
}}
className={`text-left p-3 rounded-lg border transition-colors ${
clusterMode === item.value
? 'bg-blue-500/10 border-blue-500/40 text-blue-400'
@@ -264,7 +326,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>环境</label>
<select className={inputCls} value={env} onChange={e => setEnv(e.target.value)}>
<select className={inputCls} value={env} onChange={(e) => setEnv(e.target.value)}>
<option value="development">开发</option>
<option value="testing">测试</option>
<option value="staging">预发</option>
@@ -274,7 +336,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
</div>
<div>
<label className={labelCls}>访问模式</label>
<select className={inputCls} value={accessMode} onChange={e => setAccessMode(e.target.value)}>
<select className={inputCls} value={accessMode} onChange={(e) => setAccessMode(e.target.value)}>
<option value="readwrite">读写</option>
<option value="read">只读</option>
<option value="write">只写</option>
@@ -285,35 +347,59 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
{/* 审批流程 */}
<div>
<label className={labelCls}>审批流程</label>
<select className={inputCls} value={workflowId ?? ''} onChange={e => setWorkflowId(e.target.value ? Number(e.target.value) : null)}>
<select
className={inputCls}
value={workflowId ?? ''}
onChange={(e) => setWorkflowId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">不关联审批流程</option>
{workflows.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
{workflows.length === 0 && <p className="text-xs text-[var(--dg-text-muted)] mt-1">暂无可用审批流程</p>}
</div>
{testResult && (
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
<div
className={`flex items-center gap-2 px-3 py-2 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" /> : <XCircle className="w-4 h-4" />}
<span>{testResult.success ? `连接成功${testResult.version ? ` · ${testResult.version}` : ''}${instance.db_type === 'redis' ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''}` : testResult.message}</span>
<span>
{testResult.success
? `连接成功${testResult.version ? ` · ${testResult.version}` : ''}${instance.db_type === 'redis' ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''}`
: testResult.message}
</span>
</div>
)}
{/* 排除数据库 */}
<div>
<label className={labelCls}>排除数据库 <span className="font-normal text-[var(--dg-text-muted)]">(逗号分隔,如 sys, mysql)</span></label>
<input className={inputCls} value={excludedDbs} onChange={e => setExcludedDbs(e.target.value)} placeholder="sys, information_schema, mysql" />
<label className={labelCls}>
排除数据库 <span className="font-normal text-[var(--dg-text-muted)]">(逗号分隔,如 sys, mysql)</span>
</label>
<input
className={inputCls}
value={excludedDbs}
onChange={(e) => setExcludedDbs(e.target.value)}
placeholder="sys, information_schema, mysql"
/>
</div>
{/* 脱敏字段 - 简化为逗号分隔输入 */}
<div>
<label className={labelCls}>
自定义脱敏字段 <span className="font-normal text-[var(--dg-text-muted)]">(逗号分隔,与全局敏感字段合并,如 id_card, address)</span>
自定义脱敏字段{' '}
<span className="font-normal text-[var(--dg-text-muted)]">
(逗号分隔,与全局敏感字段合并,如 id_card, address)
</span>
</label>
<input
className={inputCls}
value={maskFields}
onChange={e => setMaskFields(e.target.value)}
onChange={(e) => setMaskFields(e.target.value)}
placeholder="id_card, address, bank_account"
/>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">
@@ -332,13 +418,15 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
{testing ? '测试中...' : '测试连接'}
</button>
<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 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">
<button onClick={onClose} className="dg-btn dg-btn-secondary dg-btn-sm">
取消
</button>
<button onClick={handleSubmit} disabled={submitting} className="dg-btn dg-btn-primary dg-btn-sm">
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
{mode === 'clone' ? '克隆' : '保存'}
</button>
</div>
</div>
</div>
</ModalOverlay>
);
}
+34 -33
View File
@@ -1,52 +1,53 @@
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { XIcon } from 'lucide-react';
/**
* 统一表单弹窗基座(指南 06/07 章):
* - 结构:固定头(标题+右上角统一关闭钮)/内容区单滚动/固定底(footer 右对齐 pt-4)
* - 视觉:rounded-xl 12px、单层 shadow-2xl、无边框;ESC/点遮罩不关(表单防误触)
* - width 预设:sm(max-w-[420px])/md(max-w-lg)/lg(max-w-2xl)/xl(max-w-4xl),可传任意值覆盖
*/
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
const WIDTH_PRESETS: Record<string, string> = {
sm: 'sm:max-w-[420px]',
md: 'sm:max-w-lg',
lg: 'sm:max-w-2xl',
xl: 'sm:max-w-4xl',
};
interface Props {
open: boolean;
onClose: () => void;
title: string;
/** 可选副标题(展示在标题下) */
description?: string;
children: ReactNode;
/** sm | md | lg | xl 或任意 max-w-* 类 */
width?: string;
footer?: ReactNode;
}
export function FormDialog({
open,
onClose,
title,
children,
width = 'max-w-lg',
footer,
}: Props) {
export function FormDialog({ open, onClose, title, description, children, width = 'md', footer }: Props) {
const widthCls = WIDTH_PRESETS[width] ?? width;
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
showCloseButton={false}
className={`border-0 ${width} flex max-h-[85vh] flex-col`}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
{/* Header */}
<div className="flex items-center justify-between">
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent showCloseButton className={cn(widthCls, 'p-6')}>
{/* Header(固定,不随内容滚动;关闭钮由基座右上角提供) */}
<div className={cn('pb-3', footer && 'border-b-0', description ? 'mb-1' : 'mb-3')}>
<DialogTitle className="text-base font-semibold">{title}</DialogTitle>
<button
onClick={onClose}
className="rounded-xs p-1 text-[var(--dg-text-muted)] transition-opacity hover:opacity-70"
>
<XIcon className="h-4 w-4" />
</button>
{description && <p className="mt-1 text-xs text-[var(--dg-text-muted)]">{description}</p>}
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto pr-1">{children}</div>
{/* 内容区单滚动 */}
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-thin">{children}</div>
{/* Optional footer */}
{footer && <div className="flex justify-end gap-2 pt-2">{footer}</div>}
{/* Footer(固定,右对齐) */}
{footer && <div className="flex justify-end gap-2 pt-4">{footer}</div>}
</DialogContent>
</Dialog>
);
+54 -34
View File
@@ -1,46 +1,66 @@
import { useEffect, useCallback } from 'react';
/**
* 通用弹窗容器(Radix 内核,接口与旧版完全兼容):
* - portal/焦点陷阱/body 滚动锁定/ESC(嵌套弹窗自动分层,修复连锁关闭)
* - z-300 档,压过侧栏/顶栏;200ms fade+zoom 动画;点遮罩不关闭(防表单误触)
* - 结构:内容直挂(不再需要内层卡片!),面板 flex-col + 内容区单滚动
* - 头部(标题+关闭钮)由子组件渲染或用 title prop
*/
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
const WIDTH_PRESETS: Record<string, string> = {
sm: 'sm:max-w-[420px]',
md: 'sm:max-w-lg',
lg: 'sm:max-w-2xl',
xl: 'sm:max-w-4xl',
};
interface ModalOverlayProps {
open: boolean;
onClose: () => void;
/** sm | md | lg | xl 或任意 max-w-* 类 */
width?: string;
/** 可选标题;不传时渲染 sr-only 标题满足可访问性 */
title?: string;
/** 传 true 时子内容自己管理滚动(默认内容区自动滚动) */
noScroll?: boolean;
className?: string;
children: React.ReactNode;
}
/**
* 通用弹框组件
* - overlay z-[100],低于 header/sidebar 的 z-[200],不遮挡面包屑
* - 点击遮罩层不关闭
* - ESC 键关闭
*/
export function ModalOverlay({ open, onClose, width = 'max-w-lg', className = '', children }: ModalOverlayProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [open, handleKeyDown]);
if (!open) return null;
export function ModalOverlay({
open,
onClose,
width = 'md',
title,
noScroll = false,
className = '',
children,
}: ModalOverlayProps) {
const widthCls = WIDTH_PRESETS[width] ?? width;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40">
<div
className={`bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl w-full ${width} max-h-[85vh] overflow-y-auto p-6 ${className}`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent showCloseButton className={cn(widthCls, 'p-0', className)}>
{title ? (
<DialogTitle className="px-6 pt-5 pb-3 text-base font-semibold">{title}</DialogTitle>
) : (
<DialogTitle className="sr-only">对话框</DialogTitle>
)}
<div
className={cn(
'flex min-h-0 flex-col',
noScroll ? 'flex-1 overflow-hidden' : 'max-h-[calc(85vh-1rem)] overflow-y-auto px-6 pb-6 scrollbar-thin',
!title && 'pt-5',
)}
>
{children}
</div>
</DialogContent>
</Dialog>
);
}
+4 -4
View File
@@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-[100] bg-black/40",
"fixed inset-0 z-[300] bg-black/45 backdrop-blur-[2px]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
@@ -52,7 +52,7 @@ function DialogContent({
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"fixed z-[101] top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2",
"fixed z-[301] top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2",
"w-full max-w-[calc(100%-2rem)] sm:max-w-lg",
"rounded-xl shadow-2xl p-6",
"max-h-[85vh] flex flex-col",
@@ -65,7 +65,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-3.5 right-3.5 rounded-md p-1 text-[var(--dg-text-muted)] opacity-70 transition-opacity hover:opacity-100 focus:outline-none"
className="absolute top-3.5 right-3.5 rounded-md p-1 text-[var(--dg-text-muted)] transition-colors hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--dg-blue)]"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
@@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="dialog-footer" className={cn("flex justify-end gap-2 pt-2", className)} {...props} />
<div data-slot="dialog-footer" className={cn("flex justify-end gap-2 pt-4", className)} {...props} />
)
}
File diff suppressed because it is too large. Load diff
@@ -4,7 +4,7 @@
* 已知限制:CSV 解析为简单逗号切分,不支持引号转义/换行字段;部分失败时不自动关闭弹窗。
*/
import { useState, useMemo, useCallback } from 'react';
import { X, Loader2 } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { ModalOverlay } from '@/components/ModalOverlay';
import { instanceApi } from '@/api/instance';
import { DB_DEFAULT_PORTS } from '../utils';
@@ -32,12 +32,15 @@ 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);
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());
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,
@@ -64,7 +67,7 @@ export function QuickImportDialog({ onClose, onImported }: QuickImportDialogProp
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 validRows = rows.filter((row) => row.name && row.db_type && row.host && row.port);
const handleImport = useCallback(async () => {
setSubmitting(true);
@@ -74,7 +77,7 @@ export function QuickImportDialog({ onClose, onImported }: QuickImportDialogProp
try {
await instanceApi.create({
...row,
username: row.db_type === 'redis' ? row.username : (row.username || 'default'),
username: row.db_type === 'redis' ? row.username : row.username || 'default',
password: row.password || '',
cluster_mode: row.db_type === 'redis' ? row.cluster_mode : undefined,
});
@@ -90,21 +93,20 @@ export function QuickImportDialog({ onClose, onImported }: QuickImportDialogProp
}, [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">
<ModalOverlay open={true} onClose={onClose} width="xl">
<div className="w-full">
<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>
<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)}
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">
@@ -122,26 +124,31 @@ export function QuickImportDialog({ onClose, onImported }: QuickImportDialogProp
共解析 {rows.length} 行,可导入 {validRows.length} 行。
</div>
<div className="mt-2 max-h-24 overflow-y-auto space-y-1">
{validRows.slice(0, 5).map(row => (
{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 ? ' · 集群' : ''}
{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'}`}>
<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>)}
{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 onClick={onClose} className="dg-btn dg-btn-secondary dg-btn-sm">
取消
</button>
<button
@@ -3,10 +3,7 @@
/* 包含步骤草稿编辑、审批人按角色筛选(getEligibleUsers)与提交校验 */
/* ------------------------------------------------------------------ */
import { useState, useEffect, useRef } from 'react';
import {
Plus, X, Shield, Trash2, ArrowUp, ArrowDown,
Loader2,
} from 'lucide-react';
import { Plus, 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';
@@ -41,11 +38,23 @@ export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: Edito
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 []; } })() : []),
}))
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 {
// 新建默认:提交→执行
@@ -86,9 +95,10 @@ export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: Edito
};
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));
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;
};
@@ -104,8 +114,14 @@ export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: Edito
};
const handleSubmit = async () => {
if (!name.trim()) { setError('请输入流程名称'); return; }
if (steps.length === 0) { setError('请至少添加一个步骤'); return; }
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) {
@@ -147,22 +163,18 @@ export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: Edito
const getUserName = (uid: number) => {
const u = users.find((u) => u.id === uid);
return u ? (u.display_name || u.username) : `#${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';
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"
>
<ModalOverlay open={true} onClose={onClose} width="lg">
<div className="w-full">
{/* 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 */}
@@ -171,12 +183,22 @@ export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: Edito
<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="输入流程名称" />
<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="可选" />
<input
className={inputCls}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="可选"
/>
</div>
{/* Steps */}
@@ -203,10 +225,15 @@ export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: Edito
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)]">
<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]}`}>
<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
@@ -215,9 +242,7 @@ export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: Edito
onChange={(e) => updateStep(idx, { name: e.target.value })}
placeholder="步骤名称"
/>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
{typeConf.label}
</span>
<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)}
@@ -265,16 +290,17 @@ export function WorkflowEditorModal({ workflow, users, onClose, onSaved }: Edito
</div>
{steps.length === 0 && (
<div className="text-center py-4 text-sm text-[var(--dg-text-muted)]">
请点击上方按钮添加流程步骤
</div>
<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
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
@@ -2,7 +2,7 @@
/* WorkflowPreviewModal — 工作流预览弹框 */
/* 按步骤顺序展示阶段类型与关联人员 */
/* ------------------------------------------------------------------ */
import { X, Star, ArrowRight, Shield } from 'lucide-react';
import { 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';
@@ -15,8 +15,8 @@ interface WorkflowPreviewModalProps {
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">
<ModalOverlay open={true} onClose={onClose} width="lg">
<div className="w-full">
<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>
@@ -26,9 +26,6 @@ export function WorkflowPreviewModal({ workflow, getUserName, onClose }: Workflo
</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">
@@ -38,7 +35,9 @@ export function WorkflowPreviewModal({ workflow, getUserName, onClose }: Workflo
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]}`}>
<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">
@@ -59,13 +58,15 @@ export function WorkflowPreviewModal({ workflow, getUserName, onClose }: Workflo
</div>
)}
</div>
{i < workflow.steps.length - 1 && <ArrowRight className="hidden sm:block w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
{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 onClick={onClose} className="dg-btn dg-btn-secondary dg-btn-sm">
关闭
</button>
</div>
@@ -5,7 +5,7 @@
*/
import { useState, useEffect, useRef } from 'react';
import { Upload, X, RotateCcw, Plus, Users as UsersIcon, Server } from 'lucide-react';
import { Upload } from 'lucide-react';
import { userApi, ldapApi } from '@/api/user';
import { nameToUsername } from '@/utils/pinyin';
import { toast } from '@/hooks/useToast';
@@ -34,10 +34,6 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
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);
// 挂载即探测配置(决定是否显示 LDAP Tab)
useEffect(() => {
@@ -140,26 +136,6 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
};
// 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) {
@@ -213,19 +189,14 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
// LDAP 未配置时隐藏 LDAP 相关 Tab(本地 CSV 始终可用)
// LDAP 仅作只读接入:不再提供「导入到 LDAP」「从 LDAP 导入」
const TABS = [
{ key: 'local' as const, label: '本地导入', icon: Upload },
];
const TABS = [{ key: 'local' as const, label: '本地导入', icon: Upload }];
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">
<ModalOverlay open={true} onClose={onClose} width="lg" noScroll>
<div className="flex h-full w-full 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 className="px-6 pt-5 pb-3">
<h2 className="text-base font-semibold">批量导入用户</h2>
</div>
{/* Tabs */}
@@ -302,7 +273,6 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
)}
</>
)}
</div>
{/* Footer */}
@@ -313,15 +283,10 @@ export function BatchImportModal({ onClose, onImported }: BatchImportModalProps)
>
关闭
</button>
<button
onClick={handleImport}
disabled={!canImport()}
className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50"
>
<button onClick={handleImport} disabled={!canImport()} className="dg-btn dg-btn-primary dg-btn-sm">
{importing ? '导入中...' : '确认导入'}
</button>
</div>
</div>
</ModalOverlay>
);
@@ -5,7 +5,6 @@
*/
import { useState } from 'react';
import { XCircle } from 'lucide-react';
import { userApi } from '@/api/user';
import { toast } from '@/hooks/useToast';
import { ModalOverlay } from '@/components/ModalOverlay';
@@ -44,18 +43,20 @@ export function CreateUserModal({ onClose, onCreated }: CreateUserModalProps) {
};
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">
<ModalOverlay open={true} onClose={onClose} width="md">
<div className="w-full">
<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>
<h2 className="text-base font-semibold">新建用户</h2>
</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 })); }}
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位字母、数字或下划线"
/>
@@ -63,14 +64,22 @@ export function CreateUserModal({ onClose, onCreated }: CreateUserModalProps) {
</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="用户的显示名称" />
<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 })); }}
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位"
/>
@@ -81,7 +90,10 @@ export function CreateUserModal({ onClose, onCreated }: CreateUserModalProps) {
<input
type="email"
value={email}
onChange={e => { setEmail(e.target.value); if (errors.email) setErrors(p => ({ ...p, email: undefined })); }}
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"
/>
@@ -89,7 +101,11 @@ export function CreateUserModal({ onClose, onCreated }: CreateUserModalProps) {
</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">
<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>
@@ -97,8 +113,10 @@ export function CreateUserModal({ onClose, onCreated }: CreateUserModalProps) {
</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">
<button onClick={onClose} className="dg-btn dg-btn-secondary dg-btn-sm">
取消
</button>
<button onClick={handleSubmit} disabled={submitting} className="dg-btn dg-btn-primary dg-btn-sm">
{submitting ? '创建中...' : '创建'}
</button>
</div>
@@ -5,7 +5,6 @@
*/
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';
@@ -28,24 +27,30 @@ export function EditUserModal({ user, onClose, onUpdated }: EditUserModalProps)
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(() => {});
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]);
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 });
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);
@@ -60,11 +65,10 @@ export function EditUserModal({ user, onClose, onUpdated }: EditUserModalProps)
};
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">
<ModalOverlay open={true} onClose={onClose} width="md">
<div className="w-full">
<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>
<h2 className="text-base font-semibold">编辑用户</h2>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
@@ -78,15 +82,28 @@ export function EditUserModal({ user, onClose, onUpdated }: EditUserModalProps)
</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" />
<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" />
<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">
<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>
@@ -99,11 +116,21 @@ export function EditUserModal({ user, onClose, onUpdated }: EditUserModalProps)
<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" />
{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>
<span className="text-[10px] text-[var(--dg-text-muted)]">
{g.readonly_instance_count || 0}R / {g.write_instance_count || 0}W
</span>
</label>
))}
</div>
@@ -114,13 +141,18 @@ export function EditUserModal({ user, onClose, onUpdated }: EditUserModalProps)
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)' }} />
<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">
<button onClick={onClose} className="dg-btn dg-btn-secondary dg-btn-sm">
取消
</button>
<button onClick={handleSubmit} disabled={submitting} className="dg-btn dg-btn-primary dg-btn-sm">
{submitting ? '保存中...' : '保存'}
</button>
</div>
@@ -5,7 +5,6 @@
*/
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';
@@ -16,6 +15,8 @@ interface ResetPasswordModalProps {
onClose: () => void;
}
import { KeyRound } from 'lucide-react';
export function ResetPasswordModal({ user, onClose }: ResetPasswordModalProps) {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -24,9 +25,18 @@ export function ResetPasswordModal({ user, onClose }: ResetPasswordModalProps) {
const handleSubmit = async () => {
setError('');
if (!newPassword) { setError('请输入新密码'); return; }
if (newPassword.length < 6) { setError('密码长度至少6位'); return; }
if (newPassword !== confirmPassword) { setError('两次输入的密码不一致'); return; }
if (!newPassword) {
setError('请输入新密码');
return;
}
if (newPassword.length < 6) {
setError('密码长度至少6位');
return;
}
if (newPassword !== confirmPassword) {
setError('两次输入的密码不一致');
return;
}
setSubmitting(true);
try {
@@ -41,28 +51,52 @@ export function ResetPasswordModal({ user, onClose }: ResetPasswordModalProps) {
};
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">
<ModalOverlay open={true} onClose={onClose} width="sm">
<div className="w-full">
<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>
<h2 className="flex items-center gap-2 text-base font-semibold">
<KeyRound className="h-4 w-4 text-[var(--dg-blue)]" /> 重置密码
</h2>
</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>
<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位" />
<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="再次输入" />
<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">
<button onClick={onClose} className="dg-btn dg-btn-secondary dg-btn-sm">
取消
</button>
<button onClick={handleSubmit} disabled={submitting} className="dg-btn dg-btn-primary dg-btn-sm">
{submitting ? '重置中...' : '确认重置'}
</button>
</div>
@@ -4,7 +4,6 @@
* 已知限制:「最近活动」为静态占位文案,暂未接入活动记录接口
*/
import { XCircle } from 'lucide-react';
import { ModalOverlay } from '@/components/ModalOverlay';
import { roleNameMap, roleTagStyle } from '../constants';
import type { UserItem } from '../types';
@@ -17,19 +16,30 @@ interface UserDetailModalProps {
/** 详情弹窗「最近活动」使用的时钟图标 */
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
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">
<ModalOverlay open={true} onClose={onClose} width="md">
<div className="w-full">
<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>
<h2 className="text-base font-semibold">用户详情</h2>
</div>
{/* User Info Card */}
@@ -41,10 +51,14 @@ export function UserDetailModal({ user, onClose }: UserDetailModalProps) {
<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] || ''}`}>
<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'}`}>
<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>
@@ -63,7 +77,9 @@ export function UserDetailModal({ user, onClose }: UserDetailModalProps) {
</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 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>
@@ -77,9 +93,7 @@ export function UserDetailModal({ user, onClose }: UserDetailModalProps) {
<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 className="text-sm text-[var(--dg-text-muted)] text-center py-6">暂无活动记录</div>
</div>
</div>
</ModalOverlay>
@@ -1,128 +0,0 @@
/**
* 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>
);
}
@@ -1,105 +0,0 @@
/**
* 添加 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>
);
}
@@ -1,83 +0,0 @@
/**
* 新建组织单元(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>
);
}
@@ -99,13 +99,8 @@ export function TicketDetailDialog({
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">
<ModalOverlay open={true} onClose={onClose} width="lg" noScroll>
<div className="flex h-full w-full 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">
+438 -184
View File
@@ -1,7 +1,17 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight,
Eye, PenLine, ChevronLeft, ChevronRight as ChevRight, X,
Plus,
Search,
Edit2,
Trash2,
Server,
Users,
ChevronRight,
Eye,
PenLine,
ChevronLeft,
ChevronRight as ChevRight,
X,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
@@ -24,7 +34,13 @@ const DB_TYPE_COLORS: Record<string, string> = {
/* ───────── Transfer Component ───────── */
interface TransferItem { label: string; value: string; tag?: string; tagColor?: string; accessMode?: string }
interface TransferItem {
label: string;
value: string;
tag?: string;
tagColor?: string;
accessMode?: string;
}
interface TransferProps {
title: string;
@@ -39,17 +55,35 @@ function Transfer({ title, items, selected, onChange, emptyText = '请从左侧
const [pendingAdd, setPendingAdd] = useState<Set<string>>(new Set());
const [pendingRemove, setPendingRemove] = useState<Set<string>>(new Set());
const unselected = items.filter(i =>
!selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.includes(search))
const unselected = items.filter(
(i) =>
!selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.includes(search)),
);
const selectedFiltered = items.filter(i =>
selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.includes(search))
const selectedFiltered = items.filter(
(i) =>
selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.includes(search)),
);
const addSelected = () => { onChange([...selected, ...pendingAdd]); setPendingAdd(new Set()); };
const removeSelected = () => { onChange(selected.filter(v => !pendingRemove.has(v))); setPendingRemove(new Set()); };
const toggleAdd = (v: string) => setPendingAdd(p => { const n = new Set(p); n.has(v) ? n.delete(v) : n.add(v); return n; });
const toggleRemove = (v: string) => setPendingRemove(p => { const n = new Set(p); n.has(v) ? n.delete(v) : n.add(v); return n; });
const addSelected = () => {
onChange([...selected, ...pendingAdd]);
setPendingAdd(new Set());
};
const removeSelected = () => {
onChange(selected.filter((v) => !pendingRemove.has(v)));
setPendingRemove(new Set());
};
const toggleAdd = (v: string) =>
setPendingAdd((p) => {
const n = new Set(p);
n.has(v) ? n.delete(v) : n.add(v);
return n;
});
const toggleRemove = (v: string) =>
setPendingRemove((p) => {
const n = new Set(p);
n.has(v) ? n.delete(v) : n.add(v);
return n;
});
const row = (active: boolean, color: string) =>
`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-xs transition-colors ${active ? color : 'hover:bg-[var(--dg-bg-hover)]'}`;
@@ -62,24 +96,49 @@ function Transfer({ title, items, selected, onChange, emptyText = '请从左侧
<span className="text-xs text-[var(--dg-text-muted)]">{unselected.length}</span>
</div>
<div className="p-2">
<input className="w-full px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none"
placeholder="搜索..." value={search} onChange={e => setSearch(e.target.value)} />
<input
className="w-full px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none"
placeholder="搜索..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="max-h-40 overflow-y-auto px-2 pb-2 space-y-0.5">
{unselected.length === 0 && <div className="text-center py-3 text-xs text-[var(--dg-text-muted)]">无可用项</div>}
{unselected.map(item => (
{unselected.length === 0 && (
<div className="text-center py-3 text-xs text-[var(--dg-text-muted)]">无可用项</div>
)}
{unselected.map((item) => (
<label key={item.value} className={row(pendingAdd.has(item.value), 'bg-blue-500/10 text-blue-400')}>
<input type="checkbox" checked={pendingAdd.has(item.value)} onChange={() => toggleAdd(item.value)} className="rounded" />
<input
type="checkbox"
checked={pendingAdd.has(item.value)}
onChange={() => toggleAdd(item.value)}
className="rounded"
/>
<Server className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
<span className="truncate">{item.label}</span>
{item.tag && <span className={`ml-auto text-[10px] px-1 py-0.5 rounded ${item.tagColor || ''}`}>{item.tag}</span>}
{item.tag && (
<span className={`ml-auto text-[10px] px-1 py-0.5 rounded ${item.tagColor || ''}`}>{item.tag}</span>
)}
</label>
))}
</div>
</div>
<div className="flex flex-col justify-center gap-2">
<button onClick={addSelected} disabled={pendingAdd.size === 0} className="p-1.5 rounded border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-all"><ChevRight className="w-4 h-4" /></button>
<button onClick={removeSelected} disabled={pendingRemove.size === 0} className="p-1.5 rounded border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-all"><ChevronLeft className="w-4 h-4" /></button>
<button
onClick={addSelected}
disabled={pendingAdd.size === 0}
className="p-1.5 rounded border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-all"
>
<ChevRight className="w-4 h-4" />
</button>
<button
onClick={removeSelected}
disabled={pendingRemove.size === 0}
className="p-1.5 rounded border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-all"
>
<ChevronLeft className="w-4 h-4" />
</button>
</div>
<div className="flex-1 border border-[var(--dg-border)] rounded-lg overflow-hidden">
<div className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] flex items-center justify-between">
@@ -87,13 +146,22 @@ function Transfer({ title, items, selected, onChange, emptyText = '请从左侧
<span className="text-xs text-[var(--dg-text-muted)]">{selectedFiltered.length}</span>
</div>
<div className="max-h-48 overflow-y-auto px-2 py-2 space-y-0.5">
{selectedFiltered.length === 0 && <div className="text-center py-6 text-xs text-[var(--dg-text-muted)]">{emptyText}</div>}
{selectedFiltered.map(item => (
{selectedFiltered.length === 0 && (
<div className="text-center py-6 text-xs text-[var(--dg-text-muted)]">{emptyText}</div>
)}
{selectedFiltered.map((item) => (
<label key={item.value} className={row(pendingRemove.has(item.value), 'bg-red-500/10 text-red-400')}>
<input type="checkbox" checked={pendingRemove.has(item.value)} onChange={() => toggleRemove(item.value)} className="rounded" />
<input
type="checkbox"
checked={pendingRemove.has(item.value)}
onChange={() => toggleRemove(item.value)}
className="rounded"
/>
<Server className="w-3 h-3 text-emerald-400 flex-shrink-0" />
<span className="truncate">{item.label}</span>
{item.tag && <span className={`ml-auto text-[10px] px-1 py-0.5 rounded ${item.tagColor || ''}`}>{item.tag}</span>}
{item.tag && (
<span className={`ml-auto text-[10px] px-1 py-0.5 rounded ${item.tagColor || ''}`}>{item.tag}</span>
)}
</label>
))}
</div>
@@ -112,7 +180,7 @@ export default function InstanceGroupPage() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showUserModal, setShowUserModal] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null);
const canManage = useActionPermission('instance-group.manage');
// Form state — shared by create & edit
@@ -128,56 +196,79 @@ export default function InstanceGroupPage() {
const [userSearch, setUserSearch] = useState('');
const fetchGroups = useCallback(async () => {
try { setGroups(await instanceGroupApi.list()); } catch {} finally { setLoading(false); }
try {
setGroups(await instanceGroupApi.list());
} catch {
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchGroups(); }, [fetchGroups]);
useEffect(() => {
fetchGroups();
}, [fetchGroups]);
useEffect(() => {
if (showCreateModal || showEditModal) {
instanceApi.list().then((res: any) => {
setAllInstances(Array.isArray(res) ? res : (res?.items || []));
}).catch(() => {});
instanceApi
.list()
.then((res: any) => {
setAllInstances(Array.isArray(res) ? res : res?.items || []);
})
.catch(() => {});
}
}, [showCreateModal, showEditModal]);
useEffect(() => {
if (showUserModal) {
userApi.list().then((d: any) => {
setAllUsers(Array.isArray(d?.items || d) ? (d?.items || d) : []);
}).catch(() => {});
userApi
.list()
.then((d: any) => {
setAllUsers(Array.isArray(d?.items || d) ? d?.items || d : []);
})
.catch(() => {});
}
}, [showUserModal]);
const transferItems = useMemo(() =>
allInstances.map((i: any) => ({
label: `${i.name} (${i.host}:${i.port})`,
value: String(i.id),
tag: i.db_type,
tagColor: DB_TYPE_COLORS[i.db_type] || '',
accessMode: i.access_mode || 'readwrite',
})),
[allInstances]
const transferItems = useMemo(
() =>
allInstances.map((i: any) => ({
label: `${i.name} (${i.host}:${i.port})`,
value: String(i.id),
tag: i.db_type,
tagColor: DB_TYPE_COLORS[i.db_type] || '',
accessMode: i.access_mode || 'readwrite',
})),
[allInstances],
);
// 查询授权:只匹配 read + readwrite 实例
const readonlyTransferItems = useMemo(() =>
transferItems.filter(i => i.accessMode === 'read' || i.accessMode === 'readwrite'),
[transferItems]
const readonlyTransferItems = useMemo(
() => transferItems.filter((i) => i.accessMode === 'read' || i.accessMode === 'readwrite'),
[transferItems],
);
// 变更授权:只匹配 write + readwrite 实例
const writeTransferItems = useMemo(() =>
transferItems.filter(i => i.accessMode === 'write' || i.accessMode === 'readwrite'),
[transferItems]
const writeTransferItems = useMemo(
() => transferItems.filter((i) => i.accessMode === 'write' || i.accessMode === 'readwrite'),
[transferItems],
);
const filteredGroups = useMemo(() => groups.filter(g => !search || g.name.includes(search)), [groups, search]);
const filteredUsers = useMemo(() => allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())), [allUsers, userSearch]);
const filteredGroups = useMemo(() => groups.filter((g) => !search || g.name.includes(search)), [groups, search]);
const filteredUsers = useMemo(
() =>
allUsers.filter(
(u) => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase()),
),
[allUsers, userSearch],
);
const resetForm = () => {
setFormName(''); setFormDesc(''); setFormIsActive(true);
setReadonlyIds([]); setWriteIds([]);
setFormName('');
setFormDesc('');
setFormIsActive(true);
setReadonlyIds([]);
setWriteIds([]);
};
/* ── Create ── */
@@ -191,7 +282,9 @@ export default function InstanceGroupPage() {
readonly_instance_ids: readonlyIds.map(Number),
write_instance_ids: writeIds.map(Number),
});
setShowCreateModal(false); resetForm(); fetchGroups();
setShowCreateModal(false);
resetForm();
fetchGroups();
};
/* ── Edit ── */
@@ -214,42 +307,64 @@ export default function InstanceGroupPage() {
readonly_instance_ids: readonlyIds.map(Number),
write_instance_ids: writeIds.map(Number),
});
setShowEditModal(false); resetForm(); fetchGroups();
setShowEditModal(false);
resetForm();
fetchGroups();
};
/* ── Delete ── */
const handleDelete = async (id: number) => {
setDeleteConfirm({msg: '确定删除该权限组?', onConfirm: async () => {
await instanceGroupApi.delete(id);
if (selectedId === id) setSelectedId(null);
fetchGroups();
}});
setDeleteConfirm({
msg: '确定删除该权限组?',
onConfirm: async () => {
await instanceGroupApi.delete(id);
if (selectedId === id) setSelectedId(null);
fetchGroups();
},
});
};
/* ── Assign Users ── */
const handleAssignUsers = async () => {
if (!selectedId || selectedUserIds.length === 0) return;
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
setShowUserModal(false); setSelectedUserIds([]); fetchGroups();
setShowUserModal(false);
setSelectedUserIds([]);
fetchGroups();
};
const selectedGroup = groups.find(g => g.id === selectedId);
const selectedGroup = groups.find((g) => g.id === selectedId);
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]"><div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-2" />加载中...</div>;
if (loading)
return (
<div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-2" />
加载中...
</div>
);
/* ── Instance Row ── */
const InstanceList = ({ instances }: { instances: any[] }) => {
if (instances.length === 0) return <div className="text-xs text-[var(--dg-text-muted)] py-2">暂无</div>;
return (
<div className="space-y-1">
{instances.map(inst => (
<div key={inst.id} className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs">
{instances.map((inst) => (
<div
key={inst.id}
className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs"
>
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
<span
className={`w-1.5 h-1.5 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`}
/>
<span>{inst.name}</span>
<span className="text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
<span className="text-[var(--dg-text-muted)]">
{inst.host}:{inst.port}
</span>
</div>
<span className={`px-1 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[inst.db_type] || ''}`}>{inst.db_type}</span>
<span className={`px-1 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[inst.db_type] || ''}`}>
{inst.db_type}
</span>
</div>
))}
</div>
@@ -257,8 +372,12 @@ export default function InstanceGroupPage() {
};
/* ── Form Modal shared close/submit ── */
const formClose = () => { setShowCreateModal(false); setShowEditModal(false); resetForm(); };
const formMode = showCreateModal ? 'create' as const : showEditModal ? 'edit' as const : null;
const formClose = () => {
setShowCreateModal(false);
setShowEditModal(false);
resetForm();
};
const formMode = showCreateModal ? ('create' as const) : showEditModal ? ('edit' as const) : null;
const formSubmit = formMode === 'create' ? handleCreate : handleEdit;
return (
@@ -270,7 +389,13 @@ export default function InstanceGroupPage() {
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">每个策略包含「查询授权」和「变更授权」两类实例</p>
</div>
{canManage && (
<button onClick={() => { resetForm(); setShowCreateModal(true); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 rounded-lg text-sm font-medium text-white hover:bg-blue-600 transition-colors">
<button
onClick={() => {
resetForm();
setShowCreateModal(true);
}}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 rounded-lg text-sm font-medium text-white hover:bg-blue-600 transition-colors"
>
<Plus className="w-4 h-4" /> 新建权限策略
</button>
)}
@@ -281,23 +406,41 @@ export default function InstanceGroupPage() {
<div className="w-80 flex-shrink-0 space-y-3">
<div className="relative">
<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" placeholder="搜索" value={search} onChange={e => setSearch(e.target.value)} />
<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"
placeholder="搜索"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="space-y-2 max-h-[calc(100vh-300px)] overflow-y-auto">
{filteredGroups.length === 0 && <div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>}
{filteredGroups.map(group => {
{filteredGroups.length === 0 && (
<div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>
)}
{filteredGroups.map((group) => {
const ro = group.readonly_instance_count || 0;
const wr = group.write_instance_count || 0;
return (
<div key={group.id} onClick={() => setSelectedId(group.id)} className={`p-3 rounded-lg cursor-pointer transition-colors ${selectedId === group.id ? 'bg-blue-500/10 border border-blue-500/30' : 'bg-[var(--dg-bg-secondary)] border border-transparent hover:bg-[var(--dg-bg-hover)]'}`}>
<div
key={group.id}
onClick={() => setSelectedId(group.id)}
className={`p-3 rounded-lg cursor-pointer transition-colors ${selectedId === group.id ? 'bg-blue-500/10 border border-blue-500/30' : 'bg-[var(--dg-bg-secondary)] border border-transparent hover:bg-[var(--dg-bg-hover)]'}`}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{group.name}</span>
{!group.is_active && <span className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/15 text-red-400">禁用</span>}
{!group.is_active && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/15 text-red-400">禁用</span>
)}
</div>
<div className="flex items-center gap-2 mt-1.5 text-xs text-[var(--dg-text-muted)]">
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400 text-[10px]">{ro} 只读</span>
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400 text-[10px]">
{ro} 只读
</span>
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 text-[10px]">{wr} 写入</span>
<span className="ml-auto"><Users className="w-3 h-3 inline mr-0.5" />{group.user_count || 0}</span>
<span className="ml-auto">
<Users className="w-3 h-3 inline mr-0.5" />
{group.user_count || 0}
</span>
</div>
</div>
);
@@ -307,80 +450,126 @@ export default function InstanceGroupPage() {
{/* Right detail */}
<div className="flex-1 min-w-0">
{selectedGroup ? (() => {
const sg = selectedGroup;
const roInst = sg.readonly_instances || [];
const wrInst = sg.write_instances || [];
const roCount = sg.readonly_instance_count ?? (sg.readonly_instance_ids || []).length;
const wrCount = sg.write_instance_count ?? (sg.write_instance_ids || []).length;
return (
<div className="space-y-4">
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">{sg.name}</h2>
{!sg.is_active && <span className="px-2 py-0.5 text-xs rounded bg-red-500/15 text-red-400">禁用</span>}
</div>
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
<div className="flex items-center gap-2 mt-2">
<span className="px-2 py-0.5 text-xs rounded bg-emerald-500/15 text-emerald-400">{roCount} 只读实例</span>
<span className="px-2 py-0.5 text-xs rounded bg-amber-500/15 text-amber-400">{wrCount} 写入实例</span>
<span className="px-2 py-0.5 text-xs rounded bg-blue-500/15 text-blue-400">{sg.user_count || 0} 用户</span>
{selectedGroup ? (
(() => {
const sg = selectedGroup;
const roInst = sg.readonly_instances || [];
const wrInst = sg.write_instances || [];
const roCount = sg.readonly_instance_count ?? (sg.readonly_instance_ids || []).length;
const wrCount = sg.write_instance_count ?? (sg.write_instance_ids || []).length;
return (
<div className="space-y-4">
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">{sg.name}</h2>
{!sg.is_active && (
<span className="px-2 py-0.5 text-xs rounded bg-red-500/15 text-red-400">禁用</span>
)}
</div>
{sg.description && (
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className="px-2 py-0.5 text-xs rounded bg-emerald-500/15 text-emerald-400">
{roCount} 只读实例
</span>
<span className="px-2 py-0.5 text-xs rounded bg-amber-500/15 text-amber-400">
{wrCount} 写入实例
</span>
<span className="px-2 py-0.5 text-xs rounded bg-blue-500/15 text-blue-400">
{sg.user_count || 0} 用户
</span>
</div>
</div>
{canManage && (
<div className="flex items-center gap-2">
<button
onClick={() => openEditModal(sg)}
className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(sg.id)}
className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
{canManage && (
<div className="flex items-center gap-2">
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
</CardContent>
</Card>
{/* Readonly instances */}
<Card className="dg-card">
<CardContent className="p-5">
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
<Eye className="w-4 h-4 text-emerald-400" />
查询授权 ({roInst.length})
</h3>
<InstanceList instances={roInst} />
</CardContent>
</Card>
{/* Write instances */}
<Card className="dg-card">
<CardContent className="p-5">
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
<PenLine className="w-4 h-4 text-amber-400" />
变更授权 ({wrInst.length})
</h3>
<InstanceList instances={wrInst} />
</CardContent>
</Card>
{/* Users */}
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Users className="w-4 h-4 text-[var(--dg-text-muted)]" />
关联用户 ({sg.users?.length || 0})
</h3>
{canManage && (
<button
onClick={() => {
setSelectedUserIds([]);
setShowUserModal(true);
}}
className="text-xs text-blue-400 hover:text-blue-300"
>
添加用户
</button>
)}
</div>
{sg.users && sg.users.length > 0 ? (
<div className="space-y-1.5">
{sg.users.map((u) => (
<div
key={u.id}
className="flex items-center gap-2.5 px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg"
>
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
{(u.display_name || u.username).charAt(0)}
</div>
<span className="text-sm">{u.display_name || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
</div>
))}
</div>
) : (
<div className="text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>
)}
</div>
</CardContent>
</Card>
{/* Readonly instances */}
<Card className="dg-card">
<CardContent className="p-5">
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
<Eye className="w-4 h-4 text-emerald-400" />查询授权 ({roInst.length})
</h3>
<InstanceList instances={roInst} />
</CardContent>
</Card>
{/* Write instances */}
<Card className="dg-card">
<CardContent className="p-5">
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
<PenLine className="w-4 h-4 text-amber-400" />变更授权 ({wrInst.length})
</h3>
<InstanceList instances={wrInst} />
</CardContent>
</Card>
{/* Users */}
<Card className="dg-card">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
{canManage && <button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300">添加用户</button>}
</div>
{sg.users && sg.users.length > 0 ? (
<div className="space-y-1.5">{sg.users.map(u => (
<div key={u.id} className="flex items-center gap-2.5 px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
<span className="text-sm">{u.display_name || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
</div>
))}</div>
) : <div className="text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>}
</CardContent>
</Card>
</div>
);
})() : (
</CardContent>
</Card>
</div>
);
})()
) : (
<div className="flex flex-col items-center justify-center h-96 text-[var(--dg-text-muted)]">
<ChevronRight className="w-8 h-8 mb-2 rotate-90 opacity-50" />
<p className="text-sm">选择左侧权限组查看详情</p>
@@ -391,21 +580,32 @@ export default function InstanceGroupPage() {
{/* Form Modal (create & edit) */}
{formMode && (
<ModalOverlay open={!!formMode} onClose={formClose} width="max-w-3xl">
<div className="w-full max-w-3xl bg-[var(--dg-bg-primary)] rounded-xl shadow-2xl flex flex-col max-h-[90vh] border border-[var(--dg-border)]">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
<h3 className="text-lg font-semibold">{formMode === 'create' ? '新建权限策略' : '编辑权限策略'}</h3>
<button onClick={formClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><X className="w-4 h-4" /></button>
<ModalOverlay open={!!formMode} onClose={formClose} width="lg" noScroll>
<div className="flex h-full w-full flex-col">
<div className="px-6 pt-5 pb-3">
<h3 className="text-base font-semibold">{formMode === 'create' ? '新建权限策略' : '编辑权限策略'}</h3>
</div>
<div className="overflow-y-auto px-6 py-5 space-y-5 flex-1">
<div className="overflow-y-auto px-6 pb-6 space-y-5 flex-1 scrollbar-thin">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1.5">策略名称 <span className="text-red-400">*</span></label>
<input 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" value={formName} onChange={e => setFormName(e.target.value)} placeholder="如:生产数据库权限" />
<label className="block text-sm font-medium mb-1.5">
策略名称 <span className="text-red-400">*</span>
</label>
<input
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"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="如:生产数据库权限"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">描述</label>
<input 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" value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="可选" />
<input
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"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder="可选"
/>
</div>
</div>
@@ -415,7 +615,13 @@ export default function InstanceGroupPage() {
<span className="text-sm font-medium text-emerald-400">查询授权</span>
<span className="text-xs text-[var(--dg-text-muted)]">— 查询数据的实例</span>
</div>
<Transfer title="只读实例" items={readonlyTransferItems} selected={readonlyIds} onChange={setReadonlyIds} emptyText="选择用于查询的实例" />
<Transfer
title="只读实例"
items={readonlyTransferItems}
selected={readonlyIds}
onChange={setReadonlyIds}
emptyText="选择用于查询的实例"
/>
</div>
<div className="rounded-xl p-4 bg-amber-500/5 border border-amber-500/20">
@@ -424,12 +630,27 @@ export default function InstanceGroupPage() {
<span className="text-sm font-medium text-amber-400">变更授权</span>
<span className="text-xs text-[var(--dg-text-muted)]">— 数据变更工单的实例</span>
</div>
<Transfer title="写入实例" items={writeTransferItems} selected={writeIds} onChange={setWriteIds} emptyText="选择用于变更的实例" />
<Transfer
title="写入实例"
items={writeTransferItems}
selected={writeIds}
onChange={setWriteIds}
emptyText="选择用于变更的实例"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={formClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors rounded-lg hover:bg-[var(--dg-bg-hover)]">取消</button>
<button onClick={formSubmit} disabled={!formName.trim()} className="dg-btn dg-btn-primary disabled:opacity-50">
<button
onClick={formClose}
className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors rounded-lg hover:bg-[var(--dg-bg-hover)]"
>
取消
</button>
<button
onClick={formSubmit}
disabled={!formName.trim()}
className="dg-btn dg-btn-primary disabled:opacity-50"
>
{formMode === 'create' ? '创建' : '保存'}
</button>
</div>
@@ -443,38 +664,71 @@ export default function InstanceGroupPage() {
title={deleteConfirm?.msg || '确认操作'}
variant="danger"
confirmText="确定"
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
onConfirm={() => {
deleteConfirm?.onConfirm();
setDeleteConfirm(null);
}}
onCancel={() => setDeleteConfirm(null)}
/>
{/* User Modal */}
{showUserModal && (
<ModalOverlay open={showUserModal} onClose={() => setShowUserModal(false)} width="max-w-md">
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] rounded-xl shadow-2xl border border-[var(--dg-border)]">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
<h3 className="text-lg font-semibold">关联用户</h3>
<button onClick={() => setShowUserModal(false)} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><X className="w-4 h-4" /></button>
<ModalOverlay open={showUserModal} onClose={() => setShowUserModal(false)} width="md">
<div className="w-full">
<div className="pt-2 pb-3">
<h3 className="text-base font-semibold">关联用户</h3>
</div>
<div className="px-6 py-4">
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input value={userSearch} onChange={e => setUserSearch(e.target.value)} placeholder="搜索用户..." className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
<div className="pb-4">
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
placeholder="搜索用户..."
className="w-full pl-10 pr-4 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 className="space-y-2 max-h-72 overflow-y-auto mb-4">
{filteredUsers.map((u) => (
<label
key={u.id}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedUserIds.includes(u.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}
>
<input
type="checkbox"
checked={selectedUserIds.includes(u.id)}
onChange={(e) =>
setSelectedUserIds(
e.target.checked ? [...selectedUserIds, u.id] : selectedUserIds.filter((id) => id !== u.id),
)
}
className="rounded"
/>
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
{(u.display_name || u.username).charAt(0)}
</div>
<span className="text-sm">{u.display_name || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
</label>
))}
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowUserModal(false)}
className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors rounded-lg hover:bg-[var(--dg-bg-hover)]"
>
取消
</button>
<button
onClick={handleAssignUsers}
disabled={selectedUserIds.length === 0}
className="dg-btn dg-btn-primary disabled:opacity-50"
>
确认 ({selectedUserIds.length})
</button>
</div>
</div>
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
{filteredUsers.map(u => (
<label key={u.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedUserIds.includes(u.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
<input type="checkbox" checked={selectedUserIds.includes(u.id)} onChange={e => setSelectedUserIds(e.target.checked ? [...selectedUserIds, u.id] : selectedUserIds.filter(id => id !== u.id))} className="rounded" />
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
<span className="text-sm">{u.display_name || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
</label>
))}
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setShowUserModal(false)} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors rounded-lg hover:bg-[var(--dg-bg-hover)]">取消</button>
<button onClick={handleAssignUsers} disabled={selectedUserIds.length === 0} className="dg-btn dg-btn-primary disabled:opacity-50">确认 ({selectedUserIds.length})</button>
</div>
</div>{/* end px-6 py-4 */}
{/* end px-6 py-4 */}
</div>
</ModalOverlay>
)}
+82 -40
View File
@@ -44,7 +44,14 @@ const defaultForm = (): FormState => ({
const dingtalkDefault = () => ({ webhook_url: '', secret: '' });
const feishuDefault = () => ({ webhook_url: '' });
const emailDefault = () => ({ smtp_host: '', smtp_port: 465, smtp_user: '', smtp_password: '', from_addr: '', use_tls: true });
const emailDefault = () => ({
smtp_host: '',
smtp_port: 465,
smtp_user: '',
smtp_password: '',
from_addr: '',
use_tls: true,
});
const configDefaults: Record<ChannelType, () => Record<string, any>> = {
dingtalk: dingtalkDefault,
@@ -65,7 +72,7 @@ export default function NotificationPage() {
/* test */
const [testingId, setTestingId] = useState<number | null>(null);
const [testResult, setTestResult] = useState<Record<number, { success: boolean; message: string } | null>>({});
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null);
const fetchList = useCallback(async () => {
try {
@@ -97,11 +104,11 @@ export default function NotificationPage() {
};
const handleTypeChange = (t: ChannelType) => {
setForm(prev => ({ ...prev, channel_type: t, config: configDefaults[t]() }));
setForm((prev) => ({ ...prev, channel_type: t, config: configDefaults[t]() }));
};
const updateConfig = (key: string, value: any) => {
setForm(prev => ({ ...prev, config: { ...prev.config, [key]: value } }));
setForm((prev) => ({ ...prev, config: { ...prev.config, [key]: value } }));
};
const handleSave = async () => {
@@ -132,25 +139,33 @@ export default function NotificationPage() {
};
const handleDelete = async (id: number) => {
setDeleteConfirm({msg: '确定删除此通知渠道?', onConfirm: async () => { try { await request.delete(`/notifications/${id}`); fetchList(); } catch {} }});
setDeleteConfirm({
msg: '确定删除此通知渠道?',
onConfirm: async () => {
try {
await request.delete(`/notifications/${id}`);
fetchList();
} catch {}
},
});
return;
};
const handleTest = async (id: number) => {
try {
setTestingId(id);
setTestResult(prev => ({ ...prev, [id]: null }));
setTestResult((prev) => ({ ...prev, [id]: null }));
const res = await request.post(`/notifications/${id}/test`);
const data = (res as any).data ?? res;
setTestResult(prev => ({ ...prev, [id]: { success: true, message: data.message || '测试成功' } }));
setTestResult((prev) => ({ ...prev, [id]: { success: true, message: data.message || '测试成功' } }));
} catch (err: any) {
setTestResult(prev => ({
setTestResult((prev) => ({
...prev,
[id]: { success: false, message: err.response?.data?.detail || '测试失败' },
}));
} finally {
setTestingId(null);
setTimeout(() => setTestResult(prev => ({ ...prev, [id]: null })), 5000);
setTimeout(() => setTestResult((prev) => ({ ...prev, [id]: null })), 5000);
}
};
@@ -161,11 +176,13 @@ export default function NotificationPage() {
return (
<>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Webhook URL <span className="text-red-400">*</span></label>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">
Webhook URL <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.config.webhook_url || ''}
onChange={e => updateConfig('webhook_url', e.target.value)}
onChange={(e) => updateConfig('webhook_url', e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
/>
@@ -175,7 +192,7 @@ export default function NotificationPage() {
<input
type="text"
value={form.config.secret || ''}
onChange={e => updateConfig('secret', e.target.value)}
onChange={(e) => updateConfig('secret', e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="可选,加签密钥"
/>
@@ -186,11 +203,13 @@ export default function NotificationPage() {
if (t === 'webhook_feishu') {
return (
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Webhook URL <span className="text-red-400">*</span></label>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">
Webhook URL <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.config.webhook_url || ''}
onChange={e => updateConfig('webhook_url', e.target.value)}
onChange={(e) => updateConfig('webhook_url', e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
/>
@@ -201,11 +220,13 @@ export default function NotificationPage() {
return (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">SMTP 服务器 <span className="text-red-400">*</span></label>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">
SMTP 服务器 <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.config.smtp_host || ''}
onChange={e => updateConfig('smtp_host', e.target.value)}
onChange={(e) => updateConfig('smtp_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 text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="smtp.example.com"
/>
@@ -215,16 +236,18 @@ export default function NotificationPage() {
<input
type="number"
value={form.config.smtp_port || 465}
onChange={e => updateConfig('smtp_port', parseInt(e.target.value) || 465)}
onChange={(e) => updateConfig('smtp_port', parseInt(e.target.value) || 465)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
/>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">SMTP 用户 <span className="text-red-400">*</span></label>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">
SMTP 用户 <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.config.smtp_user || ''}
onChange={e => updateConfig('smtp_user', e.target.value)}
onChange={(e) => updateConfig('smtp_user', e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="noreply@example.com"
/>
@@ -234,17 +257,19 @@ export default function NotificationPage() {
<input
type="password"
value={form.config.smtp_password || ''}
onChange={e => updateConfig('smtp_password', e.target.value)}
onChange={(e) => updateConfig('smtp_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 text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="••••••••"
/>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">发件地址 <span className="text-red-400">*</span></label>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">
发件地址 <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.config.from_addr || ''}
onChange={e => updateConfig('from_addr', e.target.value)}
onChange={(e) => updateConfig('from_addr', e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="noreply@example.com"
/>
@@ -293,12 +318,17 @@ export default function NotificationPage() {
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">暂无通知渠道,点击右上角添加</div>
) : (
<div className="space-y-3">
{channels.map(ch => {
{channels.map((ch) => {
const Icon = CHANNEL_ICONS[ch.channel_type] || Settings;
const result = testResult[ch.id];
return (
<div key={ch.id} className={`dg-card px-5 py-4 flex items-center gap-4 ${!ch.is_enabled ? 'opacity-50' : ''}`}>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${ch.is_enabled ? 'bg-blue-500/15' : 'bg-[var(--dg-bg-tertiary)]'}`}>
<div
key={ch.id}
className={`dg-card px-5 py-4 flex items-center gap-4 ${!ch.is_enabled ? 'opacity-50' : ''}`}
>
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${ch.is_enabled ? 'bg-blue-500/15' : 'bg-[var(--dg-bg-tertiary)]'}`}
>
<Icon className={`w-5 h-5 ${ch.is_enabled ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
</div>
<div className="flex-1 min-w-0">
@@ -307,7 +337,9 @@ export default function NotificationPage() {
<span className="text-[11px] px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400">
{CHANNEL_LABELS[ch.channel_type] || ch.channel_type}
</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${ch.is_enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
<span
className={`text-[11px] px-2 py-0.5 rounded-full ${ch.is_enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}
>
{ch.is_enabled ? '已启用' : '已禁用'}
</span>
</div>
@@ -335,11 +367,17 @@ export default function NotificationPage() {
result?.success
? 'bg-green-500/15 text-green-400'
: result?.success === false
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
} disabled:opacity-50`}
>
{testingId === ch.id ? '发送中…' : result?.success ? '✓ 成功' : result?.success === false ? '✗ 失败' : '测试'}
{testingId === ch.id
? '发送中…'
: result?.success
? '✓ 成功'
: result?.success === false
? '✗ 失败'
: '测试'}
</button>
<button
onClick={() => openEdit(ch)}
@@ -364,22 +402,23 @@ export default function NotificationPage() {
{/* ── Create / Edit Modal ── */}
{showModal && (
<ModalOverlay open={showModal} onClose={() => setShowModal(false)} width="max-w-2xl">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold text-[var(--dg-text-primary)]">{editingId ? '编辑渠道' : '添加渠道'}</h3>
<button onClick={() => setShowModal(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)]">
<X className="w-5 h-5" />
</button>
<ModalOverlay open={showModal} onClose={() => setShowModal(false)} width="lg">
<div className="pt-1">
<div className="pb-5">
<h3 className="text-base font-semibold text-[var(--dg-text-primary)]">
{editingId ? '编辑渠道' : '添加渠道'}
</h3>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">渠道名称 <span className="text-red-400">*</span></label>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">
渠道名称 <span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
onChange={(e) => setForm((prev) => ({ ...prev, 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 text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="例如:数据变更消息机器人"
/>
@@ -414,7 +453,7 @@ export default function NotificationPage() {
<div className="flex items-center gap-3 pt-2 border-t border-[var(--dg-border)]">
<button
type="button"
onClick={() => setForm(prev => ({ ...prev, is_enabled: !prev.is_enabled }))}
onClick={() => setForm((prev) => ({ ...prev, is_enabled: !prev.is_enabled }))}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
form.is_enabled ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
}`}
@@ -452,7 +491,10 @@ export default function NotificationPage() {
title={deleteConfirm?.msg || '确认操作'}
variant="danger"
confirmText="确定"
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
onConfirm={() => {
deleteConfirm?.onConfirm();
setDeleteConfirm(null);
}}
onCancel={() => setDeleteConfirm(null)}
/>
</div>