mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # AGENTS.md # backend/docs/docs.go # backend/docs/swagger.json # backend/docs/swagger.yaml # frontend/components/providers/title-updater.tsx # frontend/messages/fragments/admin.en.json # frontend/messages/fragments/admin.zh-CN.json # frontend/proxy.ts
This commit is contained in:
@@ -3,7 +3,6 @@ import { FileTextIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* 空状态展示组件属性
|
||||
@@ -56,9 +55,6 @@ export function EmptyState({
|
||||
className,
|
||||
iconSize = 'md',
|
||||
}: EmptyStateProps) {
|
||||
const t = useTranslations('layout.empty');
|
||||
const displayTitle = title ?? t('noData');
|
||||
const displayDescription = description ?? t('noContent');
|
||||
const iconSizes = { sm: 'size-10', md: 'size-12', lg: 'size-16' };
|
||||
const iconInnerSizes = { sm: 'size-5', md: 'size-6', lg: 'size-8' };
|
||||
|
||||
@@ -80,14 +76,10 @@ export function EmptyState({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{displayTitle && (
|
||||
<p className='text-base font-medium mb-1'>{displayTitle}</p>
|
||||
)}
|
||||
{title && <p className='text-base font-medium mb-1'>{title}</p>}
|
||||
|
||||
{displayDescription && (
|
||||
<p className='text-sm text-muted-foreground max-w-md'>
|
||||
{displayDescription}
|
||||
</p>
|
||||
{description && (
|
||||
<p className='text-sm text-muted-foreground max-w-md'>{description}</p>
|
||||
)}
|
||||
|
||||
{onAction && actionText && (
|
||||
@@ -136,12 +128,10 @@ export function EmptyStateWithBorder(props: EmptyStateProps) {
|
||||
* @returns {React.ReactNode} 简化版空状态组件
|
||||
*/
|
||||
export function EmptyInline({
|
||||
message,
|
||||
message = '暂无数据',
|
||||
icon: Icon = FileTextIcon,
|
||||
className,
|
||||
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
|
||||
const t = useTranslations('layout.empty');
|
||||
const displayMessage = message ?? t('noData');
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -150,7 +140,7 @@ export function EmptyInline({
|
||||
)}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
<span>{displayMessage}</span>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* 错误展示组件属性
|
||||
@@ -49,19 +48,16 @@ export interface ErrorDisplayProps {
|
||||
* @returns {React.ReactNode} 错误展示组件
|
||||
*/
|
||||
export function ErrorDisplay({
|
||||
title,
|
||||
title = '加载失败',
|
||||
message,
|
||||
error,
|
||||
onRetry,
|
||||
retryText,
|
||||
retryText = '重试',
|
||||
icon: Icon = AlertCircle,
|
||||
className,
|
||||
showStack = false,
|
||||
}: ErrorDisplayProps) {
|
||||
const t = useTranslations('layout.error');
|
||||
const displayTitle = title ?? t('loadFailed');
|
||||
const displayRetryText = retryText ?? t('retry');
|
||||
const errorMessage = message || error?.message || t('unknownError');
|
||||
const errorMessage = message || error?.message || '发生未知错误,请稍后重试';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -74,7 +70,7 @@ export function ErrorDisplay({
|
||||
<Icon className='size-6 text-red-600 dark:text-red-400' />
|
||||
</div>
|
||||
|
||||
<p className='text-lg font-semibold mb-2'>{displayTitle}</p>
|
||||
<p className='text-lg font-semibold mb-2'>{title}</p>
|
||||
|
||||
<p className='text-sm text-muted-foreground max-w-md mb-4'>
|
||||
{errorMessage}
|
||||
@@ -82,14 +78,14 @@ export function ErrorDisplay({
|
||||
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant='outline' className='mt-2'>
|
||||
{displayRetryText}
|
||||
{retryText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
|
||||
<details className='mt-6 text-left max-w-2xl w-full'>
|
||||
<summary className='cursor-pointer text-xs text-muted-foreground hover:text-foreground'>
|
||||
{t('viewDetails')}
|
||||
查看详细错误信息
|
||||
</summary>
|
||||
<pre className='mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto'>
|
||||
{error.stack}
|
||||
@@ -119,8 +115,7 @@ export function ErrorInline({
|
||||
onRetry,
|
||||
className,
|
||||
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
|
||||
const t = useTranslations('layout.error');
|
||||
const errorMessage = message || error?.message || t('unknownErrorShort');
|
||||
const errorMessage = message || error?.message || '发生未知错误';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -138,7 +133,7 @@ export function ErrorInline({
|
||||
size='sm'
|
||||
className='h-6 px-4 text-xs'
|
||||
>
|
||||
{t('retry')}
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@ import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SearchDialog } from '@/components/layout/search-dialog';
|
||||
import { LanguageSwitcher } from '@/components/common/language-switcher';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { LanguageSwitcher } from '@/components/common/language-switcher';
|
||||
|
||||
/**
|
||||
* 铃铛按钮组件
|
||||
@@ -123,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'>
|
||||
@@ -255,7 +255,7 @@ function AppBanner() {
|
||||
<p className='flex flex-wrap items-center justify-center gap-x-2 text-center'>
|
||||
<span className='text-foreground'>{t('bannerTitle')}</span>
|
||||
<a
|
||||
href='https://example.com'
|
||||
href='https://openflare.fyrn.link/changelog/'
|
||||
target='_blank'
|
||||
className='underline underline-offset-4 hover:text-foreground'
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* 加载页面组件
|
||||
@@ -61,7 +60,6 @@ export function AppleSpinner({
|
||||
* 用于统一显示加载状态
|
||||
*/
|
||||
export function LoadingPage(props: { text?: string; badgeText?: string }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _ = props;
|
||||
return (
|
||||
<div className='absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center'>
|
||||
@@ -89,15 +87,12 @@ interface LoadingStateProps extends React.ComponentProps<'div'> {
|
||||
* 用于统一显示加载中的状态
|
||||
*/
|
||||
export function LoadingState({
|
||||
title,
|
||||
description,
|
||||
title = '加载中',
|
||||
description = '正在获取活动数据...',
|
||||
icon: Icon = FileTextIcon,
|
||||
className,
|
||||
iconSize = 'md',
|
||||
}: LoadingStateProps) {
|
||||
const t = useTranslations('layout.loading');
|
||||
const displayTitle = title ?? t('loading');
|
||||
const displayDescription = description ?? t('loadingDesc');
|
||||
const iconSizes = { sm: 'size-8', md: 'size-10', lg: 'size-14' };
|
||||
const iconInnerSizes = { sm: 'size-4', md: 'size-5', lg: 'size-7' };
|
||||
|
||||
@@ -119,13 +114,13 @@ export function LoadingState({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{displayTitle && (
|
||||
<p className='text-sm font-medium mb-1 animate-pulse'>{displayTitle}</p>
|
||||
{title && (
|
||||
<p className='text-sm font-medium mb-1 animate-pulse'>{title}</p>
|
||||
)}
|
||||
|
||||
{displayDescription && (
|
||||
{description && (
|
||||
<p className='text-xs text-muted-foreground max-w-md animate-pulse'>
|
||||
{displayDescription}
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
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,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import type { OpenFlareNavGroup } from '@/lib/navigation/openflare-nav';
|
||||
import {
|
||||
matchesNavPath,
|
||||
openflareSidebarNav,
|
||||
isNavGroupActive,
|
||||
} from '@/lib/navigation/openflare-nav';
|
||||
import { usePublicConfig } from '@/hooks/use-public-config';
|
||||
|
||||
function parseMenuDisplayConfig(
|
||||
raw: string | undefined,
|
||||
): Record<string, boolean> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
return {};
|
||||
|
||||
return Object.entries(parsed).reduce<Record<string, boolean>>(
|
||||
(result, [key, value]) => {
|
||||
if (typeof value === 'boolean') {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarNavGroupMenuItem({
|
||||
group,
|
||||
pathname,
|
||||
onNavigate,
|
||||
}: {
|
||||
group: OpenFlareNavGroup;
|
||||
pathname: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const t = useTranslations('layout');
|
||||
const groupTitle = t(`groups.${group.titleKey}`);
|
||||
const groupActive = isNavGroupActive(pathname, group);
|
||||
const [open, setOpen] = useState(groupActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupActive) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [groupActive]);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={groupTitle} isActive={groupActive}>
|
||||
<group.icon />
|
||||
<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) => {
|
||||
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>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenFlareSidebarMenu({
|
||||
onNavigate,
|
||||
}: {
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations('layout');
|
||||
const { config } = usePublicConfig();
|
||||
const displayConfig = useMemo(
|
||||
() => parseMenuDisplayConfig(config?.menu_display_config),
|
||||
[config],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenu className='gap-1'>
|
||||
{openflareSidebarNav.map((entry) => {
|
||||
if (entry.kind === 'group') {
|
||||
const filteredItems = entry.items.filter(
|
||||
(item) => displayConfig[item.url] !== false,
|
||||
);
|
||||
if (filteredItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SidebarNavGroupMenuItem
|
||||
key={entry.titleKey}
|
||||
group={{ ...entry, items: filteredItems }}
|
||||
pathname={pathname}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayConfig[entry.url] === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = t(`nav.${entry.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuItem key={entry.url}>
|
||||
<SidebarMenuButton
|
||||
tooltip={title}
|
||||
isActive={matchesNavPath(pathname, entry.url, entry.childUrls)}
|
||||
asChild
|
||||
>
|
||||
<Link href={entry.url} onClick={onNavigate}>
|
||||
<entry.icon />
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,7 @@ 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[]>([]);
|
||||
@@ -117,9 +118,9 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
}, [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) => {
|
||||
|
||||
@@ -47,25 +47,22 @@ import {
|
||||
ArrowUpRight,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Code,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Home,
|
||||
Layers,
|
||||
LogOut,
|
||||
MessagesSquare,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
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';
|
||||
|
||||
type NavItem = {
|
||||
titleKey: string;
|
||||
@@ -74,33 +71,21 @@ type NavItem = {
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
const navMainItems: NavItem[] = [
|
||||
{ titleKey: 'home', url: '/home', icon: Home },
|
||||
{ titleKey: 'myFiles', url: '/files', icon: FolderOpen },
|
||||
];
|
||||
|
||||
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: 'messageGateway',
|
||||
url: '/admin/message-gateway',
|
||||
icon: MessagesSquare,
|
||||
},
|
||||
{ titleKey: 'logs', url: '/admin/logs', icon: Terminal },
|
||||
// { 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: 'demo', url: '/admin/demo', icon: Code },
|
||||
{ titleKey: 'apiDocs', url: '/docs/api', icon: CreditCard, external: true },
|
||||
{
|
||||
titleKey: 'usageDocs',
|
||||
url: '/docs/how-to-use',
|
||||
url: 'https://openflare.fyrn.link/',
|
||||
icon: FileText,
|
||||
external: true,
|
||||
},
|
||||
@@ -194,11 +179,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}
|
||||
};
|
||||
|
||||
const navMainFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
|
||||
return navMainItems.filter((item) => displayConfig[item.url] !== false);
|
||||
}, [config]);
|
||||
|
||||
const adminFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
|
||||
return adminItems.filter((item) => displayConfig[item.url] !== false);
|
||||
@@ -265,7 +245,7 @@ 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 ? t('user.adminRole') : t('user.userRole')}
|
||||
@@ -354,7 +334,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<DropdownMenuSeparator className='my-2' />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
router.push('/docs/how-to-use');
|
||||
window.open(
|
||||
'https://openflare.fyrn.link/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
handleCloseSidebar();
|
||||
}}
|
||||
>
|
||||
@@ -372,28 +356,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</DropdownMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className='group-data-[collapsible=icon]'>
|
||||
{navMainFiltered.length > 0 && (
|
||||
{openflareSidebarNav.length > 0 && (
|
||||
<SidebarGroup className='py-0'>
|
||||
<SidebarGroupContent className='py-1'>
|
||||
<SidebarMenu className='gap-1'>
|
||||
{navMainFiltered.map((item) => {
|
||||
const title = t(`nav.${item.titleKey}`);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton
|
||||
tooltip={title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
<OpenFlareSidebarMenu onNavigate={handleCloseSidebar} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user