mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 10:16:37 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Bell,
|
||||
@@ -21,6 +22,7 @@ import { useTheme } from 'next-themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SearchDialog } from '@/components/layout/search-dialog';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { LanguageSwitcher } from '@/components/common/language-switcher';
|
||||
|
||||
/**
|
||||
* 铃铛按钮组件
|
||||
@@ -28,6 +30,7 @@ import { Kbd } from '@/components/ui/kbd';
|
||||
const BellButton = memo(function BellButton() {
|
||||
const { isRinging } = useBellRing();
|
||||
const { showBell, isMounted } = useNotificationSettings();
|
||||
const t = useTranslations('layout.header');
|
||||
const [isClickAnimating, setIsClickAnimating] = useState(false);
|
||||
|
||||
if (!isMounted) return null;
|
||||
@@ -56,7 +59,7 @@ const BellButton = memo(function BellButton() {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className='sr-only'>通知</span>
|
||||
<span className='sr-only'>{t('notifications')}</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -80,6 +83,7 @@ export function SiteHeader({
|
||||
const { user } = useUser();
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const t = useTranslations('layout.header');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
@@ -102,7 +106,7 @@ export function SiteHeader({
|
||||
<div className='bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Info className='size-4 shrink-0 text-amber-500' />
|
||||
<span>为了您的账号安全,请立即修改密码!</span>
|
||||
<span>{t('changePasswordBanner')}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -110,7 +114,7 @@ export function SiteHeader({
|
||||
className='text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs'
|
||||
onClick={() => router.push('/settings/profile')}
|
||||
>
|
||||
去修改密码
|
||||
{t('changePasswordAction')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -119,7 +123,7 @@ export function SiteHeader({
|
||||
<div className='flex items-center gap-2'>
|
||||
<SidebarTrigger />
|
||||
<span className='text-sm font-medium truncate max-w-[120px]'>
|
||||
{user?.nickname || user?.username || 'Guest'}
|
||||
{user?.nickname || user?.username || t('guest')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
@@ -130,9 +134,10 @@ export function SiteHeader({
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search className='size-[18px]' />
|
||||
<span className='sr-only'>搜索</span>
|
||||
<span className='sr-only'>{t('search')}</span>
|
||||
</Button>
|
||||
<BellButton />
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
@@ -140,7 +145,7 @@ export function SiteHeader({
|
||||
onClick={() => router.push('/settings')}
|
||||
>
|
||||
<Settings className='size-[18px]' />
|
||||
<span className='sr-only'>设置</span>
|
||||
<span className='sr-only'>{t('settings')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,7 +159,7 @@ export function SiteHeader({
|
||||
>
|
||||
<Search className='absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||
<div className='flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted'>
|
||||
<span>搜索</span>
|
||||
<span>{t('search')}</span>
|
||||
<Kbd className='ml-auto gap-0.5 font-mono'>
|
||||
<span>{metaKey}</span>
|
||||
<span>K</span>
|
||||
@@ -164,6 +169,7 @@ export function SiteHeader({
|
||||
|
||||
<div className='ml-auto flex items-center gap-1'>
|
||||
<BellButton />
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
@@ -171,7 +177,7 @@ export function SiteHeader({
|
||||
onClick={() => router.push('/settings')}
|
||||
>
|
||||
<Settings className='size-[18px]' />
|
||||
<span className='sr-only'>设置</span>
|
||||
<span className='sr-only'>{t('settings')}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -185,7 +191,7 @@ export function SiteHeader({
|
||||
) : (
|
||||
<Maximize2 className='size-[18px]' />
|
||||
)}
|
||||
<span className='sr-only'>切换全宽</span>
|
||||
<span className='sr-only'>{t('toggleFullWidth')}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -205,7 +211,7 @@ export function SiteHeader({
|
||||
) : (
|
||||
<Moon className='size-[18px]' />
|
||||
)}
|
||||
<span className='sr-only'>主题切换</span>
|
||||
<span className='sr-only'>{t('toggleTheme')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,6 +225,7 @@ export function SiteHeader({
|
||||
}
|
||||
|
||||
function AppBanner() {
|
||||
const t = useTranslations('layout.header');
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const bannerKey = 'ldc-banner-dismissed-script';
|
||||
|
||||
@@ -246,13 +253,13 @@ function AppBanner() {
|
||||
>
|
||||
<div className='flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground'>
|
||||
<p className='flex flex-wrap items-center justify-center gap-x-2 text-center'>
|
||||
<span className='text-foreground'>更新日志</span>
|
||||
<span className='text-foreground'>{t('bannerTitle')}</span>
|
||||
<a
|
||||
href='https://open-flare.pages.dev/changelog/'
|
||||
target='_blank'
|
||||
className='underline underline-offset-4 hover:text-foreground'
|
||||
>
|
||||
查看详情
|
||||
{t('bannerLink')}
|
||||
</a>
|
||||
</p>
|
||||
<Button
|
||||
@@ -261,7 +268,7 @@ function AppBanner() {
|
||||
className='absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0'
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<span className='sr-only'>Dismiss</span>
|
||||
<span className='sr-only'>{t('dismiss')}</span>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -58,6 +59,8 @@ function SidebarNavGroupMenuItem({
|
||||
pathname: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const t = useTranslations('layout');
|
||||
const groupTitle = t(`groups.${group.titleKey}`);
|
||||
const groupActive = isNavGroupActive(pathname, group);
|
||||
const [open, setOpen] = useState(groupActive);
|
||||
|
||||
@@ -76,26 +79,29 @@ function SidebarNavGroupMenuItem({
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={group.title} isActive={groupActive}>
|
||||
<SidebarMenuButton tooltip={groupTitle} isActive={groupActive}>
|
||||
<group.icon />
|
||||
<span>{group.title}</span>
|
||||
<span>{groupTitle}</span>
|
||||
<ChevronRight className='ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{group.items.map((item) => (
|
||||
<SidebarMenuSubItem key={item.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={matchesNavPath(pathname, item.url, item.childUrls)}
|
||||
>
|
||||
<Link href={item.url} onClick={onNavigate}>
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
{group.items.map((item) => {
|
||||
const title = t(`nav.${item.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuSubItem key={item.url}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={matchesNavPath(pathname, item.url, item.childUrls)}
|
||||
>
|
||||
<Link href={item.url} onClick={onNavigate}>
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
@@ -109,6 +115,7 @@ export function OpenFlareSidebarMenu({
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations('layout');
|
||||
const { config } = usePublicConfig();
|
||||
const displayConfig = useMemo(
|
||||
() => parseMenuDisplayConfig(config?.menu_display_config),
|
||||
@@ -126,7 +133,7 @@ export function OpenFlareSidebarMenu({
|
||||
|
||||
return (
|
||||
<SidebarNavGroupMenuItem
|
||||
key={entry.title}
|
||||
key={entry.titleKey}
|
||||
group={{ ...entry, items: filteredItems }}
|
||||
pathname={pathname}
|
||||
onNavigate={onNavigate}
|
||||
@@ -138,16 +145,17 @@ export function OpenFlareSidebarMenu({
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = t(`nav.${entry.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuItem key={entry.title}>
|
||||
<SidebarMenuItem key={entry.url}>
|
||||
<SidebarMenuButton
|
||||
tooltip={entry.title}
|
||||
tooltip={title}
|
||||
isActive={matchesNavPath(pathname, entry.url, entry.childUrls)}
|
||||
asChild
|
||||
>
|
||||
<Link href={entry.url} onClick={onNavigate}>
|
||||
<entry.icon />
|
||||
<span>{entry.title}</span>
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
||||
import { type SearchItem, searchItems } from '@/lib/utils/search-data';
|
||||
import { FileText, Home, Settings, Shield } from 'lucide-react';
|
||||
import { useUser } from '@/contexts/user-context';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean;
|
||||
@@ -28,34 +29,43 @@ const categoryIcons = {
|
||||
};
|
||||
|
||||
const categoryLabels = {
|
||||
page: '页面',
|
||||
feature: '功能',
|
||||
setting: '设置',
|
||||
admin: '管理',
|
||||
page: 'categoryPage',
|
||||
feature: 'categoryFeature',
|
||||
setting: 'categorySetting',
|
||||
admin: 'categoryAdmin',
|
||||
};
|
||||
|
||||
const getTips = (metaKey: string) => [
|
||||
const getTips = (metaKey: string, t: (key: string) => string) => [
|
||||
<>
|
||||
<span className='text-muted-foreground/80 lowercase'>Tips: 还可以使用</span>
|
||||
<span className='text-muted-foreground/80 lowercase'>
|
||||
{t('tipPrefix')}{' '}
|
||||
</span>
|
||||
<Kbd className='mx-1'>/</Kbd>
|
||||
<span className='text-muted-foreground/80 lowercase'>来打开此界面</span>
|
||||
</>,
|
||||
<>
|
||||
<span className='text-muted-foreground/80 lowercase'>Tips: 使用</span>
|
||||
<Kbd className='mx-1'>↑</Kbd>
|
||||
<Kbd className='mx-1'>↓</Kbd>
|
||||
<span className='text-muted-foreground/80 lowercase'>来切换选中项</span>
|
||||
</>,
|
||||
<>
|
||||
<span className='text-muted-foreground/80 lowercase'>Tips: 按住</span>
|
||||
<Kbd className='mx-1'>{metaKey}</Kbd>
|
||||
<span className='text-muted-foreground/80 lowercase'>+</span>
|
||||
<Kbd className='mx-1'>↵</Kbd>
|
||||
<span className='text-muted-foreground/80 lowercase'>在新标签页打开</span>
|
||||
<span className='text-muted-foreground/80 lowercase'> {t('tipSlash')}</span>
|
||||
</>,
|
||||
<>
|
||||
<span className='text-muted-foreground/80 lowercase'>
|
||||
你知道吗:搜索功能还在持续升级中
|
||||
{t('tipPrefix')}{' '}
|
||||
</span>
|
||||
<Kbd className='mx-1'>↑</Kbd>
|
||||
<Kbd className='mx-1'>↓</Kbd>
|
||||
<span className='text-muted-foreground/80 lowercase'> {t('tipArrow')}</span>
|
||||
</>,
|
||||
<>
|
||||
<span className='text-muted-foreground/80 lowercase'>
|
||||
{t('tipPrefix')}{' '}
|
||||
</span>
|
||||
<Kbd className='mx-1'>{metaKey}</Kbd>
|
||||
<span className='text-muted-foreground/80 lowercase'>+</span>
|
||||
<Kbd className='mx-1'>↵</Kbd>
|
||||
<span className='text-muted-foreground/80 lowercase'>
|
||||
{' '}
|
||||
{t('tipNewTab')}
|
||||
</span>
|
||||
</>,
|
||||
<>
|
||||
<span className='text-muted-foreground/80 lowercase'>
|
||||
{t('tipDidYouKnow')}
|
||||
</span>
|
||||
</>,
|
||||
];
|
||||
@@ -63,6 +73,8 @@ const getTips = (metaKey: string) => [
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const router = useRouter();
|
||||
const { user } = useUser();
|
||||
const t = useTranslations('layout.search');
|
||||
const tLayout = useTranslations('layout');
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null);
|
||||
const [results, setResults] = useState<SearchItem[]>([]);
|
||||
@@ -99,16 +111,16 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const tips = getTips(metaKey);
|
||||
const tips = getTips(metaKey, t);
|
||||
const randomTip = tips[Math.floor(Math.random() * tips.length)];
|
||||
setCurrentTip(randomTip);
|
||||
}
|
||||
}, [open, metaKey]);
|
||||
}, [open, metaKey, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const items = searchItems(search, user?.is_admin);
|
||||
const items = searchItems(search, user?.is_admin, (key) => tLayout(key));
|
||||
setResults(items);
|
||||
}, [search, user?.is_admin]);
|
||||
}, [search, user?.is_admin, tLayout]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: SearchItem, openInNewTab = false) => {
|
||||
@@ -170,18 +182,20 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
className='max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]'
|
||||
>
|
||||
<CommandInput
|
||||
placeholder='搜索页面和功能...'
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
|
||||
<CommandEmpty>{t('noResults')}</CommandEmpty>
|
||||
{Object.entries(groupedResults).map(([category, items]) => {
|
||||
const Icon = categoryIcons[category as keyof typeof categoryIcons];
|
||||
return (
|
||||
<CommandGroup
|
||||
key={category}
|
||||
heading={categoryLabels[category as keyof typeof categoryLabels]}
|
||||
heading={t(
|
||||
categoryLabels[category as keyof typeof categoryLabels],
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
@@ -217,11 +231,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
<span className='ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground'>
|
||||
{
|
||||
{t(
|
||||
categoryLabels[
|
||||
item.category as keyof typeof categoryLabels
|
||||
]
|
||||
}
|
||||
],
|
||||
)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
@@ -235,11 +249,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
|
||||
<Kbd>↵</Kbd>
|
||||
</KbdGroup>
|
||||
<span>{isCtrlPressed ? '在新标签页打开' : '打开'}</span>
|
||||
<span>{isCtrlPressed ? t('openInNewTab') : t('open')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Kbd>Esc</Kbd>
|
||||
<span>关闭搜索界面</span>
|
||||
<span>{t('closeSearch')}</span>
|
||||
</div>
|
||||
<div className='ml-auto flex items-center gap-1'>{currentTip}</div>
|
||||
</div>
|
||||
|
||||
@@ -58,33 +58,39 @@ import {
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useUser } from '@/contexts/user-context';
|
||||
import { usePublicConfig } from '@/hooks/use-public-config';
|
||||
import { OpenFlareSidebarMenu } from '@/components/layout/openflare-sidebar-menu';
|
||||
import { openflareSidebarNav } from '@/lib/navigation/openflare-nav';
|
||||
|
||||
/* 导航数据 */
|
||||
const data = {
|
||||
admin: [
|
||||
{ title: '用户管理', url: '/admin/users', icon: UserRound },
|
||||
{ title: '任务管理', url: '/admin/tasks', icon: Layers },
|
||||
{ title: '存储管理', url: '/admin/files', icon: FolderOpen },
|
||||
{ title: '数据管理', url: '/admin/database', icon: Database },
|
||||
{ title: '通知推送', url: '/admin/push', icon: Bell },
|
||||
// { title: '系统日志', url: '/admin/logs', icon: Terminal },
|
||||
{ title: '系统配置', url: '/admin/system', icon: ShieldCheck },
|
||||
{ title: '系统设置', url: '/admin/settings', icon: Settings },
|
||||
],
|
||||
document: [
|
||||
{
|
||||
title: '使用文档',
|
||||
url: 'https://open-flare.pages.dev/',
|
||||
icon: FileText,
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
type NavItem = {
|
||||
titleKey: string;
|
||||
url: string;
|
||||
icon: React.ComponentType;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
const adminItems: NavItem[] = [
|
||||
{ titleKey: 'users', url: '/admin/users', icon: UserRound },
|
||||
{ titleKey: 'tasks', url: '/admin/tasks', icon: Layers },
|
||||
{ titleKey: 'storage', url: '/admin/files', icon: FolderOpen },
|
||||
{ titleKey: 'database', url: '/admin/database', icon: Database },
|
||||
{ titleKey: 'push', url: '/admin/push', icon: Bell },
|
||||
// { titleKey: 'logs', url: '/admin/logs', icon: Terminal },
|
||||
{ titleKey: 'system', url: '/admin/system', icon: ShieldCheck },
|
||||
{ titleKey: 'adminSettings', url: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
const documentItems: NavItem[] = [
|
||||
{
|
||||
titleKey: 'usageDocs',
|
||||
url: 'https://open-flare.pages.dev/',
|
||||
icon: FileText,
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
function parseMenuDisplayConfig(
|
||||
raw: string | undefined,
|
||||
): Record<string, boolean> {
|
||||
@@ -122,6 +128,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar();
|
||||
const { user, logout, loading: userLoading } = useUser();
|
||||
const { config } = usePublicConfig();
|
||||
const t = useTranslations('layout');
|
||||
const tCommon = useTranslations('common');
|
||||
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = React.useState(false);
|
||||
const pathname = usePathname();
|
||||
@@ -161,9 +169,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
await logout();
|
||||
setShowLogoutDialog(false);
|
||||
} catch (error) {
|
||||
toast.error('登出失败', {
|
||||
toast.error(t('logoutDialog.failed'), {
|
||||
description:
|
||||
error instanceof Error ? error.message : '登出时发生错误,请重试',
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('logoutDialog.failedDescription'),
|
||||
});
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
@@ -171,12 +181,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
const adminFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
|
||||
return data.admin.filter((item) => displayConfig[item.url] !== false);
|
||||
return adminItems.filter((item) => displayConfig[item.url] !== false);
|
||||
}, [config]);
|
||||
|
||||
const documentFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
|
||||
return data.document.filter((item) => displayConfig[item.url] !== false);
|
||||
return documentItems.filter((item) => displayConfig[item.url] !== false);
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
@@ -232,10 +242,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</div>
|
||||
<div className='flex flex-col items-start flex-1 text-left group-data-[collapsible=icon]:hidden'>
|
||||
<span className='text-xs font-medium truncate w-full text-left ml-2'>
|
||||
{user?.nickname || user?.username || 'Unknown User'}
|
||||
{user?.nickname || user?.username || t('user.unknownUser')}
|
||||
</span>
|
||||
<span className='text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2'>
|
||||
{user?.is_admin ? '系统管理员' : '普通用户'}
|
||||
{user?.is_admin ? t('user.adminRole') : t('user.userRole')}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className='size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden' />
|
||||
@@ -286,15 +296,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</div>
|
||||
<span className='text-base font-semibold truncate mt-2'>
|
||||
{userLoading
|
||||
? '加载中...'
|
||||
: user?.nickname || user?.username || '未登录'}
|
||||
? tCommon('loading')
|
||||
: user?.nickname ||
|
||||
user?.username ||
|
||||
t('user.notLoggedIn')}
|
||||
</span>
|
||||
<span className='text-xs font-base text-muted-foreground'>
|
||||
{userLoading
|
||||
? '正在同步账户'
|
||||
? t('user.syncing')
|
||||
: user?.is_admin
|
||||
? '系统管理员'
|
||||
: '普通用户'}
|
||||
? t('user.adminRole')
|
||||
: t('user.userRole')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
@@ -305,7 +317,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}}
|
||||
>
|
||||
<UserRound className='mr-2 size-4' />
|
||||
<span>我的资料</span>
|
||||
<span>{t('user.profile')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -314,7 +326,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}}
|
||||
>
|
||||
<Settings className='mr-2 size-4' />
|
||||
<span>设置</span>
|
||||
<span>{t('user.settings')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className='my-2' />
|
||||
<DropdownMenuItem
|
||||
@@ -328,14 +340,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}}
|
||||
>
|
||||
<FileQuestionMark className='mr-2 size-4' />
|
||||
<span>使用帮助</span>
|
||||
<span>{t('user.help')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='text-destructive hover:bg-destructive/50'
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
>
|
||||
<LogOut className='mr-2 size-4 text-destructive' />
|
||||
<span>退出登录</span>
|
||||
<span>{t('user.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -352,27 +364,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
{user?.is_admin && adminFiltered.length > 0 && (
|
||||
<SidebarGroup className='py-0 pt-4'>
|
||||
<SidebarGroupLabel className='text-xs font-normal text-muted-foreground'>
|
||||
管理
|
||||
{t('groups.admin')}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className='py-1'>
|
||||
<SidebarMenu className='gap-1'>
|
||||
{adminFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={
|
||||
pathname === item.url ||
|
||||
pathname.startsWith(`${item.url}/`)
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{adminFiltered.map((item) => {
|
||||
const title = t(`nav.${item.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton
|
||||
tooltip={title}
|
||||
isActive={
|
||||
pathname === item.url ||
|
||||
pathname.startsWith(`${item.url}/`)
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -381,37 +396,40 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
{documentFiltered.length > 0 && (
|
||||
<SidebarGroup className='py-0 pt-4'>
|
||||
<SidebarGroupLabel className='text-xs font-normal text-muted-foreground'>
|
||||
文档库
|
||||
{t('groups.docs')}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className='py-1'>
|
||||
<SidebarMenu className='gap-1'>
|
||||
{documentFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
{item.external ? (
|
||||
<Link
|
||||
href={item.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={handleCloseSidebar}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span className='flex-1'>{item.title}</span>
|
||||
<ArrowUpRight className='size-3 text-muted-foreground' />
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span className='flex-1'>{item.title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{documentFiltered.map((item) => {
|
||||
const title = t(`nav.${item.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton
|
||||
tooltip={title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
{item.external ? (
|
||||
<Link
|
||||
href={item.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={handleCloseSidebar}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span className='flex-1'>{title}</span>
|
||||
<ArrowUpRight className='size-3 text-muted-foreground' />
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span className='flex-1'>{title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -431,18 +449,22 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认登出</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('logoutDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isLoggingOut
|
||||
? '正在登出,请稍候...'
|
||||
: '您确定要登出当前账户吗?'}
|
||||
? t('logoutDialog.loggingOut')
|
||||
: t('logoutDialog.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoggingOut}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isLoggingOut}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut && <Spinner className='mr-2' />}
|
||||
{isLoggingOut ? '登出中...' : '确认登出'}
|
||||
{isLoggingOut
|
||||
? t('logoutDialog.inProgress')
|
||||
: t('logoutDialog.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
Reference in New Issue
Block a user