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:
ryan
2026-09-03 09:16:22 +08:00
1065 changed files with 271858 additions and 9600 deletions
+5 -15
View File
@@ -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>
);
}
+8 -13
View File
@@ -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>
+3 -3
View File
@@ -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'
>
+6 -11
View File
@@ -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>
);
}
+3 -2
View File
@@ -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) => {
+12 -46
View File
@@ -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>
)}