fix(desktop): session persistence — refresh/login/context/empty-content 4-bug fix
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
1. App.tsx: add restoreSession() call on startup to prevent redirect to login page after refresh (isRestoring guard + BootstrapScreen) 2. CloneManager: call syncAgents() after loadClones() to restore currentAgent and conversation history on app load 3. zclaw-memory: add get_or_create_session() so frontend session UUID is persisted directly — kernel no longer creates mismatched IDs 4. openai.rs: assistant message content must be non-empty for Kimi/Qwen APIs — replace empty content with meaningful placeholders Also includes admin-v2 ModelServices unified page (merge providers + models + API keys into expandable row layout)
This commit is contained in:
@@ -1,15 +1,9 @@
|
||||
// ============================================================
|
||||
// AdminLayout — ProLayout 管理后台布局
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import ProLayout from '@ant-design/pro-layout'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
CloudServerOutlined,
|
||||
ApiOutlined,
|
||||
KeyOutlined,
|
||||
BarChartOutlined,
|
||||
SwapOutlined,
|
||||
SettingOutlined,
|
||||
@@ -17,87 +11,375 @@ import {
|
||||
MessageOutlined,
|
||||
RobotOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SunOutlined,
|
||||
MoonOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { Avatar, Dropdown, message } from 'antd'
|
||||
import type { MenuDataItem } from '@ant-design/pro-layout'
|
||||
import { useThemeStore, setThemeMode } from '@/stores/themeStore'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const menuConfig: MenuDataItem[] = [
|
||||
{ path: '/', name: '仪表盘', icon: <DashboardOutlined /> },
|
||||
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin' },
|
||||
{ path: '/providers', name: '服务商', icon: <CloudServerOutlined />, permission: 'provider:manage' },
|
||||
{ path: '/models', name: '模型管理', icon: <ApiOutlined />, permission: 'model:read' },
|
||||
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read' },
|
||||
{ path: '/api-keys', name: 'API 密钥', icon: <KeyOutlined />, permission: 'admin:full' },
|
||||
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full' },
|
||||
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use' },
|
||||
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read' },
|
||||
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read' },
|
||||
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full' },
|
||||
// ============================================================
|
||||
// Navigation Configuration
|
||||
// ============================================================
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
name: string
|
||||
icon: ReactNode
|
||||
permission?: string
|
||||
group: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, group: '核心' },
|
||||
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin', group: '资源管理' },
|
||||
{ path: '/model-services', name: '模型服务', icon: <CloudServerOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read', group: '资源管理' },
|
||||
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
||||
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
|
||||
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
|
||||
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
||||
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
||||
]
|
||||
|
||||
function filterMenuByPermission(
|
||||
items: MenuDataItem[],
|
||||
hasPermission: (p: string) => boolean,
|
||||
): MenuDataItem[] {
|
||||
return items
|
||||
.filter((item) => !item.permission || hasPermission(item.permission as string))
|
||||
.map(({ permission, ...rest }) => ({
|
||||
...rest,
|
||||
children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined,
|
||||
}))
|
||||
// ============================================================
|
||||
// Sidebar Component
|
||||
// ============================================================
|
||||
|
||||
function Sidebar({
|
||||
collapsed,
|
||||
onNavigate,
|
||||
activePath,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onNavigate: (path: string) => void
|
||||
activePath: string
|
||||
}) {
|
||||
const { hasPermission } = useAuthStore()
|
||||
const visibleItems = navItems.filter(
|
||||
(item) => !item.permission || hasPermission(item.permission),
|
||||
)
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, NavItem[]>()
|
||||
for (const item of visibleItems) {
|
||||
const list = map.get(item.group) || []
|
||||
list.push(item)
|
||||
map.set(item.group, list)
|
||||
}
|
||||
return map
|
||||
}, [visibleItems])
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col h-full" aria-label="主导航">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center h-14 px-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0"
|
||||
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||
>
|
||||
<span className="text-white font-bold text-sm">Z</span>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="ml-3 text-lg font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">
|
||||
ZCLAW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Items */}
|
||||
<div className="flex-1 overflow-y-auto py-3 px-2">
|
||||
{Array.from(groups.entries()).map(([groupName, items]) => (
|
||||
<div key={groupName} className="mb-3">
|
||||
{!collapsed && (
|
||||
<div className="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-600">
|
||||
{groupName}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.path === '/'
|
||||
? activePath === '/'
|
||||
: activePath.startsWith(item.path)
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
|
||||
transition-all duration-150 ease-in-out cursor-pointer border-none bg-transparent
|
||||
${
|
||||
isActive
|
||||
? 'text-brand-purple bg-brand-purple/8 dark:text-brand-purple dark:bg-brand-purple/12'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}
|
||||
${collapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<span
|
||||
className={`text-base ${isActive ? 'text-brand-purple' : ''}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
{!collapsed && <span>{item.name}</span>}
|
||||
{isActive && (
|
||||
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-brand-purple" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
return collapsed ? (
|
||||
<Tooltip key={item.path} title={item.name} placement="right">
|
||||
{btn}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div key={item.path}>{btn}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-800 p-3">
|
||||
{!collapsed && (
|
||||
<div className="text-[11px] text-neutral-400 dark:text-neutral-600 text-center">
|
||||
ZCLAW Admin v2
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mobile Drawer Sidebar
|
||||
// ============================================================
|
||||
|
||||
function MobileDrawer({
|
||||
open,
|
||||
onClose,
|
||||
onNavigate,
|
||||
activePath,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onNavigate: (path: string) => void
|
||||
activePath: string
|
||||
}) {
|
||||
return (
|
||||
<Drawer
|
||||
placement="left"
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
width={280}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<Sidebar collapsed={false} onNavigate={onNavigate} activePath={activePath} />
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Breadcrumb
|
||||
// ============================================================
|
||||
|
||||
const breadcrumbMap: Record<string, string> = {
|
||||
'/': '仪表盘',
|
||||
'/accounts': '账号管理',
|
||||
'/model-services': '模型服务',
|
||||
'/providers': '模型服务',
|
||||
'/models': '模型服务',
|
||||
'/api-keys': 'API 密钥',
|
||||
'/agent-templates': 'Agent 模板',
|
||||
'/usage': '用量统计',
|
||||
'/relay': '中转任务',
|
||||
'/config': '系统配置',
|
||||
'/prompts': '提示词管理',
|
||||
'/logs': '操作日志',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Layout
|
||||
// ============================================================
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { account, hasPermission, logout } = useAuthStore()
|
||||
const { account, logout } = useAuthStore()
|
||||
const themeState = useThemeStore()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
const menuData = filterMenuByPermission(menuConfig, hasPermission)
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)')
|
||||
setIsMobile(mq.matches)
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
navigate(path)
|
||||
setMobileOpen(false)
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
logout()
|
||||
message.success('已退出登录')
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
}, [logout, navigate])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeMode(themeState.resolved === 'dark' ? 'light' : 'dark')
|
||||
}, [themeState.resolved])
|
||||
|
||||
const currentPage = breadcrumbMap[location.pathname] || '页面'
|
||||
|
||||
return (
|
||||
<ProLayout
|
||||
title="ZCLAW"
|
||||
logo={<div style={{ width: 28, height: 28, background: '#1677ff', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: 14 }}>Z</div>}
|
||||
layout="mix"
|
||||
fixSiderbar
|
||||
fixedHeader
|
||||
location={{ pathname: location.pathname }}
|
||||
menuDataRender={() => menuData}
|
||||
menuItemRender={(item, dom) => (
|
||||
<div onClick={() => item.path && navigate(item.path)}>{dom}</div>
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-50 dark:bg-neutral-950">
|
||||
{/* Desktop Sidebar */}
|
||||
{!isMobile && (
|
||||
<aside
|
||||
className={`
|
||||
shrink-0 border-r border-neutral-200 dark:border-neutral-800
|
||||
bg-white dark:bg-neutral-900
|
||||
transition-all duration-200 ease-in-out
|
||||
${collapsed ? 'w-12' : 'w-64'}
|
||||
`}
|
||||
>
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
onNavigate={handleNavigate}
|
||||
activePath={location.pathname}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
avatarProps={{
|
||||
src: undefined,
|
||||
title: account?.display_name || account?.username || 'Admin',
|
||||
size: 'small',
|
||||
render: (_, dom) => (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{dom}
|
||||
</Dropdown>
|
||||
),
|
||||
}}
|
||||
suppressSiderWhenMenuEmpty
|
||||
contentStyle={{ padding: 24 }}
|
||||
>
|
||||
<Outlet />
|
||||
</ProLayout>
|
||||
|
||||
{/* Mobile Drawer */}
|
||||
{isMobile && (
|
||||
<MobileDrawer
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
onNavigate={handleNavigate}
|
||||
activePath={location.pathname}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<header className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile menu button */}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<MenuUnfoldOutlined className="text-lg" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Collapse toggle (desktop) */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
>
|
||||
{collapsed ? (
|
||||
<MenuUnfoldOutlined className="text-lg" />
|
||||
) : (
|
||||
<MenuFoldOutlined className="text-lg" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-neutral-400 dark:text-neutral-600">ZCLAW</span>
|
||||
<span className="text-neutral-300 dark:text-neutral-700">/</span>
|
||||
<span className="text-neutral-900 dark:text-neutral-100 font-medium">
|
||||
{currentPage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
<Tooltip title={themeState.resolved === 'dark' ? '切换亮色' : '切换暗色'}>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label="切换主题"
|
||||
>
|
||||
{themeState.resolved === 'dark' ? (
|
||||
<SunOutlined className="text-lg" />
|
||||
) : (
|
||||
<MoonOutlined className="text-lg" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* User avatar */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors">
|
||||
<Avatar
|
||||
size={28}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #863bff, #47bfff)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{(account?.display_name || account?.username || 'A')[0].toUpperCase()}
|
||||
</Avatar>
|
||||
{!isMobile && (
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 font-medium">
|
||||
{account?.display_name || account?.username || 'Admin'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-1 overflow-y-auto p-6"
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user