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:
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 行编辑、各页删除确认——确认单卡片/单滚动/统一关闭钮与按钮/动画一致
|
||||
- 提交并推送远程
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user