feat: v0.4.0 主题切换 + LLM 全面优化 + 登录会话 + 用户管理/LDAP
主题: - 深色/浅色/跟随系统(useTheme:本地缓存先行+远端同步+matchMedia), 侧边栏快捷切换,设置页三档即选即生效 LLM(参考 opsxdb/aiops): - llm.Client.ChatStream 流式(SSE 增量解析,透传 reasoning_content, ctx 控制超时);POST /api/ai/chat/stream(平台实时数据注入降幻觉, 未配 LLM 降级规则 JSON,指令解析 action 不变) - test-llm 支持草稿直测(保存前验证,apiKey 空/掩码回退已存) - 发布失败自动 AI 分析日志尾部(结论/根因/建议),发布记录与发布 Tab 展示分析卡片 - 设置页 Provider 预设(DeepSeek/GLM/GLM编码/Qwen/Kimi/Ollama/自定义) 自动填 endpoint+模型+获取 Key 直达链接 登录与会话: - HMAC 签名 token(7 天,密钥派生自 encryption.key); authMiddleware 保护 /api/*(豁免登录/健康/SSE日志流/入站webhook) - 登录页 /login + 未登录跳转 + 登出;401 自动清理跳登录 用户管理与 LDAP(借鉴 LokiStack): - /users 页面:本地用户 CRUD、一次性密码、角色切换、来源徽章 (本地·蓝 | LDAP导入·紫);LDAP 导入按钮仅在启用且配置齐备时显示 - LDAP 配置卡(未启用仅提示语)、测试连接草稿直测+可选测试账号 - go-ldap:管理 Bind/用户搜索(防注入)/用户 DN 直连验证; 批量导入冲突策略(已LDAP跳过/本地同名转LDAP/新建); 登录分流本地 argon2id 或 LDAP 目录验证 + 首登 JIT 建号
This commit is contained in:
1 parent
555934cc64
commit
97193eb090
24 files changed
+2422
-98
No files matched your search
+27
-2
@@ -22,9 +22,11 @@ import GitOpsDeploy from '@/pages/GitOpsDeploy';
|
||||
import PluginManager from '@/pages/PluginManager';
|
||||
import SystemSettings from '@/pages/SystemSettings';
|
||||
import AiAssistant from '@/components/ai/AiAssistant';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import Login from '@/pages/Login';
|
||||
import UserManage from '@/pages/UserManage';
|
||||
import { apiClient, auth } from '@/lib/api';
|
||||
|
||||
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
||||
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'settings';
|
||||
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||
|
||||
// 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等)
|
||||
@@ -41,6 +43,7 @@ const ROUTE_TO_MODULE: Record<string, ModuleType> = {
|
||||
'/jenkins-import': 'jenkins-import',
|
||||
'/webhooks': 'webhooks',
|
||||
'/plugins': 'plugins',
|
||||
'/users': 'users',
|
||||
'/settings': 'settings',
|
||||
};
|
||||
|
||||
@@ -56,12 +59,27 @@ const MODULE_TO_ROUTE: Record<ModuleType, string> = {
|
||||
'jenkins-import': '/jenkins-import',
|
||||
webhooks: '/webhooks',
|
||||
plugins: '/plugins',
|
||||
users: '/users',
|
||||
settings: '/settings',
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [loggedIn, setLoggedIn] = useState(!!auth.getToken());
|
||||
|
||||
// 未登录访问任何页面 → 跳登录页
|
||||
useEffect(() => {
|
||||
if (!loggedIn && location.pathname !== '/login') {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [loggedIn, location.pathname, navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
auth.clear();
|
||||
setLoggedIn(false);
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
// Module navigation
|
||||
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
|
||||
@@ -215,6 +233,8 @@ export default function App() {
|
||||
return <GitOpsDeploy />;
|
||||
case 'plugins':
|
||||
return <PluginManager />;
|
||||
case 'users':
|
||||
return <UserManage />;
|
||||
case 'settings':
|
||||
return <SystemSettings />;
|
||||
default:
|
||||
@@ -222,6 +242,10 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!loggedIn || location.pathname === '/login') {
|
||||
return <Login onSuccess={() => { setLoggedIn(true); navigate('/pipelines', { replace: true }); }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex overflow-hidden" style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}>
|
||||
<Toaster position="top-right" richColors />
|
||||
@@ -230,6 +254,7 @@ export default function App() {
|
||||
onModuleChange={goToModule}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{renderContent()}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket } from 'lucide-react';
|
||||
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { apiClient, type PipelineRunDTO } from '@/lib/api';
|
||||
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
|
||||
|
||||
interface Props {
|
||||
pipelineId: number;
|
||||
}
|
||||
|
||||
// 列表行:PipelineRunDTO + 失败时自动生成的 AI 分析字段
|
||||
type RunRow = PipelineRunDTO & { analysis?: string };
|
||||
|
||||
const statusMap: Record<string, { color: string; bg: string; label: string }> = {
|
||||
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||
@@ -32,7 +35,7 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
||||
|
||||
const { data: runs = [], isLoading } = useQuery({
|
||||
queryKey: ['runs', { pipelineId }],
|
||||
queryFn: () => apiClient.listRuns({ pipelineId }),
|
||||
queryFn: () => apiClient.listRuns({ pipelineId }) as Promise<RunRow[]>,
|
||||
});
|
||||
|
||||
const { data: detail } = useQuery({
|
||||
@@ -132,6 +135,19 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* AI 失败分析(紧凑版,日志区上方) */}
|
||||
{r.analysis ? (
|
||||
<div className="mb-2 rounded-md border px-3 py-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||
<div className="flex items-center gap-1 text-[11px] font-semibold mb-1" style={{ color: '#8250df' }}>
|
||||
<Sparkles className="w-3 h-3" />AI 失败分析
|
||||
</div>
|
||||
<div className="text-[11px] leading-[18px] whitespace-pre-wrap" style={{ color: 'var(--text-primary)' }}>{r.analysis}</div>
|
||||
</div>
|
||||
) : ['running', 'pending', 'queued'].includes(r.status) ? (
|
||||
<div className="mb-2 flex items-center gap-1 text-[10px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />失败后将自动生成 AI 分析
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
|
||||
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
|
||||
|
||||
@@ -13,18 +13,24 @@ import {
|
||||
Webhook,
|
||||
CloudSync,
|
||||
Puzzle,
|
||||
Settings
|
||||
Settings,
|
||||
Users,
|
||||
Sun,
|
||||
Moon,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { useTheme } from '@/providers/theme';
|
||||
|
||||
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
||||
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'settings';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: ModuleType;
|
||||
onModuleChange: (module: ModuleType) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch; badge: string | null }[] }[] = [
|
||||
@@ -57,18 +63,20 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
|
||||
label: '系统',
|
||||
items: [
|
||||
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
|
||||
{ id: 'users', label: '用户管理', icon: Users, badge: null },
|
||||
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) {
|
||||
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse, onLogout }: SidebarProps) {
|
||||
// 徽标数来自后端真实统计
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['summary'],
|
||||
queryFn: apiClient.getSummary,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const { toggle: themeToggle, isDark } = useTheme();
|
||||
|
||||
const badgeFor = (id: ModuleType): string | null => {
|
||||
if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null;
|
||||
@@ -167,11 +175,27 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div
|
||||
className="px-2 py-2 border-t flex justify-center"
|
||||
{/* Bottom bar: theme toggle + collapse */}
|
||||
<div
|
||||
className="px-2 py-2 border-t flex items-center justify-center gap-1"
|
||||
style={{ borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<button
|
||||
onClick={themeToggle}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title="切换深色/浅色主题"
|
||||
>
|
||||
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot } from 'lucide-react';
|
||||
import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot, Square } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { apiClient, auth } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
onNavigate?: (module: string) => void;
|
||||
@@ -11,6 +11,9 @@ interface Props {
|
||||
interface ChatItem {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
streaming?: boolean;
|
||||
stopped?: boolean;
|
||||
action?: any; // {type:'release', jobs, branch, env}
|
||||
actionState?: 'pending' | 'done' | 'cancelled';
|
||||
result?: { triggered: any[]; failed: any[] };
|
||||
@@ -21,6 +24,21 @@ const suggestions = [
|
||||
'把后端两个服务发到 pre',
|
||||
];
|
||||
|
||||
// 解析一个 SSE 事件块(不含结尾空行):event: xxx / data: {...}
|
||||
function parseSSEChunk(chunk: string): { event: string; data: string } | null {
|
||||
let event = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (!line || line.startsWith(':')) continue; // 注释/空行
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
return { event, data: dataLines.join('\n') };
|
||||
}
|
||||
|
||||
const isJsonInstruction = (s: string) => s.trimStart().startsWith('{');
|
||||
|
||||
export default function AiAssistant({ onNavigate }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
@@ -29,6 +47,7 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
{ role: 'assistant', content: '你好,我是 FlowStack 智能发布助手。告诉我你要发布哪些服务(支持自然语言),例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。' },
|
||||
]);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
@@ -49,20 +68,122 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
if (!content || sending) return;
|
||||
setInput('');
|
||||
const userMsg: ChatItem = { role: 'user', content };
|
||||
const next = [...messages, userMsg];
|
||||
setMessages(next);
|
||||
const assistantIndex = messages.length + 1; // 占位 assistant 气泡下标
|
||||
const history = [...messages, userMsg].slice(-10).map((m) => ({ role: m.role, content: m.content }));
|
||||
setMessages((prev) => [...prev, userMsg, { role: 'assistant', content: '', streaming: true }]);
|
||||
setSending(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const patch = (updater: (m: ChatItem) => ChatItem) => {
|
||||
setMessages((prev) => prev.map((m, i) => (i === assistantIndex ? updater(m) : m)));
|
||||
};
|
||||
|
||||
let accContent = '';
|
||||
let accReasoning = '';
|
||||
let gotDone = false;
|
||||
|
||||
const applyDone = (data: { reply?: string; action?: any }) => {
|
||||
gotDone = true;
|
||||
const reply = typeof data?.reply === 'string' && data.reply ? data.reply : accContent;
|
||||
patch((m) => ({
|
||||
...m,
|
||||
content: reply,
|
||||
action: data?.action,
|
||||
actionState: data?.action ? 'pending' : undefined,
|
||||
streaming: false,
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
const history = next.slice(-10).map((m) => ({ role: m.role, content: m.content }));
|
||||
const res = await apiClient.aiChat(history);
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: res.reply, action: res.action, actionState: res.action ? 'pending' : undefined }]);
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = auth.getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/ai/chat/stream', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ messages: history }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
auth.clear();
|
||||
if (!window.location.pathname.startsWith('/login')) window.location.href = '/login';
|
||||
throw new Error('登录已失效');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = `请求失败 (HTTP ${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error) message = body.error;
|
||||
} catch {
|
||||
// 非 JSON 响应体
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const ctype = res.headers.get('content-type') || '';
|
||||
if (!ctype.includes('text/event-stream')) {
|
||||
// 后端未配置 LLM:直接返回普通 JSON {reply, viaAI:false, action}
|
||||
const data = await res.json();
|
||||
applyDone(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 手写 SSE 解析:事件以空行分隔
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
const handleChunk = (chunk: string) => {
|
||||
const parsed = parseSSEChunk(chunk);
|
||||
if (!parsed) return;
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = JSON.parse(parsed.data);
|
||||
} catch {
|
||||
payload = { content: parsed.data };
|
||||
}
|
||||
if (parsed.event === 'delta') {
|
||||
if (typeof payload?.content === 'string' && payload.content) accContent += payload.content;
|
||||
if (typeof payload?.reasoning === 'string' && payload.reasoning) accReasoning += payload.reasoning;
|
||||
patch((m) => ({ ...m, content: accContent, reasoning: accReasoning }));
|
||||
} else if (parsed.event === 'done') {
|
||||
applyDone(payload);
|
||||
}
|
||||
// start / 其他事件:无需处理
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
||||
const chunk = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
if (chunk.trim()) handleChunk(chunk);
|
||||
}
|
||||
}
|
||||
if (buf.trim()) handleChunk(buf.trim()); // 冲刷结尾未跟空行的残包
|
||||
if (!gotDone) patch((m) => ({ ...m, streaming: false }));
|
||||
} catch (err) {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: '请求失败:' + (err as Error).message }]);
|
||||
if ((err as any)?.name === 'AbortError') {
|
||||
// 用户主动停止:保留已生成内容
|
||||
patch((m) => ({ ...m, content: accContent || m.content, streaming: false, stopped: true }));
|
||||
} else if (accContent) {
|
||||
// 中途断流:保留已有内容并标记
|
||||
patch((m) => ({ ...m, content: accContent, streaming: false, stopped: true }));
|
||||
} else {
|
||||
patch(() => ({ role: 'assistant', content: '请求失败:' + (err as Error).message }));
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopStream = () => abortRef.current?.abort();
|
||||
|
||||
const confirmRelease = async (index: number) => {
|
||||
const msg = messages[index];
|
||||
if (!msg?.action) return;
|
||||
@@ -106,7 +227,7 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
{/* 对话面板 */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed bottom-22 right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
|
||||
className="fixed right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
|
||||
style={{ bottom: 84, height: 520, background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
{/* 头部 */}
|
||||
@@ -139,9 +260,36 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
: { background: 'var(--panel-bg-secondary)', color: 'var(--text-primary)', borderBottomLeftRadius: 4 }
|
||||
}
|
||||
>
|
||||
{m.content}
|
||||
{/* reasoning 增量:灰色小字(仅推理过程中展示,最近 120 字符) */}
|
||||
{m.role === 'assistant' && m.streaming && !!m.reasoning && (
|
||||
<div className="italic text-[10px] leading-4 mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{m.reasoning.length > 120 ? '…' : ''}{m.reasoning.slice(-120)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
if (m.role === 'assistant' && m.streaming && isJsonInstruction(m.content)) {
|
||||
// 指令解析 JSON:不展示原始流
|
||||
return <span className="inline-flex items-center gap-1 animate-pulse">📋 解析发布指令中…</span>;
|
||||
}
|
||||
if (m.role === 'assistant' && !m.streaming && isJsonInstruction(m.content) && m.action) {
|
||||
return <span>已识别发布指令,请确认:</span>;
|
||||
}
|
||||
if (m.role === 'assistant' && m.streaming && !m.content && !m.reasoning) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />思考中…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return m.content;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{m.stopped && (
|
||||
<div className="mt-1 text-[10px] italic" style={{ color: 'var(--text-tertiary)' }}>已停止</div>
|
||||
)}
|
||||
|
||||
{/* 发布计划卡片 */}
|
||||
{m.action && m.action.type === 'release' && (
|
||||
<div className="mt-2 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
|
||||
@@ -215,14 +363,6 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sending && (
|
||||
<div className="flex justify-start">
|
||||
<div className="px-3 py-2 rounded-xl flex items-center gap-1.5" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--accent-blue)' }} />
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>思考中…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 快捷示例 */}
|
||||
@@ -251,14 +391,25 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
className="flex-1 px-3 py-2 text-[12px] rounded-lg border outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => send()}
|
||||
disabled={sending || !input.trim()}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{sending ? (
|
||||
<button
|
||||
onClick={stopStream}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white flex-shrink-0"
|
||||
style={{ background: '#cf222e' }}
|
||||
title="停止生成"
|
||||
>
|
||||
<Square className="w-3 h-3" fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => send()}
|
||||
disabled={!input.trim()}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+32
-1
@@ -7,11 +7,30 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'flowstack-token'
|
||||
|
||||
export const auth = {
|
||||
getToken: () => localStorage.getItem(TOKEN_KEY),
|
||||
setToken: (t: string) => localStorage.setItem(TOKEN_KEY, t),
|
||||
clear: () => localStorage.removeItem(TOKEN_KEY),
|
||||
}
|
||||
|
||||
export async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const token = auth.getToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const res = await fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||
headers: { ...headers, ...(init?.headers as Record<string, string> || {}) },
|
||||
...init,
|
||||
})
|
||||
if (res.status === 401 && !path.includes('/auth/login')) {
|
||||
// 登录失效:清理并跳登录页(避免循环)
|
||||
auth.clear()
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
throw new ApiError('登录已失效', 401)
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = `请求失败 (HTTP ${res.status})`
|
||||
try {
|
||||
@@ -154,5 +173,17 @@ export const apiClient = {
|
||||
batchParse: (b: { text: string; refType?: string; fixedRef?: string }) =>
|
||||
api<any>('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }),
|
||||
testLLM: () => api<any>('/api/settings/test-llm', { method: 'POST' }),
|
||||
testLLMDraft: (body: any) => api<any>('/api/settings/test-llm', { method: 'POST', body: JSON.stringify(body) }),
|
||||
testLark: () => api<any>('/api/settings/test-lark', { method: 'POST' }),
|
||||
// 用户与会话
|
||||
login: (username: string, password: string) =>
|
||||
api<any>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
listUsers: () => api<any[]>('/api/users'),
|
||||
createUser: (b: any) => api<any>('/api/users', { method: 'POST', body: JSON.stringify(b) }),
|
||||
updateUser: (id: any, b: any) => api(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||
deleteUser: (id: any) => api(`/api/users/${id}`, { method: 'DELETE' }),
|
||||
resetUserPassword: (id: any) => api<any>(`/api/users/${id}/reset-password`, { method: 'POST' }),
|
||||
ldapListUsers: () => api<any[]>('/api/ldap/users', { method: 'POST' }),
|
||||
ldapImport: (users: any[]) => api<any>('/api/users/ldap-import', { method: 'POST', body: JSON.stringify({ users }) }),
|
||||
testLDAP: (body: any) => api<any>('/api/settings/ldap/test', { method: 'POST', body: JSON.stringify(body) }),
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { Rocket, Loader2, KeyRound, User } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient, auth } from '@/lib/api';
|
||||
|
||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
toast.error('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.login(username.trim(), password);
|
||||
auth.setToken(res.token);
|
||||
toast.success(`欢迎,${res.nickname || res.username}`);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error('登录失败', { description: (err as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div className="w-[380px] rounded-xl border shadow-lg p-8" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}>
|
||||
<Rocket className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-[18px] font-bold" style={{ color: 'var(--text-primary)' }}>FlowStack</h1>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>发布系统 · 请登录</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</label>
|
||||
<div className="relative">
|
||||
<User className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full pl-9 pr-3 py-2 text-[13px] rounded-md border outline-none"
|
||||
style={inputStyle}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码</label>
|
||||
<div className="relative">
|
||||
<KeyRound className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-[13px] rounded-md border outline-none"
|
||||
style={inputStyle}
|
||||
placeholder="初始密码见 data/initial-admin-password.txt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 text-[13px] font-medium rounded-md text-white disabled:opacity-60 flex items-center justify-center gap-2"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
登 录
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-[11px] text-center mt-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
支持本地账号与 LDAP 账号(启用后自动识别)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Search, GitBranch,
|
||||
CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
|
||||
RotateCcw, ExternalLink, Calendar, User, Container, Layers,
|
||||
Wifi, WifiOff, Loader2
|
||||
Wifi, WifiOff, Loader2, Sparkles
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -54,6 +54,7 @@ interface RunRecord {
|
||||
durationMs?: number;
|
||||
buildNumber?: number;
|
||||
externalUrl?: string;
|
||||
analysis?: string; // 失败时后端自动生成的 AI 失败分析
|
||||
}
|
||||
|
||||
interface RunStage {
|
||||
@@ -144,6 +145,8 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
||||
|
||||
const logsToRender: RunLog[] = isRunning ? liveLogs : (detail?.logs ?? []);
|
||||
|
||||
const analysis: string | undefined = detail?.run?.analysis ?? record.analysis;
|
||||
|
||||
return (
|
||||
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-0 px-4 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
@@ -165,6 +168,19 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{/* AI 失败分析(日志区上方) */}
|
||||
{analysis ? (
|
||||
<div className="mb-4 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-semibold mb-1.5" style={{ color: '#8250df' }}>
|
||||
<Sparkles className="w-3.5 h-3.5" />AI 失败分析
|
||||
</div>
|
||||
<div className="text-[12px] leading-5 whitespace-pre-wrap" style={{ color: 'var(--text-primary)' }}>{analysis}</div>
|
||||
</div>
|
||||
) : isRunning ? (
|
||||
<div className="mb-4 flex items-center gap-1.5 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />失败后将自动生成 AI 分析
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab === 'stages' && (
|
||||
detailLoading && stages.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Palette, Monitor, Moon, Sun,
|
||||
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2
|
||||
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2, ExternalLink, ChevronDown, ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { useTheme, type ThemeMode } from '@/providers/theme';
|
||||
|
||||
interface ThemeConfig {
|
||||
mode: 'light' | 'dark' | 'system';
|
||||
@@ -62,12 +63,48 @@ const colorOptions = [
|
||||
{ name: '青色', value: '#0e7c7b', class: 'bg-[#0e7c7b]' },
|
||||
];
|
||||
|
||||
// LLM 服务商预设(OpenAI 兼容接口)
|
||||
const LLM_PROVIDERS = [
|
||||
{ id: 'deepseek', label: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', model: 'deepseek-chat', keyUrl: 'https://platform.deepseek.com/api_keys' },
|
||||
{ id: 'glm', label: '智谱 GLM', endpoint: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' },
|
||||
{ id: 'glm-coding', label: '智谱 GLM 编码套餐', endpoint: 'https://open.bigmodel.cn/api/coding/paas/v4', model: 'glm-4.7', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' },
|
||||
{ id: 'qwen', label: '阿里 Qwen', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', keyUrl: 'https://bailian.console.aliyun.com/' },
|
||||
{ id: 'kimi', label: 'Kimi', endpoint: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', keyUrl: 'https://platform.moonshot.cn/console/api-keys' },
|
||||
{ id: 'ollama', label: 'Ollama 本地', endpoint: 'http://localhost:11434/v1', model: 'qwen2.5:7b', keyUrl: '' },
|
||||
{ id: 'custom', label: '自定义…', endpoint: '', model: '', keyUrl: '' },
|
||||
];
|
||||
|
||||
// 按 endpoint 反匹配预设(匹配不到返回 custom)
|
||||
function matchProviderByEndpoint(endpoint?: string | null): string {
|
||||
if (!endpoint) return 'custom';
|
||||
const hit = LLM_PROVIDERS.find((p) => p.id !== 'custom' && p.endpoint === endpoint);
|
||||
return hit ? hit.id : 'custom';
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { change: themeChange } = useTheme();
|
||||
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
||||
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
||||
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
|
||||
const [llmProvider, setLlmProvider] = useState('custom');
|
||||
const [lark, setLark] = useState({ enabled: false, webhook: '', secret: '' });
|
||||
const [ldap, setLdap] = useState({
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: '389',
|
||||
useSSL: false,
|
||||
bindDN: '',
|
||||
bindPassword: '',
|
||||
baseDN: '',
|
||||
userFilter: '(objectClass=inetOrgPerson)',
|
||||
usernameAttr: 'uid',
|
||||
nameAttr: 'displayName',
|
||||
emailAttr: 'mail',
|
||||
});
|
||||
// 测试连接的可选「测试账号」展开态
|
||||
const [ldapTestOpen, setLdapTestOpen] = useState(false);
|
||||
const [ldapTest, setLdapTest] = useState({ username: '', password: '' });
|
||||
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||
|
||||
@@ -83,12 +120,27 @@ export default function SystemSettings() {
|
||||
model: settings.llmModel || '',
|
||||
apiKey: '', // 只写:回显永远留空
|
||||
}));
|
||||
setLlmProvider(matchProviderByEndpoint(settings.llmEndpoint));
|
||||
setLark((prev) => ({
|
||||
...prev,
|
||||
enabled: !!settings.larkEnabled,
|
||||
webhook: settings.larkWebhook || '',
|
||||
secret: '',
|
||||
}));
|
||||
setLdap((prev) => ({
|
||||
...prev,
|
||||
enabled: !!settings.ldapEnabled,
|
||||
host: settings.ldapHost || '',
|
||||
port: settings.ldapPort ? String(settings.ldapPort) : '389',
|
||||
useSSL: !!settings.ldapUseSSL,
|
||||
bindDN: settings.ldapBindDN || '',
|
||||
bindPassword: '', // 只写:回显永远留空
|
||||
baseDN: settings.ldapBaseDN || '',
|
||||
userFilter: settings.ldapUserFilter || '(objectClass=inetOrgPerson)',
|
||||
usernameAttr: settings.ldapUsernameAttr || 'uid',
|
||||
nameAttr: settings.ldapNameAttr || 'displayName',
|
||||
emailAttr: settings.ldapEmailAttr || 'mail',
|
||||
}));
|
||||
}, [settings]);
|
||||
|
||||
const saveLarkMutation = useMutation({
|
||||
@@ -114,6 +166,54 @@ export default function SystemSettings() {
|
||||
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const saveLdapMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: any = {
|
||||
ldapEnabled: true,
|
||||
ldapHost: ldap.host,
|
||||
ldapPort: Number(ldap.port) || 389,
|
||||
ldapUseSSL: ldap.useSSL,
|
||||
ldapBindDN: ldap.bindDN,
|
||||
ldapBaseDN: ldap.baseDN,
|
||||
ldapUserFilter: ldap.userFilter,
|
||||
ldapUsernameAttr: ldap.usernameAttr,
|
||||
ldapNameAttr: ldap.nameAttr,
|
||||
ldapEmailAttr: ldap.emailAttr,
|
||||
};
|
||||
if (ldap.bindPassword) body.ldapBindPassword = ldap.bindPassword;
|
||||
return apiClient.updateSettings(body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('LDAP 配置已保存', { description: '可在「用户管理」页从 LDAP 导入用户' });
|
||||
setLdap((p) => ({ ...p, bindPassword: '' }));
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const testLdapMutation = useMutation({
|
||||
// 草稿直测:用当前表单值(无需先保存),Bind 密码留空时后端回退用已存密码
|
||||
mutationFn: () =>
|
||||
apiClient.testLDAP({
|
||||
enabled: true,
|
||||
host: ldap.host,
|
||||
port: Number(ldap.port) || 389,
|
||||
useSSL: ldap.useSSL,
|
||||
bindDN: ldap.bindDN,
|
||||
bindPassword: ldap.bindPassword || undefined,
|
||||
baseDN: ldap.baseDN,
|
||||
userFilter: ldap.userFilter,
|
||||
usernameAttr: ldap.usernameAttr,
|
||||
testUsername: ldapTest.username || undefined,
|
||||
testPassword: ldapTest.password || undefined,
|
||||
}),
|
||||
onSuccess: (res: any) => {
|
||||
if (res.success) toast.success('LDAP 连接成功', { description: res.message });
|
||||
else toast.error('LDAP 连接失败', { description: res.message });
|
||||
},
|
||||
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const saveLlmMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
|
||||
@@ -129,7 +229,13 @@ export default function SystemSettings() {
|
||||
});
|
||||
|
||||
const testLlmMutation = useMutation({
|
||||
mutationFn: apiClient.testLLM,
|
||||
// 草稿直测:用当前表单值(无需先保存)
|
||||
mutationFn: () => apiClient.testLLMDraft({
|
||||
llmEnabled: true,
|
||||
llmEndpoint: llm.endpoint,
|
||||
llmModel: llm.model,
|
||||
llmApiKey: llm.apiKey || undefined,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) toast.success('AI 连接成功', { description: res.message });
|
||||
else toast.error('AI 连接失败', { description: res.message });
|
||||
@@ -191,14 +297,14 @@ export default function SystemSettings() {
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Mode */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式</label>
|
||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式(选择即生效)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
{ id: 'light', label: '浅色', icon: Sun },
|
||||
{ id: 'dark', label: '深色', icon: Moon },
|
||||
{ id: 'system', label: '跟随系统', icon: Monitor },
|
||||
].map(m => (
|
||||
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as ThemeConfig['mode'] }))}
|
||||
<button key={m.id} onClick={() => { setTheme(p => ({ ...p, mode: m.id as ThemeConfig['mode'] })); themeChange(m.id as ThemeMode); }}
|
||||
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
||||
style={{
|
||||
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||
@@ -389,10 +495,45 @@ export default function SystemSettings() {
|
||||
<input type="checkbox" checked={llm.enabled} onChange={(e) => setLlm({ ...llm, enabled: e.target.checked })} />
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 AI(OpenAI 兼容接口:DeepSeek / GLM / Qwen 等)</span>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>服务商预设</label>
|
||||
<select
|
||||
value={llmProvider}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setLlmProvider(id);
|
||||
const preset = LLM_PROVIDERS.find((p) => p.id === id);
|
||||
if (preset && id !== 'custom') {
|
||||
// 非 custom:自动填入 endpoint/model(用户仍可修改)
|
||||
setLlm((prev) => ({ ...prev, endpoint: preset.endpoint, model: preset.model }));
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label>
|
||||
<input value={llm.endpoint} onChange={(e) => setLlm({ ...llm, endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input value={llm.endpoint} onChange={(e) => setLlm({ ...llm, endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="flex-1 min-w-0 px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
{!!LLM_PROVIDERS.find((p) => p.id === llmProvider)?.keyUrl && (
|
||||
<a
|
||||
href={LLM_PROVIDERS.find((p) => p.id === llmProvider)!.keyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="打开 API Key 申请页面"
|
||||
className="flex-shrink-0 flex items-center gap-1 px-2 py-[6px] text-[11px] rounded-md border whitespace-nowrap"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />获取 API Key
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>模型</label>
|
||||
@@ -409,7 +550,7 @@ export default function SystemSettings() {
|
||||
{saveLlmMutation.isPending ? '保存中…' : '保存 AI 配置'}
|
||||
</button>
|
||||
<button onClick={() => testLlmMutation.mutate()} disabled={testLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
{testLlmMutation.isPending ? '测试中…' : '测试连接(需先保存)'}
|
||||
{testLlmMutation.isPending ? '测试中…' : '测试连接(按当前表单直测)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,6 +593,106 @@ export default function SystemSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LDAP 目录服务 */}
|
||||
<div className="rounded-lg border overflow-hidden mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>LDAP 目录服务</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.ldapEnabled ? '#dafbe1' : '#f6f8fa', color: settings?.ldapEnabled ? '#1a7f37' : '#6e7781' }}>
|
||||
{settings?.ldapEnabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!ldap.enabled ? (
|
||||
<div className="p-5 space-y-3">
|
||||
<p className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
LDAP 未启用——打开开关填写目录服务信息,启用后可在用户管理页导入用户
|
||||
</p>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={ldap.enabled} onChange={(e) => setLdap({ ...ldap, enabled: e.target.checked })} />
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 LDAP 目录服务</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>服务器地址</label>
|
||||
<input value={ldap.host} onChange={(e) => setLdap({ ...ldap, host: e.target.value })} placeholder="ldap.example.com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>端口</label>
|
||||
<input value={ldap.port} onChange={(e) => setLdap({ ...ldap, port: e.target.value })} placeholder="389" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={ldap.useSSL} onChange={(e) => setLdap({ ...ldap, useSSL: e.target.checked })} />
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>使用 LDAPS(SSL/TLS 加密连接,端口通常为 636)</span>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Bind DN(绑定账号)</label>
|
||||
<input value={ldap.bindDN} onChange={(e) => setLdap({ ...ldap, bindDN: e.target.value })} placeholder="cn=admin,dc=example,dc=com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Bind 密码</label>
|
||||
<input type="password" value={ldap.bindPassword} onChange={(e) => setLdap({ ...ldap, bindPassword: e.target.value })} placeholder={settings?.ldapBindPasswordSet ? '已保存,输入新密码以修改' : ''} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Base DN(用户搜索基准)</label>
|
||||
<input value={ldap.baseDN} onChange={(e) => setLdap({ ...ldap, baseDN: e.target.value })} placeholder="ou=users,dc=example,dc=com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户过滤器</label>
|
||||
<input value={ldap.userFilter} onChange={(e) => setLdap({ ...ldap, userFilter: e.target.value })} placeholder="(objectClass=inetOrgPerson)" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名属性</label>
|
||||
<input value={ldap.usernameAttr} onChange={(e) => setLdap({ ...ldap, usernameAttr: e.target.value })} placeholder="uid" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>姓名属性</label>
|
||||
<input value={ldap.nameAttr} onChange={(e) => setLdap({ ...ldap, nameAttr: e.target.value })} placeholder="displayName" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>邮箱属性</label>
|
||||
<input value={ldap.emailAttr} onChange={(e) => setLdap({ ...ldap, emailAttr: e.target.value })} placeholder="mail" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 可选:测试账号级验证 */}
|
||||
<div>
|
||||
<button onClick={() => setLdapTestOpen(!ldapTestOpen)} className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--accent-blue)' }}>
|
||||
{ldapTestOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
测试账号(可选,验证指定用户能否通过目录登录)
|
||||
</button>
|
||||
{ldapTestOpen && (
|
||||
<div className="grid grid-cols-2 gap-4 mt-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>测试用户名</label>
|
||||
<input value={ldapTest.username} onChange={(e) => setLdapTest({ ...ldapTest, username: e.target.value })} placeholder="zhangsan" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>测试密码</label>
|
||||
<input type="password" value={ldapTest.password} onChange={(e) => setLdapTest({ ...ldapTest, password: e.target.value })} placeholder="目录服务中的密码" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
「测试连接」按当前表单草稿直测(Bind 密码留空时使用已保存密码),无需先保存。保存后可在「用户管理」页从 LDAP 批量导入用户。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => saveLdapMutation.mutate()} disabled={saveLdapMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||
{saveLdapMutation.isPending ? '保存中…' : '保存 LDAP 配置'}
|
||||
</button>
|
||||
<button onClick={() => testLdapMutation.mutate()} disabled={testLdapMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
{testLdapMutation.isPending ? '测试中…' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
KeyRound,
|
||||
Trash2,
|
||||
ShieldCheck,
|
||||
Copy,
|
||||
CloudDownload,
|
||||
AlertCircle,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
function fmtTime(v?: string): string {
|
||||
if (!v || v.startsWith('0001-')) return '—';
|
||||
return v.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
const roleBadge = {
|
||||
admin: { label: '管理员', bg: '#dafbe1', color: '#1a7f37' },
|
||||
user: { label: '普通用户', bg: '#f6f8fa', color: '#6e7781' },
|
||||
} as const;
|
||||
|
||||
const sourceBadge = {
|
||||
ldap: { label: 'LDAP 导入', bg: '#fbefff', color: '#8250df' },
|
||||
local: { label: '本地账号', bg: '#ddf4ff', color: '#0969da' },
|
||||
} as const;
|
||||
|
||||
interface UserForm {
|
||||
username: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
role: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const emptyForm: UserForm = { username: '', nickname: '', email: '', role: 'user', password: '' };
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
|
||||
interface LdapEntry {
|
||||
username: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
created: number;
|
||||
converted: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
failedUsers: string[];
|
||||
}
|
||||
|
||||
export default function UserManage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
|
||||
const [ldapOpen, setLdapOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
|
||||
const usersQuery = useQuery({ queryKey: ['users'], queryFn: apiClient.listUsers });
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||
|
||||
// LDAP 就绪(已启用且关键配置齐全)时才显示「从 LDAP 导入」入口
|
||||
const ldapReady = !!(settings?.ldapEnabled && settings?.ldapHost && settings?.ldapBindDN && settings?.ldapBaseDN);
|
||||
|
||||
const users: any[] = usersQuery.data ?? [];
|
||||
|
||||
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
|
||||
// LDAP 预览列表(仅弹窗打开且未进入结果态时请求)
|
||||
const ldapQuery = useQuery({
|
||||
queryKey: ['ldap-users'],
|
||||
queryFn: apiClient.ldapListUsers,
|
||||
enabled: ldapOpen && !importResult,
|
||||
});
|
||||
const ldapEntries: LdapEntry[] = (ldapQuery.data as LdapEntry[]) ?? [];
|
||||
|
||||
// 本地用户索引(按用户名小写匹配,目录服务通常大小写不敏感)
|
||||
const localMap = useMemo(() => {
|
||||
const m = new Map<string, any>();
|
||||
users.forEach((u) => m.set(String(u.username || '').toLowerCase(), u));
|
||||
return m;
|
||||
}, [users]);
|
||||
|
||||
// ---- 新建用户 ----
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: any) => apiClient.createUser(body),
|
||||
onSuccess: (res: any) => {
|
||||
invalidateUsers();
|
||||
setDialogOpen(false);
|
||||
if (res?.initialPassword) {
|
||||
setCreatedResult({ username: form.username.trim(), password: String(res.initialPassword) });
|
||||
} else {
|
||||
toast.success('用户已创建');
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const submitCreate = () => {
|
||||
if (!form.username.trim()) {
|
||||
toast.error('请填写用户名');
|
||||
return;
|
||||
}
|
||||
const body: any = {
|
||||
username: form.username.trim(),
|
||||
nickname: form.nickname.trim(),
|
||||
email: form.email.trim(),
|
||||
role: form.role,
|
||||
};
|
||||
if (form.password) body.password = form.password;
|
||||
createMutation.mutate(body);
|
||||
};
|
||||
|
||||
// ---- 重置密码 ----
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.resetUserPassword(id),
|
||||
onSuccess: (res: any) => {
|
||||
if (res?.initialPassword) {
|
||||
toast.success('密码已重置', { description: `一次性密码:${res.initialPassword}(仅显示一次,请立即保存)` });
|
||||
} else {
|
||||
toast.success('密码已重置');
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// ---- 角色切换 ----
|
||||
const roleMutation = useMutation({
|
||||
mutationFn: (vars: { id: any; role: string }) => apiClient.updateUser(vars.id, { role: vars.role }),
|
||||
onSuccess: (_d, vars) => {
|
||||
toast.success(vars.role === 'admin' ? '已设为管理员' : '已降为普通用户');
|
||||
invalidateUsers();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// ---- 删除 ----
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.deleteUser(id),
|
||||
onSuccess: () => {
|
||||
toast.success('用户已删除');
|
||||
invalidateUsers();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// ---- LDAP 导入 ----
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (list: LdapEntry[]) => apiClient.ldapImport(list),
|
||||
onSuccess: (res: any) => {
|
||||
setImportResult({
|
||||
created: res?.created ?? 0,
|
||||
converted: res?.converted ?? 0,
|
||||
skipped: res?.skipped ?? 0,
|
||||
failed: res?.failed ?? 0,
|
||||
failedUsers: res?.failedUsers ?? [],
|
||||
});
|
||||
invalidateUsers();
|
||||
},
|
||||
onError: (err: Error) => toast.error('导入失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const openLdapDialog = () => {
|
||||
setSelected(new Set());
|
||||
setImportResult(null);
|
||||
setLdapOpen(true);
|
||||
};
|
||||
|
||||
const closeLdapDialog = (open: boolean) => {
|
||||
setLdapOpen(open);
|
||||
if (!open) {
|
||||
setSelected(new Set());
|
||||
setImportResult(null);
|
||||
invalidateUsers();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelected = (username: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(username)) next.delete(username);
|
||||
else next.add(username);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedEntries = ldapEntries.filter((e) => selected.has(e.username));
|
||||
|
||||
const copyText = (text: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => toast.success('已复制', { description: text }))
|
||||
.catch(() => toast.error('复制失败'));
|
||||
};
|
||||
|
||||
const importStats = importResult
|
||||
? [
|
||||
{ key: 'created', label: '新增', value: importResult.created, bg: '#dafbe1', color: '#1a7f37' },
|
||||
{ key: 'converted', label: '转换', value: importResult.converted, bg: '#ddf4ff', color: '#0969da' },
|
||||
{ key: 'skipped', label: '跳过', value: importResult.skipped, bg: '#f6f8fa', color: '#6e7781' },
|
||||
{ key: 'failed', label: '失败', value: importResult.failed, bg: '#ffebe9', color: '#cf222e' },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>用户管理</h1>
|
||||
<span
|
||||
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{users.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ldapReady && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
onClick={openLdapDialog}
|
||||
>
|
||||
<CloudDownload className="w-3.5 h-3.5" />
|
||||
从 LDAP 导入
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => {
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{usersQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : usersQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16">
|
||||
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(usersQuery.error as Error).message}</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
onClick={() => usersQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
{['用户名', '昵称', '邮箱', '角色', '来源', '创建时间', '操作'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => {
|
||||
const role = (roleBadge as any)[u.role] ?? roleBadge.user;
|
||||
const source = (sourceBadge as any)[u.authSource] ?? sourceBadge.local;
|
||||
const isAdmin = u.role === 'admin';
|
||||
return (
|
||||
<tr key={u.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[13px] font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{u.nickname || '—'}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{u.email || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: role.bg, color: role.color }}>
|
||||
{role.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: source.bg, color: source.color }}>
|
||||
{source.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(u.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
{u.authSource !== 'ldap' && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="重置密码"
|
||||
disabled={resetMutation.isPending}
|
||||
onClick={() => resetMutation.mutate(u.id)}
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title={isAdmin ? '降为普通用户' : '设为管理员'}
|
||||
disabled={roleMutation.isPending}
|
||||
onClick={() => roleMutation.mutate({ id: u.id, role: isAdmin ? 'user' : 'admin' })}
|
||||
>
|
||||
{isAdmin ? (
|
||||
<UserCog className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} />
|
||||
) : (
|
||||
<ShieldCheck className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除用户 ${u.username} 吗?`)) deleteMutation.mutate(u.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
暂无用户,点击右上角「新建用户」创建
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建用户弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>新建用户</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名 *</Label>
|
||||
<Input
|
||||
className="h-8 text-[12px] font-mono"
|
||||
style={inputStyle}
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
placeholder="zhangsan"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>昵称</Label>
|
||||
<Input
|
||||
className="h-8 text-[12px]"
|
||||
style={inputStyle}
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
|
||||
placeholder="张三"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>角色</Label>
|
||||
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
|
||||
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择角色" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user" className="text-[12px]">普通用户</SelectItem>
|
||||
<SelectItem value="admin" className="text-[12px]">管理员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>邮箱</Label>
|
||||
<Input
|
||||
className="h-8 text-[12px]"
|
||||
style={inputStyle}
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
placeholder="zhangsan@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-8 text-[12px] font-mono"
|
||||
style={inputStyle}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
placeholder="留空由系统随机生成"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={createMutation.isPending}
|
||||
onClick={submitCreate}
|
||||
>
|
||||
{createMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
创建
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 创建成功:一次性密码 */}
|
||||
<Dialog open={!!createdResult} onOpenChange={(open) => !open && setCreatedResult(null)}>
|
||||
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>用户已创建</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
用户 <span className="font-mono font-medium" style={{ color: 'var(--text-primary)' }}>{createdResult?.username}</span> 创建成功。
|
||||
系统已生成一次性初始密码,仅显示此次,请立即复制并妥善交付给用户:
|
||||
</p>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-3 py-2.5 rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<code className="text-[14px] font-mono font-semibold tracking-wide break-all" style={{ color: 'var(--accent-blue)' }}>
|
||||
{createdResult?.password}
|
||||
</code>
|
||||
<button
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] rounded border flex-shrink-0"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
onClick={() => createdResult && copyText(createdResult.password)}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => setCreatedResult(null)}
|
||||
>
|
||||
我已保存
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* LDAP 导入弹窗 */}
|
||||
<Dialog open={ldapOpen} onOpenChange={closeLdapDialog}>
|
||||
<DialogContent className="sm:max-w-lg" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>从 LDAP 导入用户</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{importResult ? (
|
||||
/* 结果态:四色统计 + 失败名单 */
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{importStats.map((s) => (
|
||||
<div key={s.key} className="rounded-md px-3 py-2.5 text-center" style={{ background: s.bg }}>
|
||||
<div className="text-[18px] font-bold" style={{ color: s.color }}>{s.value}</div>
|
||||
<div className="text-[11px] font-medium" style={{ color: s.color }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{importResult.failedUsers.length > 0 && (
|
||||
<div
|
||||
className="rounded-md border px-3 py-2.5 space-y-1.5"
|
||||
style={{ background: '#ffebe9', borderColor: '#ff818266' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-medium" style={{ color: '#cf222e' }}>
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
以下用户导入失败:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{importResult.failedUsers.map((name) => (
|
||||
<code key={name} className="text-[11px] font-mono px-1.5 py-[2px] rounded" style={{ background: '#ffffff99', color: '#cf222e' }}>
|
||||
{name}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
「转换」表示本地同名账号已切换为 LDAP 来源;「跳过」表示该用户已是 LDAP 账号。
|
||||
</p>
|
||||
</div>
|
||||
) : ldapQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
正在从 LDAP 目录读取用户...
|
||||
</div>
|
||||
) : ldapQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-10">
|
||||
<div className="text-[13px] text-center" style={{ color: 'var(--text-secondary)' }}>
|
||||
{(ldapQuery.error as Error).message}
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>请到系统设置检查 LDAP 配置</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
onClick={() => ldapQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* 选择态 */
|
||||
<div className="space-y-3">
|
||||
<div className="max-h-[340px] overflow-y-auto custom-scrollbar rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
{ldapEntries.map((entry) => {
|
||||
const local = localMap.get(String(entry.username || '').toLowerCase());
|
||||
const isImported = local?.authSource === 'ldap';
|
||||
const willConvert = !!local && local.authSource !== 'ldap';
|
||||
const checked = selected.has(entry.username);
|
||||
return (
|
||||
<label
|
||||
key={entry.username}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 ${isImported ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
style={{ borderColor: 'var(--border-color-light)', background: checked ? 'var(--accent-blue-light)' : 'transparent' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-3.5 h-3.5 flex-shrink-0"
|
||||
style={{ accentColor: 'var(--accent-blue)' }}
|
||||
checked={checked}
|
||||
disabled={isImported}
|
||||
onChange={() => toggleSelected(entry.username)}
|
||||
/>
|
||||
<span className="text-[12px] font-mono font-medium flex-shrink-0" style={{ color: 'var(--text-primary)' }}>
|
||||
{entry.username}
|
||||
</span>
|
||||
<span className="text-[11px] truncate flex-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{[entry.displayName, entry.email].filter(Boolean).join(' · ') || '—'}
|
||||
</span>
|
||||
{isImported && (
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded flex-shrink-0" style={{ background: '#f6f8fa', color: '#6e7781' }}>已导入</span>
|
||||
)}
|
||||
{willConvert && (
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded flex-shrink-0" style={{ background: '#ddf4ff', color: '#0969da' }}>将转为 LDAP</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{ldapEntries.length === 0 && (
|
||||
<div className="px-3 py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
目录中未读取到用户
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
共 {ldapEntries.length} 个用户,已选 {selected.size} 个
|
||||
</span>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={selected.size === 0 || importMutation.isPending}
|
||||
onClick={() => importMutation.mutate(selectedEntries)}
|
||||
>
|
||||
{importMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
导入 {selected.size} 个用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => closeLdapDialog(false)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
const STORAGE_KEY = 'flowstack-theme';
|
||||
|
||||
function isThemeMode(v: string | null): v is ThemeMode {
|
||||
return v === 'light' || v === 'dark' || v === 'system';
|
||||
}
|
||||
|
||||
function applyMode(mode: ThemeMode) {
|
||||
const dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
}
|
||||
|
||||
/** 深浅主题:本地缓存先行应用避免闪白,远端设置为最终来源 */
|
||||
export function useTheme() {
|
||||
const [mode, setMode] = useState<ThemeMode>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return isThemeMode(saved) ? saved : 'light';
|
||||
});
|
||||
const [synced, setSynced] = useState(false);
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
|
||||
// 立即应用 + system 模式跟随系统变化
|
||||
useEffect(() => {
|
||||
applyMode(mode);
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
if (mode !== 'system') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => {
|
||||
applyMode('system');
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [mode]);
|
||||
|
||||
// 启动后用远端设置校准一次(多端一致)
|
||||
useEffect(() => {
|
||||
if (synced) return;
|
||||
apiClient.getSettings().then((s: any) => {
|
||||
try {
|
||||
const theme = JSON.parse(s?.theme || '{}');
|
||||
if (isThemeMode(theme.mode)) setMode(theme.mode);
|
||||
} catch { /* ignore */ }
|
||||
setSynced(true);
|
||||
}).catch(() => setSynced(true));
|
||||
}, [synced]);
|
||||
|
||||
const change = useCallback(async (next: ThemeMode) => {
|
||||
setMode(next);
|
||||
// 持久化到后端(失败静默,本地已生效)
|
||||
try {
|
||||
const s = await apiClient.getSettings();
|
||||
let theme: any = {};
|
||||
try { theme = JSON.parse(s?.theme || '{}'); } catch { /* ignore */ }
|
||||
await apiClient.updateSettings({ theme: JSON.stringify({ ...theme, mode: next }) });
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
change(isDark ? 'light' : 'dark');
|
||||
}, [change]);
|
||||
|
||||
return { mode, isDark, change, toggle };
|
||||
}
|
||||
Reference in new issue
Block a user