- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化 - 移除内置 agents 配置以简化系统架构 - 更新 ContextBuilder 使用动态提示词模板而非硬编码内容 - 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数 - 添加输出语言指令确保用户界面内容按指定语言生成 - 扩展前端 LanguageSwitcher 组件支持三种语言选项 - 优化 Header 和侧边栏组件的响应式布局和文本截断处理 - 更新测试用例验证不同语言环境下的提示词正确性
270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import Link from 'next/link';
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, Menu, MessageSquare, Puzzle, Settings, Store, Wrench, X } from 'lucide-react';
|
|
import { logout } from '@/lib/api';
|
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { appConnectionStatusLabel } from '@/lib/i18n/common';
|
|
import { pickAppText } from '@/lib/i18n/core';
|
|
import { useAppI18n } from '@/lib/i18n/provider';
|
|
import { useChatStore } from '@/lib/store';
|
|
|
|
type NavItem = {
|
|
key: 'chat' | 'tasks' | 'notifications' | 'skills' | 'files' | 'tools' | 'agents' | 'outlook' | 'marketplace' | 'settings';
|
|
href: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
matchPrefixes?: string[];
|
|
};
|
|
|
|
const NAV_ITEMS: NavItem[] = [
|
|
{ key: 'chat', href: '/', icon: MessageSquare },
|
|
{ key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/cron'] },
|
|
{ key: 'notifications', href: '/notifications', icon: Bell, matchPrefixes: ['/notifications'] },
|
|
{ key: 'skills', href: '/skills', icon: Puzzle },
|
|
{ key: 'files', href: '/files', icon: FolderOpen, matchPrefixes: ['/files'] },
|
|
{ key: 'tools', href: '/mcp', icon: Wrench, matchPrefixes: ['/mcp'] },
|
|
{ key: 'agents', href: '/agents', icon: Bot, matchPrefixes: ['/agents'] },
|
|
{ key: 'outlook', href: '/outlook', icon: Mail, matchPrefixes: ['/outlook'] },
|
|
{ key: 'marketplace', href: '/marketplace', icon: Store, matchPrefixes: ['/marketplace'] },
|
|
{
|
|
key: 'settings',
|
|
href: '/settings',
|
|
icon: Settings,
|
|
matchPrefixes: ['/settings', '/status', '/logs'],
|
|
},
|
|
];
|
|
|
|
function ConnectionDot() {
|
|
const { locale } = useAppI18n();
|
|
const wsStatus = useChatStore((s) => s.wsStatus);
|
|
const beaverReady = useChatStore((s) => s.beaverReady);
|
|
|
|
const isOnline = wsStatus === 'connected' && beaverReady === true;
|
|
const isChecking = wsStatus === 'connected' && beaverReady === null;
|
|
const isConnecting = wsStatus === 'connecting' || isChecking;
|
|
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && beaverReady === false);
|
|
|
|
const color = isOnline
|
|
? 'bg-[#869683]'
|
|
: isConnecting
|
|
? 'bg-[#8B7E77]'
|
|
: 'bg-[#5F5550]';
|
|
|
|
const label = appConnectionStatusLabel(wsStatus, beaverReady, locale);
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<span className={`w-2 h-2 rounded-full ${color}`} />
|
|
<span>{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const Header = () => {
|
|
const { locale } = useAppI18n();
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
|
const user = useChatStore((s) => s.user);
|
|
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
|
|
const setUser = useChatStore((s) => s.setUser);
|
|
|
|
const navLabel = React.useCallback((key: NavItem['key']) => {
|
|
if (key === 'chat') return pickAppText(locale, '对话', 'Chat');
|
|
if (key === 'tasks') return 'Task';
|
|
if (key === 'notifications') return pickAppText(locale, '通知', 'Notifications');
|
|
if (key === 'skills') return pickAppText(locale, '技能', 'Skills');
|
|
if (key === 'files') return pickAppText(locale, '文件', 'Files');
|
|
if (key === 'tools') return pickAppText(locale, '工具', 'Tools');
|
|
if (key === 'agents') return pickAppText(locale, '智能体', 'Agents');
|
|
if (key === 'outlook') return 'Outlook';
|
|
if (key === 'marketplace') return pickAppText(locale, '市场', 'Marketplace');
|
|
return pickAppText(locale, '配置', 'Settings');
|
|
}, [locale]);
|
|
|
|
const handleLogout = async () => {
|
|
await logout();
|
|
setUser(null);
|
|
router.replace('/login');
|
|
router.refresh();
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
setMobileMenuOpen(false);
|
|
}, [pathname]);
|
|
|
|
React.useEffect(() => {
|
|
if (!mobileMenuOpen) return;
|
|
|
|
const previousOverflow = document.body.style.overflow;
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setMobileMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [mobileMenuOpen]);
|
|
|
|
const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase();
|
|
|
|
const renderNavLinks = (compact = false) =>
|
|
NAV_ITEMS.map((item) => {
|
|
const isActive =
|
|
item.href === '/'
|
|
? pathname === '/'
|
|
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
|
|
const Icon = item.icon;
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={compact ? () => setMobileMenuOpen(false) : undefined}
|
|
className={`flex h-11 shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${
|
|
compact ? 'justify-start rounded-lg border border-transparent bg-background px-4' : 'px-4'
|
|
} ${
|
|
isActive
|
|
? 'bg-primary text-primary-foreground'
|
|
: compact
|
|
? 'text-[#4F4642] hover:border-[#E6E1DE] hover:bg-muted hover:text-[#0B0B0B]'
|
|
: 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]'
|
|
}`}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{navLabel(item.key)}
|
|
</Link>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
|
|
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
|
|
<div className="flex h-16 items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] min-[1800px]:hidden"
|
|
aria-label={mobileMenuOpen ? pickAppText(locale, '关闭导航', 'Close navigation') : pickAppText(locale, '打开导航', 'Open navigation')}
|
|
aria-expanded={mobileMenuOpen}
|
|
aria-controls="app-primary-mobile-nav"
|
|
onClick={() => setMobileMenuOpen((open) => !open)}
|
|
>
|
|
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
</button>
|
|
<Link href="/" className="hidden h-11 shrink-0 items-center min-[360px]:flex">
|
|
<span className="font-serif text-[26px] font-semibold leading-none text-[#0B0B0B] sm:text-[28px]">
|
|
Beaver
|
|
</span>
|
|
</Link>
|
|
</div>
|
|
|
|
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] min-[1800px]:flex">
|
|
{renderNavLinks(false)}
|
|
</nav>
|
|
|
|
<div className="flex min-w-0 shrink-0 items-center justify-end gap-2 sm:gap-3">
|
|
<div className="hidden shrink-0 xl:block">
|
|
<ConnectionDot />
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<LanguageSwitcher />
|
|
{user ? (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex h-11 w-11 min-w-0 items-center justify-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4] sm:w-auto sm:max-w-[11rem] sm:justify-start sm:px-2"
|
|
aria-label={pickAppText(locale, '打开账号菜单', 'Open account menu')}
|
|
>
|
|
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
|
|
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
|
|
{userInitial}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="hidden min-w-0 max-w-24 truncate sm:block">{user.username}</span>
|
|
<ChevronDown className="hidden h-4 w-4 text-muted-foreground sm:block" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
|
|
<div className="overflow-hidden rounded-3xl bg-[linear-gradient(180deg,#F7F5F4,#FFFFFF)]">
|
|
<div className="border-b border-border/60 px-6 py-5">
|
|
<p className="truncate text-center text-sm font-medium text-muted-foreground">
|
|
{user.email}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center gap-4 px-6 py-6 text-center">
|
|
<Avatar className="h-24 w-24 border-4 border-white shadow-sm">
|
|
<AvatarFallback className="bg-primary text-4xl font-semibold text-primary-foreground">
|
|
{userInitial}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="space-y-1">
|
|
<p className="text-2xl font-semibold tracking-tight text-foreground">
|
|
{pickAppText(locale, `${user.username},你好!`, `Hi, ${user.username}`)}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{pickAppText(locale, '当前已登录到你的工作区实例。', 'You are currently signed in to your workspace instance.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border/60 bg-white/90 px-4 py-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleLogout}
|
|
className="h-12 w-full justify-center rounded-2xl text-sm font-semibold"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
{pickAppText(locale, '退出登录', 'Sign Out')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : !isAuthLoading ? null : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
{mobileMenuOpen && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="fixed inset-x-0 bottom-0 top-16 z-40 bg-black/40 min-[1800px]:hidden"
|
|
aria-label={pickAppText(locale, '关闭导航', 'Close navigation')}
|
|
onClick={() => setMobileMenuOpen(false)}
|
|
/>
|
|
<nav
|
|
id="app-primary-mobile-nav"
|
|
aria-label={pickAppText(locale, '主导航', 'Primary navigation')}
|
|
className="fixed bottom-0 left-0 top-16 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 min-[1800px]:hidden"
|
|
>
|
|
<div className="min-h-full bg-background px-4 py-5">
|
|
<div className="grid gap-2 bg-background">
|
|
{renderNavLinks(true)}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Header;
|