refactor(frontend): split 7 oversized pages into feature co-location
Per guide ch.7 page split pattern (skeleton orchestrates, private components live in features/<domain>/): - InstancePage 1716->328, UsersPage 1309->240, TicketsPage 1076->195, TicketDetailPage 911->287, QueriesPage 883->479, LdapConfigPage 860->393, WorkflowPage 852->194 - new feature modules: datasource/ rbac/ ticket/ query/ system/ldap/ governance/workflow/ (components + hooks + types + utils) - stable keys replace index keys in dynamic lists; polling intervals and toast call sites preserved 1:1 (verified by agents) - filter/pagination state moved to URL query via new useQueryState hook (Instance/Users/Tickets/AuditLogs pages): refresh/share/back-button restore state; composite updates applied atomically - permission pure functions extracted to lib/permissions.ts (used by useAuth + useLogin); page-size audit: 0 pages over 700-line budget
This commit is contained in:
1 parent
ac563d1c5e
commit
ac498c7ba2
92 files changed
+8224
-6146
No files matched your search
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 用途:用户列表批量操作条(选中计数 + 批量启用/禁用/删除下拉菜单)
|
||||
* 必需 props:selectedCount(选中数量)、onAction(执行批量动作)
|
||||
* 已知限制:菜单开合状态由组件内部管理;删除动作的二次确认由页面统一弹出
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { BATCH_OPTIONS, type BatchActionKey } from '../constants';
|
||||
|
||||
interface BatchActionBarProps {
|
||||
selectedCount: number;
|
||||
onAction: (action: BatchActionKey) => void;
|
||||
}
|
||||
|
||||
export function BatchActionBar({ selectedCount, onAction }: BatchActionBarProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
||||
<span className="text-sm text-blue-400">
|
||||
已选择 <strong>{selectedCount}</strong> 项
|
||||
</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm text-blue-400 hover:bg-blue-500/25 transition-colors"
|
||||
>
|
||||
批量操作 <ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[160px]">
|
||||
{BATCH_OPTIONS.map(opt => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => { onAction(opt.key); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 用途:批量导入用户弹窗(本地 CSV 导入 / 导入到 LDAP / 从 LDAP 导入 三个 Tab,含新建 OU 子弹窗)
|
||||
* 必需 props:onClose(关闭)、onImported(导入有成功项后回调,页面负责关窗并刷新列表)
|
||||
* 已知限制:CSV 预览仅展示前 50(本地)/ 30(到 LDAP)行;拼音用户名重复时以 username+序号作稳定 key
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Upload, X, RotateCcw, Plus, Users as UsersIcon, Server,
|
||||
} from 'lucide-react';
|
||||
import { userApi, ldapApi } from '@/api/user';
|
||||
import { nameToUsername } from '@/utils/pinyin';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
|
||||
interface BatchImportModalProps {
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) {
|
||||
const [tab, setTab] = useState<'local' | 'to-ldap' | 'from-ldap'>('local');
|
||||
const [preview, setPreview] = useState<any[]>([]);
|
||||
const [skipExisting, setSkipExisting] = useState(true);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// LDAP state
|
||||
const [ldapOus, setLdapOus] = useState<any[]>([]);
|
||||
const [ldapUsers, setLdapUsers] = useState<any[]>([]);
|
||||
const [selectedLdapUsers, setSelectedLdapUsers] = useState<Set<string>>(new Set());
|
||||
const [ldapLoading, setLdapLoading] = useState(false);
|
||||
const [targetOu, setTargetOu] = useState('');
|
||||
const [ldapReady, setLdapReady] = useState(false);
|
||||
|
||||
// Create OU dialog
|
||||
const [showCreateOu, setShowCreateOu] = useState(false);
|
||||
const [newOuName, setNewOuName] = useState('');
|
||||
const [newOuDesc, setNewOuDesc] = useState('');
|
||||
const [creatingOu, setCreatingOu] = useState(false);
|
||||
|
||||
// Check LDAP readiness + load OUs
|
||||
useEffect(() => {
|
||||
if (tab === 'to-ldap' || tab === 'from-ldap') {
|
||||
setLdapLoading(true);
|
||||
ldapApi.getConfig().then((res: any) => {
|
||||
const ready = !!res?.ldap_host;
|
||||
setLdapReady(ready);
|
||||
if (ready && ldapOus.length === 0) {
|
||||
return loadLdapOus();
|
||||
}
|
||||
setLdapLoading(false);
|
||||
}).catch(() => {
|
||||
setLdapReady(false);
|
||||
setLdapLoading(false);
|
||||
});
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
// CSV parse
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = reader.result as string;
|
||||
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
||||
if (lines.length < 2) { toast('error', '文件为空'); return; }
|
||||
|
||||
const header = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const nameIdx = header.findIndex(h => /姓名|name|昵称|显示名/.test(h));
|
||||
const emailIdx = header.findIndex(h => /邮箱|email|mail/.test(h));
|
||||
const roleIdx = header.findIndex(h => /角色|role/.test(h));
|
||||
|
||||
if (nameIdx < 0) { toast('error', 'CSV 需要包含"姓名"列'); return; }
|
||||
|
||||
const rows = lines.slice(1).map(line => {
|
||||
const cols = line.split(',').map(c => c.trim().replace(/^"|"$/g, ''));
|
||||
const name = cols[nameIdx] || '';
|
||||
return {
|
||||
display_name: name,
|
||||
username: nameToUsername(name),
|
||||
password: nameToUsername(name),
|
||||
email: emailIdx >= 0 ? cols[emailIdx] : '',
|
||||
role: roleIdx >= 0 ? cols[roleIdx] || 'developer' : 'developer',
|
||||
};
|
||||
}).filter(r => r.display_name);
|
||||
setPreview(rows);
|
||||
setResult(null);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// LDAP OU loading
|
||||
const loadLdapOus = async () => {
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
const res = await ldapApi.listOus() as any;
|
||||
setLdapOus(Array.isArray(res) ? res : (res?.ous || res?.data || []));
|
||||
} catch { toast('error', '加载 LDAP OU 失败'); }
|
||||
finally { setLdapLoading(false); }
|
||||
};
|
||||
|
||||
const loadLdapUsers = async (baseDn: string) => {
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
const res = await ldapApi.listUsers({ base_dn: baseDn }) as any;
|
||||
setLdapUsers(Array.isArray(res) ? res : (res?.users || res?.data || []));
|
||||
} catch { toast('error', '加载 LDAP 用户失败'); }
|
||||
finally { setLdapLoading(false); }
|
||||
};
|
||||
|
||||
// Create OU
|
||||
const handleCreateOu = async () => {
|
||||
if (!newOuName.trim()) { toast('warning', '请输入 OU 名称'); return; }
|
||||
setCreatingOu(true);
|
||||
try {
|
||||
await ldapApi.createOu({ ou_name: newOuName.trim(), description: newOuDesc.trim() });
|
||||
toast('success', `OU "${newOuName}" 创建成功`);
|
||||
setShowCreateOu(false);
|
||||
setNewOuName('');
|
||||
setNewOuDesc('');
|
||||
await loadLdapOus();
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || '创建 OU 失败');
|
||||
} finally { setCreatingOu(false); }
|
||||
};
|
||||
|
||||
// Import
|
||||
const handleImport = async () => {
|
||||
if (tab === 'local' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
|
||||
if (tab === 'to-ldap' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
|
||||
if (tab === 'to-ldap' && !targetOu) { toast('warning', '请选择目标 OU'); return; }
|
||||
if (tab === 'from-ldap' && selectedLdapUsers.size === 0) { toast('warning', '请选择用户'); return; }
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
let res: any;
|
||||
if (tab === 'local') {
|
||||
res = await userApi.batchImport({ users: preview, import_type: 'local', skip_existing: skipExisting });
|
||||
} else if (tab === 'to-ldap') {
|
||||
res = await ldapApi.batchImportToLdap({ users: preview, ou_dn: targetOu });
|
||||
} else {
|
||||
const users = ldapUsers.filter(u => selectedLdapUsers.has(u.dn));
|
||||
res = await userApi.batchImportLdap({ users });
|
||||
}
|
||||
setResult(res);
|
||||
if (res.success_count > 0 || res.success) onImported();
|
||||
if (tab === 'to-ldap') {
|
||||
toast('success', res.message || `成功 ${res.success_count} 个`);
|
||||
} else {
|
||||
toast('success', `成功导入 ${res.success_count} 个用户`);
|
||||
}
|
||||
} catch { toast('error', '导入失败'); }
|
||||
finally { setImporting(false); }
|
||||
};
|
||||
|
||||
const canImport = () => {
|
||||
if (importing) return false;
|
||||
if (tab === 'local') return preview.length > 0;
|
||||
if (tab === 'to-ldap') return preview.length > 0 && !!targetOu;
|
||||
return selectedLdapUsers.size > 0;
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: 'local' as const, label: '本地导入', icon: Upload },
|
||||
{ key: 'to-ldap' as const, label: '导入到 LDAP', icon: Server },
|
||||
{ key: 'from-ldap' as const, label: '从 LDAP 导入', icon: UsersIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-2xl">
|
||||
<div className="w-full max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<h2 className="text-lg font-semibold">批量导入用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-6 pb-3">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button key={t.key} onClick={() => setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors ${tab === t.key ? 'bg-[var(--dg-blue)] text-white' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||
<Icon className="w-4 h-4" />{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* ── Local Import ── */}
|
||||
{tab === 'local' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Upload className="w-4 h-4" />选择 CSV 文件
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<input type="checkbox" checked={skipExisting} onChange={e => setSkipExisting(e.target.checked)} className="rounded" />
|
||||
跳过已存在用户
|
||||
</label>
|
||||
</div>
|
||||
{preview.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-2 pr-3">姓名</th>
|
||||
<th className="py-2 pr-3">用户名</th>
|
||||
<th className="py-2 pr-3">邮箱</th>
|
||||
<th className="py-2">角色</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.slice(0, 50).map((r, i) => (
|
||||
<tr key={`${r.username}-${i}`} className="border-b border-[var(--dg-border)]/50">
|
||||
<td className="py-2 pr-3">{r.display_name}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{r.email || '-'}</td>
|
||||
<td className="py-2 text-[var(--dg-text-muted)]">{r.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
{preview.length > 50 && (
|
||||
<tr><td colSpan={4} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Import to LDAP ── */}
|
||||
{tab === 'to-ldap' && (
|
||||
<>
|
||||
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
|
||||
{!ldapLoading && !ldapReady && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
|
||||
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && (
|
||||
<>
|
||||
{/* OU selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">目标 OU</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={targetOu}
|
||||
onChange={e => setTargetOu(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
>
|
||||
<option value="">Base DN (根)</option>
|
||||
{ldapOus.map((ou: any) => (
|
||||
<option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={loadLdapOus} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]" title="刷新 OU">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setShowCreateOu(true)} className="flex items-center gap-1 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Plus className="w-3.5 h-3.5" />新建 OU
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* File upload (same CSV format) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Upload className="w-4 h-4" />选择 CSV 文件
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">CSV 需包含"姓名"列,系统自动生成拼音用户名和密码</span>
|
||||
</div>
|
||||
{preview.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-2 pr-3">姓名</th>
|
||||
<th className="py-2 pr-3">用户名(自动)</th>
|
||||
<th className="py-2">密码(自动)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.slice(0, 30).map((r, i) => (
|
||||
<tr key={`${r.username}-${i}`} className="border-b border-[var(--dg-border)]/50">
|
||||
<td className="py-2 pr-3">{r.display_name}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
|
||||
<td className="py-2 text-[var(--dg-text-muted)]">{r.password}</td>
|
||||
</tr>
|
||||
))}
|
||||
{preview.length > 30 && (
|
||||
<tr><td colSpan={3} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── From LDAP Import ── */}
|
||||
{tab === 'from-ldap' && (
|
||||
<>
|
||||
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
|
||||
{!ldapLoading && !ldapReady && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
|
||||
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && ldapOus.length > 0 && ldapUsers.length === 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-2">选择组织单元:</p>
|
||||
{ldapOus.map(ou => (
|
||||
<button key={ou.dn} onClick={() => loadLdapUsers(ou.dn)} className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] flex items-center gap-2">
|
||||
<UsersIcon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<span>{ou.name}</span>
|
||||
{ou.description && <span className="text-[var(--dg-text-muted)]">— {ou.description}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ldapUsers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">{ldapUsers.length} 个用户</p>
|
||||
<button onClick={() => setSelectedLdapUsers(prev => prev.size === ldapUsers.length ? new Set() : new Set(ldapUsers.map(u => u.dn)))} className="text-xs text-[var(--dg-blue)]">
|
||||
{selectedLdapUsers.size === ldapUsers.length ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
{ldapUsers.map(u => (
|
||||
<label key={u.dn} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer">
|
||||
<input type="checkbox" checked={selectedLdapUsers.has(u.dn)} onChange={() => setSelectedLdapUsers(prev => { const n = new Set(prev); n.has(u.dn) ? n.delete(u.dn) : n.add(u.dn); return n; })} />
|
||||
<span className="text-sm font-medium">{u.cn || u.nickname || u.uid}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">@{u.uid || u.username}</span>
|
||||
{u.mail && <span className="text-xs text-[var(--dg-text-muted)]">{u.mail}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && ldapOus.length === 0 && (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] text-center py-4">LDAP 目录中无可用 OU,请先创建 OU 或检查配置</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-sm space-y-1">
|
||||
<p className="text-green-400">成功: {result.success_count ?? (result.success ? 1 : 0)}</p>
|
||||
{result.skipped_count > 0 && <p className="text-yellow-400">跳过: {result.skipped_count}</p>}
|
||||
{result.failed_count > 0 && <p className="text-red-400">失败: {result.failed_count}</p>}
|
||||
{result.failed_users?.map((f: any, i: number) => (
|
||||
<p key={`${f.username}-${i}`} className="text-red-400 text-xs">{f.username}: {f.error}</p>
|
||||
))}
|
||||
{result.message && !result.success_count && <p className="text-[var(--dg-text-secondary)]">{result.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">关闭</button>
|
||||
<button onClick={handleImport} disabled={!canImport()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
|
||||
{importing ? '导入中...' : '确认导入'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create OU sub-dialog */}
|
||||
{showCreateOu && (
|
||||
<ModalOverlay open={showCreateOu} onClose={() => setShowCreateOu(false)} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold">创建新的组织单位 (OU)</h3>
|
||||
<button onClick={() => setShowCreateOu(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">OU 名称 *</label>
|
||||
<input value={newOuName} onChange={e => setNewOuName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="例如:研发部、市场部" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">描述</label>
|
||||
<input value={newOuDesc} onChange={e => setNewOuDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选描述" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={() => setShowCreateOu(false)} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||
<button onClick={handleCreateOu} disabled={creatingOu || !newOuName.trim()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
|
||||
{creatingOu ? '创建中...' : '创建 OU'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 用途:新建用户表单弹窗(用户名/显示名称/密码/邮箱/角色)
|
||||
* 必需 props:onClose(关闭)、onCreated(创建成功后回调,页面负责关窗并刷新列表)
|
||||
* 已知限制:成功 toast 沿用原有反馈点,由本组件内部触发
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { userApi } from '@/api/user';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { validateUserForm } from '../utils';
|
||||
import type { FormErrors } from '../types';
|
||||
|
||||
interface CreateUserModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateUserModal({ onClose, onCreated }: CreateUserModalProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('developer');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validation = validateUserForm({ username, password, email });
|
||||
setErrors(validation);
|
||||
if (Object.keys(validation).length > 0) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
||||
toast('success', '用户创建成功');
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || '创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">新建用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">用户名 *</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={e => { setUsername(e.target.value); if (errors.username) setErrors(p => ({ ...p, username: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.username ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="3-30位字母、数字或下划线"
|
||||
/>
|
||||
{errors.username && <p className="text-xs text-red-400 mt-1">{errors.username}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">显示名称</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="用户的显示名称" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">密码 *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); if (errors.password) setErrors(p => ({ ...p, password: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.password ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="至少6位"
|
||||
/>
|
||||
{errors.password && <p className="text-xs text-red-400 mt-1">{errors.password}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); if (errors.email) setErrors(p => ({ ...p, email: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.email ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="example@company.com"
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-red-400 mt-1">{errors.email}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">角色</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||
<option value="admin">超级管理员</option>
|
||||
<option value="dba">数据库管理员</option>
|
||||
<option value="developer">开发人员</option>
|
||||
<option value="auditor">审计员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 用途:编辑用户弹窗(显示名称/邮箱/角色变更、权限组分配、启用开关)
|
||||
* 必需 props:user(待编辑用户)、onClose(关闭)、onUpdated(保存成功后回调,页面负责关窗并刷新列表)
|
||||
* 已知限制:权限组通过反查实例组成员得到,加载失败时静默为空列表(保持原行为)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { userApi } from '@/api/user';
|
||||
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface EditUserModalProps {
|
||||
user: UserItem;
|
||||
onClose: () => void;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function EditUserModal({ user, onClose, onUpdated }: EditUserModalProps) {
|
||||
const [displayName, setDisplayName] = useState(user.display_name || '');
|
||||
const [email, setEmail] = useState(user.email || '');
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [isActive, setIsActive] = useState(user.is_active);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [groups, setGroups] = useState<InstanceGroup[]>([]);
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
instanceGroupApi.list().then((data: InstanceGroup[]) => {
|
||||
setGroups(data);
|
||||
// Reverse lookup: find which groups contain this user
|
||||
const matched = data
|
||||
.filter(g => g.users?.some(u => u.id === user.id))
|
||||
.map(g => g.id);
|
||||
setSelectedGroupIds(matched);
|
||||
}).catch(() => {});
|
||||
}, [user.id]);
|
||||
|
||||
const toggleGroup = (gid: number) => {
|
||||
setSelectedGroupIds(prev => prev.includes(gid) ? prev.filter(id => id !== gid) : [...prev, gid]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.update(user.id, { display_name: displayName, email: email || undefined, role, is_active: isActive });
|
||||
// Update group associations
|
||||
if (selectedGroupIds.length >= 0) {
|
||||
await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds);
|
||||
}
|
||||
toast('success', '用户更新成功');
|
||||
onUpdated();
|
||||
} catch {
|
||||
toast('error', '更新失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">编辑用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.display_name || user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">显示名称</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">邮箱</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">角色</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||
<option value="admin">超级管理员</option>
|
||||
<option value="dba">数据库管理员</option>
|
||||
<option value="developer">开发人员</option>
|
||||
<option value="auditor">审计员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">权限组</label>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] py-2">暂无可选权限组</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-36 overflow-y-auto">
|
||||
{groups.map(g => (
|
||||
<label key={g.id} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-colors text-sm ${selectedGroupIds.includes(g.id) ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]'}`}>
|
||||
<input type="checkbox" checked={selectedGroupIds.includes(g.id)} onChange={() => toggleGroup(g.id)} className="rounded" />
|
||||
<span className="flex-1 truncate">{g.name}</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">{g.readonly_instance_count || 0}R / {g.write_instance_count || 0}W</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${isActive ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<div className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" style={{ transform: isActive ? 'translateX(20px)' : 'translateX(2px)' }} />
|
||||
</button>
|
||||
<span className="text-sm">{isActive ? '已启用' : '已禁用'}</span>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 用途:重置用户密码弹窗(新密码 + 二次确认)
|
||||
* 必需 props:user(目标用户)、onClose(关闭,成功后也由本组件回调关闭)
|
||||
* 已知限制:仅本地用户可用(LDAP 用户入口在表格行内已禁用)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { XCircle, KeyRound } from 'lucide-react';
|
||||
import { userApi } from '@/api/user';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface ResetPasswordModalProps {
|
||||
user: UserItem;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ResetPasswordModal({ user, onClose }: ResetPasswordModalProps) {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
if (!newPassword) { setError('请输入新密码'); return; }
|
||||
if (newPassword.length < 6) { setError('密码长度至少6位'); return; }
|
||||
if (newPassword !== confirmPassword) { setError('两次输入的密码不一致'); return; }
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.update(user.id, { password: newPassword });
|
||||
toast('success', '密码重置成功');
|
||||
onClose();
|
||||
} catch {
|
||||
toast('error', '密码重置失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-sm">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2"><KeyRound className="w-5 h-5 text-yellow-400" /> 重置密码</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-500/5 border border-yellow-500/15 rounded-lg mb-4">
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">为用户 <strong className="text-[var(--dg-text-primary)]">{user.display_name || user.username}</strong> 设置新密码</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">新密码</label>
|
||||
<input type="password" value={newPassword} onChange={e => { setNewPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="至少6位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">确认密码</label>
|
||||
<input type="password" value={confirmPassword} onChange={e => { setConfirmPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="再次输入" />
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-yellow-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '重置中...' : '确认重置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 用途:用户详情弹窗(基本信息卡片 + 邮箱/创建时间/最后登录/用户ID + 最近活动占位)
|
||||
* 必需 props:user(目标用户)、onClose(关闭)
|
||||
* 已知限制:「最近活动」为静态占位文案,暂未接入活动记录接口
|
||||
*/
|
||||
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
import { roleNameMap, roleTagStyle } from '../constants';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface UserDetailModalProps {
|
||||
user: UserItem;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 详情弹窗「最近活动」使用的时钟图标 */
|
||||
function Clock(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserDetailModal({ user, onClose }: UserDetailModalProps) {
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-lg">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">用户详情</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="flex items-center gap-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-xl mb-5">
|
||||
<div className="w-14 h-14 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white text-lg font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold">{user.display_name || user.username}</div>
|
||||
<div className="text-sm text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${roleTagStyle[user.role] || ''}`}>
|
||||
{roleNameMap[user.role] || user.role}
|
||||
</span>
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${user.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{user.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Fields */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">邮箱</div>
|
||||
<div className="text-sm font-medium truncate">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">创建时间</div>
|
||||
<div className="text-sm font-medium">{new Date(user.created_at).toLocaleDateString('zh-CN')}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">最后登录</div>
|
||||
<div className="text-sm font-medium">{user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '-'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">用户ID</div>
|
||||
<div className="text-sm font-medium">#{user.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Log */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
最近活动
|
||||
</h3>
|
||||
<div className="text-sm text-[var(--dg-text-muted)] text-center py-6">
|
||||
暂无活动记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 用途:用户列表筛选栏(关键字搜索 + 角色/状态/来源下拉 + 重置入口)
|
||||
* 必需 props:search / roleFilter / statusFilter / sourceFilter 及对应 onChange 回调、onReset
|
||||
* 已知限制:搜索为受控输入,每次按键即触发上层重新请求(保持原页面行为)
|
||||
*/
|
||||
|
||||
import { Search, Filter, RotateCcw } from 'lucide-react';
|
||||
import { roleOptions, statusOptions, sourceOptions } from '../constants';
|
||||
|
||||
interface UserFiltersProps {
|
||||
search: string;
|
||||
roleFilter: string;
|
||||
statusFilter: string;
|
||||
sourceFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onRoleFilterChange: (value: string) => void;
|
||||
onStatusFilterChange: (value: string) => void;
|
||||
onSourceFilterChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function UserFilters({
|
||||
search,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
sourceFilter,
|
||||
onSearchChange,
|
||||
onRoleFilterChange,
|
||||
onStatusFilterChange,
|
||||
onSourceFilterChange,
|
||||
onReset,
|
||||
}: UserFiltersProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20"
|
||||
placeholder="搜索用户名/邮箱/显示名称"
|
||||
value={search}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={roleFilter}
|
||||
onChange={e => onRoleFilterChange(e.target.value)}
|
||||
>
|
||||
{roleOptions.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={statusFilter}
|
||||
onChange={e => onStatusFilterChange(e.target.value)}
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={sourceFilter}
|
||||
onChange={e => onSourceFilterChange(e.target.value)}
|
||||
>
|
||||
{sourceOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
{(search || roleFilter || statusFilter || sourceFilter) && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" /> 重置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 用途:用户列表表格(多选、行内启用/禁用开关、详情/编辑/重置密码/删除入口)+ 分页底栏
|
||||
* 必需 props:users / loading / total / page / pageSize / selectedIds / allSelected 及各事件回调
|
||||
* 已知限制:仅 loading 且列表为空时显示加载态;空态「清除筛选条件」由 showClearFilters 控制显隐
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckSquare, Square, Search, Eye, Edit2, Trash2, KeyRound,
|
||||
ChevronLeft, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { roleNameMap, roleTagStyle } from '../constants';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
interface UserTableProps {
|
||||
users: UserItem[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
selectedIds: Set<number>;
|
||||
allSelected: boolean;
|
||||
showClearFilters: boolean;
|
||||
onToggleSelectAll: () => void;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onShowDetail: (user: UserItem) => void;
|
||||
onShowEdit: (user: UserItem) => void;
|
||||
onShowResetPassword: (user: UserItem) => void;
|
||||
onToggleActive: (user: UserItem) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onClearFilters: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function UserTable({
|
||||
users,
|
||||
loading,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
selectedIds,
|
||||
allSelected,
|
||||
showClearFilters,
|
||||
onToggleSelectAll,
|
||||
onToggleSelect,
|
||||
onShowDetail,
|
||||
onShowEdit,
|
||||
onShowResetPassword,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
onClearFilters,
|
||||
onPageChange,
|
||||
}: UserTableProps) {
|
||||
return (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{loading && users.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">没有找到匹配的用户</p>
|
||||
{showClearFilters && (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
|
||||
>
|
||||
清除筛选条件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-3 py-3 w-10">
|
||||
<button onClick={onToggleSelectAll} className="hover:opacity-70 transition-opacity">
|
||||
{allSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">用户</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">角色</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">邮箱</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">最后登录</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">来源</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => {
|
||||
const isSelected = selectedIds.has(user.id);
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={`border-b border-[var(--dg-border-light)] transition-colors ${
|
||||
isSelected ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-3.5">
|
||||
<button onClick={() => onToggleSelect(user.id)} className="hover:opacity-70 transition-opacity">
|
||||
{isSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => onShowDetail(user)}>
|
||||
<div>
|
||||
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'}`}>
|
||||
{roleNameMap[user.role] || user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">
|
||||
{user.last_login
|
||||
? new Date(user.last_login).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button
|
||||
onClick={() => onToggleActive(user)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
title={user.is_active ? '点击禁用' : '点击启用'}
|
||||
aria-label={user.is_active ? '禁用用户' : '启用用户'}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all"
|
||||
style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
|
||||
user.auth_source === 'ldap'
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
|
||||
}`}>
|
||||
{user.auth_source === 'ldap' ? 'LDAP' : '本地'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => onShowDetail(user)}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onShowEdit(user)}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => user.auth_source !== 'ldap' && onShowResetPassword(user)}
|
||||
className={`p-1.5 rounded-md transition-colors ${user.auth_source === 'ldap' ? 'opacity-30 cursor-not-allowed' : 'hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
|
||||
title={user.auth_source === 'ldap' ? 'LDAP 用户密码由 LDAP 服务器管理' : '重置密码'}
|
||||
disabled={user.auth_source === 'ldap'}
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded-md hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
|
||||
onClick={() => onDelete(user.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">
|
||||
共 <strong className="text-[var(--dg-text-primary)]">{total}</strong> 条
|
||||
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
|
||||
</span>
|
||||
{total > pageSize && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
||||
{page} / {Math.ceil(total / pageSize)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(Math.ceil(total / pageSize), page + 1))}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* ───────── RBAC 用户管理域常量 ───────── */
|
||||
|
||||
import { UserCheck, UserX, Trash2 } from 'lucide-react';
|
||||
|
||||
/** 角色值 → 中文名 */
|
||||
export const roleNameMap: Record<string, string> = {
|
||||
admin: '超级管理员',
|
||||
dba: '数据库管理员',
|
||||
developer: '开发人员',
|
||||
auditor: '审计员',
|
||||
};
|
||||
|
||||
/** 角色值 → 标签样式 */
|
||||
export const roleTagStyle: Record<string, string> = {
|
||||
admin: 'bg-cyan-500/15 text-cyan-400',
|
||||
dba: 'bg-blue-500/15 text-blue-400',
|
||||
developer: 'bg-green-500/15 text-green-400',
|
||||
auditor: 'bg-orange-500/15 text-orange-400',
|
||||
};
|
||||
|
||||
/** 筛选栏角色下拉选项 */
|
||||
export const roleOptions = [
|
||||
{ value: '', label: '全部角色' },
|
||||
{ value: 'admin', label: '超级管理员' },
|
||||
{ value: 'dba', label: '数据库管理员' },
|
||||
{ value: 'developer', label: '开发人员' },
|
||||
{ value: 'auditor', label: '审计员' },
|
||||
];
|
||||
|
||||
/** 筛选栏状态下拉选项 */
|
||||
export const statusOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'active', label: '启用' },
|
||||
{ value: 'disabled', label: '禁用' },
|
||||
];
|
||||
|
||||
/** 筛选栏来源下拉选项 */
|
||||
export const sourceOptions = [
|
||||
{ value: '', label: '全部来源' },
|
||||
{ value: 'local', label: '本地' },
|
||||
{ value: 'ldap', label: 'LDAP' },
|
||||
];
|
||||
|
||||
/** 批量操作菜单选项 */
|
||||
export const BATCH_OPTIONS = [
|
||||
{ key: 'enable', label: '批量启用', icon: UserCheck },
|
||||
{ key: 'disable', label: '批量禁用', icon: UserX },
|
||||
{ key: 'delete', label: '批量删除', icon: Trash2 },
|
||||
] as const;
|
||||
|
||||
/** 批量操作键类型 */
|
||||
export type BatchActionKey = (typeof BATCH_OPTIONS)[number]['key'];
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 用途:管理用户表格的多选状态(单选切换 / 全选切换 / 选中集合)
|
||||
* 必需参数:visible(当前可见的用户列表,用于计算全选状态)
|
||||
* 已知限制:翻页或刷新后不自动清除选中(与原页面行为一致,由调用方决定何时清空)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
export function useUserSelection(visible: UserItem[]) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const allSelected = visible.length > 0 && visible.every(u => selectedIds.has(u.id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
const next = new Set(selectedIds);
|
||||
visible.forEach(u => next.delete(u.id));
|
||||
setSelectedIds(next);
|
||||
} else {
|
||||
const next = new Set(selectedIds);
|
||||
visible.forEach(u => next.add(u.id));
|
||||
setSelectedIds(next);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
return { selectedIds, setSelectedIds, allSelected, toggleSelectAll, toggleSelect };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 用途:按分页与筛选条件拉取用户列表(参数组装与后端接口保持原样)
|
||||
* 必需参数:page / pageSize / search / roleFilter / statusFilter
|
||||
* 已知限制:来源(sourceFilter)为前端过滤,不参与请求;失败时 toast 必现
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { userApi } from '@/api/user';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import type { UserItem } from '../types';
|
||||
|
||||
export interface UseUsersListParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
roleFilter: string;
|
||||
statusFilter: string;
|
||||
}
|
||||
|
||||
export function useUsersList({ page, pageSize, search, roleFilter, statusFilter }: UseUsersListParams) {
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: pageSize };
|
||||
if (search) params.search = search;
|
||||
if (roleFilter) params.role = roleFilter;
|
||||
if (statusFilter === 'active') params.is_active = true;
|
||||
else if (statusFilter === 'disabled') params.is_active = false;
|
||||
const res = await userApi.list(params);
|
||||
const data = res as any;
|
||||
setUsers(Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : []);
|
||||
setTotal(data?.total ?? data?.length ?? 0);
|
||||
} catch {
|
||||
toast('error', '获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, search, roleFilter, statusFilter]);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
return { users, total, loading, fetchUsers };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* ───────── RBAC 用户管理域共享类型 ───────── */
|
||||
|
||||
/** 用户条目(列表 / 详情 / 编辑共用) */
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login?: string | null;
|
||||
auth_source?: string; // 'local' | 'ldap'
|
||||
}
|
||||
|
||||
/** 用户动态记录(详情页最近活动预留) */
|
||||
export interface UserActivity {
|
||||
id: string;
|
||||
action: string;
|
||||
target: string;
|
||||
created_at: string;
|
||||
status: 'success' | 'failed';
|
||||
}
|
||||
|
||||
/** 新建用户表单校验错误 */
|
||||
export interface FormErrors {
|
||||
username?: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ───────── RBAC 用户管理域工具函数 ───────── */
|
||||
|
||||
import type { UserItem, FormErrors } from './types';
|
||||
import { roleNameMap } from './constants';
|
||||
|
||||
/** 新建用户表单校验(用户名/密码/邮箱) */
|
||||
export function validateUserForm(data: { username: string; password: string; email: string }): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
if (!data.username.trim()) {
|
||||
errors.username = '用户名不能为空';
|
||||
} else if (!/^[a-zA-Z0-9_]{3,30}$/.test(data.username)) {
|
||||
errors.username = '用户名只能包含字母、数字和下划线,长度3-30';
|
||||
}
|
||||
if (!data.password) {
|
||||
errors.password = '密码不能为空';
|
||||
} else if (data.password.length < 6) {
|
||||
errors.password = '密码长度至少6位';
|
||||
}
|
||||
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.email = '邮箱格式不正确';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** 导出用户列表为 CSV(带 BOM,Excel 可直接打开) */
|
||||
export function exportUsersToCSV(users: UserItem[]) {
|
||||
const headers = ['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间'];
|
||||
const rows = users.map(u => [
|
||||
u.username,
|
||||
u.display_name || '',
|
||||
u.email || '',
|
||||
roleNameMap[u.role] || u.role,
|
||||
u.is_active ? '启用' : '禁用',
|
||||
u.created_at,
|
||||
]);
|
||||
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in new issue
Block a user