mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 06:36:38 +08:00
[优化] go 引用调整
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import type {EChartsOption} from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
type RankChartItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type RankChartProps = {
|
||||
items: RankChartItem[];
|
||||
color: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
|
||||
|
||||
function getChartValue(params: unknown) {
|
||||
if (typeof params !== 'object' || params === null || !('value' in params)) {
|
||||
return 0;
|
||||
}
|
||||
const rawValue = (params as { value?: unknown }).value;
|
||||
if (Array.isArray(rawValue)) {
|
||||
const candidate = rawValue[0];
|
||||
return typeof candidate === 'number' ? candidate : 0;
|
||||
}
|
||||
return typeof rawValue === 'number' ? rawValue : 0;
|
||||
}
|
||||
|
||||
export function RankChart({
|
||||
items,
|
||||
color,
|
||||
valueFormatter = defaultFormatter,
|
||||
emptyMessage = '暂无分布数据',
|
||||
}: RankChartProps) {
|
||||
const option = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
animationDuration: 400,
|
||||
grid: {
|
||||
left: 16,
|
||||
right: 24,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const data = item as { name?: string; value?: number };
|
||||
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.16)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: items.map((item) => item.label),
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: '#cbd5e1',
|
||||
width: 120,
|
||||
overflow: 'truncate',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: items.map((item) => item.value),
|
||||
barWidth: 12,
|
||||
showBackground: true,
|
||||
backgroundStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.12)',
|
||||
borderRadius: 999,
|
||||
},
|
||||
itemStyle: {
|
||||
color,
|
||||
borderRadius: 999,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#e2e8f0',
|
||||
formatter: (params: unknown) =>
|
||||
valueFormatter(getChartValue(params)),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[color, items, valueFormatter],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{ height: Math.max(220, items.length * 44), width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
|
||||
type TrendChartSeries = {
|
||||
label: string;
|
||||
color: string;
|
||||
fillColor?: string;
|
||||
values: number[];
|
||||
variant?: 'line' | 'area';
|
||||
valueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
type TrendChartProps = {
|
||||
labels: string[];
|
||||
series: TrendChartSeries[];
|
||||
height?: number;
|
||||
yAxisValueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
type TooltipParam = {
|
||||
axisValueLabel?: string;
|
||||
color?: string;
|
||||
seriesName?: string;
|
||||
value?: number | string | Array<number | string>;
|
||||
};
|
||||
|
||||
const defaultFormatter = (value: number) => formatCompactNumber(value);
|
||||
|
||||
export function TrendChart({
|
||||
labels,
|
||||
series,
|
||||
height = 220,
|
||||
yAxisValueFormatter,
|
||||
}: TrendChartProps) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
|
||||
const maxValue = calculateNiceAxisMax(
|
||||
series.flatMap((item) => item.values),
|
||||
);
|
||||
|
||||
return {
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const items = Array.isArray(params) ? (params as TooltipParam[]) : [];
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const header = items[0]?.axisValueLabel ?? '';
|
||||
const rows = items.map((item) => {
|
||||
const matchedSeries = series.find(
|
||||
(seriesItem) => seriesItem.label === item.seriesName,
|
||||
);
|
||||
const formatter =
|
||||
matchedSeries?.valueFormatter ??
|
||||
yAxisValueFormatter ??
|
||||
defaultFormatter;
|
||||
const rawValue = Array.isArray(item.value)
|
||||
? item.value[1]
|
||||
: item.value;
|
||||
const numericValue =
|
||||
typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
|
||||
|
||||
return [
|
||||
'<span style="display:inline-flex;align-items:center;gap:8px;">',
|
||||
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${item.color ?? '#94a3b8'};"></span>`,
|
||||
`<span>${item.seriesName ?? ''}</span>`,
|
||||
`<strong style="margin-left:8px;">${formatter(numericValue)}</strong>`,
|
||||
'</span>',
|
||||
].join('');
|
||||
});
|
||||
|
||||
return [header, ...rows].join('<br/>');
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: labels,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.24)',
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
margin: 14,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
formatter: (value: number) => axisFormatter(value),
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.16)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
series: series.map((item) => ({
|
||||
name: item.label,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
color: item.color,
|
||||
width: 3,
|
||||
},
|
||||
itemStyle: {
|
||||
color: item.color,
|
||||
},
|
||||
areaStyle:
|
||||
item.variant === 'area'
|
||||
? {
|
||||
color: item.fillColor ?? `${item.color}33`,
|
||||
}
|
||||
: undefined,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
scale: true,
|
||||
},
|
||||
data: item.values,
|
||||
})),
|
||||
};
|
||||
}, [labels, series, yAxisValueFormatter]);
|
||||
|
||||
if (labels.length === 0 || series.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
|
||||
暂无趋势数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{series.map((item) => {
|
||||
const latestValue = item.values[item.values.length - 1] ?? 0;
|
||||
const formatter = item.valueFormatter ?? defaultFormatter;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="min-w-[140px] rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{formatter(latestValue)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{ height, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, children }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm">
|
||||
<p className="text-base font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</p>
|
||||
{description ? (
|
||||
<p className="mt-2 leading-6 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{children ? <div className="mt-4">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface ErrorStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ErrorState({ title, description }: ErrorStateProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-5 py-6 text-sm">
|
||||
<p className="text-base font-semibold text-[var(--status-danger-foreground)]">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-2 leading-6 text-[var(--status-danger-foreground)]/80">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {StatusBadge} from '@/components/ui/status-badge';
|
||||
|
||||
interface FeaturePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
milestones: string[];
|
||||
}
|
||||
|
||||
export function FeaturePlaceholder({
|
||||
title,
|
||||
description,
|
||||
milestones,
|
||||
}: FeaturePlaceholderProps) {
|
||||
return (
|
||||
<AppCard
|
||||
title={title}
|
||||
description={description}
|
||||
action={<StatusBadge label="阶段 1 骨架完成" variant="success" />}
|
||||
>
|
||||
<ul className="space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{milestones.map((item) => (
|
||||
<li key={item} className="flex gap-3">
|
||||
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--brand-primary)]" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
type InlineMessageTone = 'info' | 'success' | 'danger';
|
||||
|
||||
const toneClasses: Record<InlineMessageTone, string> = {
|
||||
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
|
||||
success:
|
||||
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
||||
danger:
|
||||
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
|
||||
};
|
||||
|
||||
interface InlineMessageProps {
|
||||
tone?: InlineMessageTone;
|
||||
message: string;
|
||||
className?: string;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export function InlineMessage({
|
||||
tone = 'info',
|
||||
message,
|
||||
className,
|
||||
onClear,
|
||||
}: InlineMessageProps) {
|
||||
const onClearRef = useRef(onClear);
|
||||
|
||||
useEffect(() => {
|
||||
onClearRef.current = onClear;
|
||||
}, [onClear]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only trigger toast if onClear is provided (dynamic feedback)
|
||||
if (!onClearRef.current || !message) return;
|
||||
|
||||
const options = {
|
||||
position: 'bottom-right' as const,
|
||||
};
|
||||
|
||||
if (tone === 'success') {
|
||||
toast.success(message, options);
|
||||
} else if (tone === 'danger') {
|
||||
toast.error(message, options);
|
||||
} else {
|
||||
toast(message, options);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onClearRef.current?.();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [tone, message]);
|
||||
|
||||
// If onClear is provided, this is a toast feedback notice, so render nothing inline
|
||||
if (onClear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise, render as a static/persistent inline alert banner (original behavior)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm leading-6',
|
||||
toneClasses[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
interface LoadingStateProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingState({ className }: LoadingStateProps) {
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-[var(--control-background-hover)]" />
|
||||
<div className="h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]" />
|
||||
<div className="h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DashboardSidebar } from '@/components/layout/dashboard-sidebar';
|
||||
import { DashboardTopbar } from '@/components/layout/dashboard-topbar';
|
||||
|
||||
interface DashboardShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardShell({ children }: DashboardShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-transparent text-[var(--foreground-primary)]">
|
||||
<DashboardSidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<DashboardTopbar />
|
||||
<main className="flex-1 px-4 py-6 md:px-8 md:py-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { dashboardNavigation } from '@/lib/constants/navigation';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { isNavigationItemActive } from '@/lib/utils/navigation';
|
||||
import { useAppShellStore } from '@/store/app-shell';
|
||||
import type { NavigationIconKey, NavigationItem } from '@/types/navigation';
|
||||
|
||||
function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
|
||||
const commonProps = {
|
||||
className: 'h-[18px] w-[18px]',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
viewBox: '0 0 24 24',
|
||||
};
|
||||
|
||||
switch (icon) {
|
||||
case 'home':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M3 10.5 12 3l9 7.5" />
|
||||
<path d="M5.5 9.5V21h13V9.5" />
|
||||
<path d="M9.5 21v-6h5v6" />
|
||||
</svg>
|
||||
);
|
||||
case 'node':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<rect x="4" y="4" width="6" height="6" rx="1.5" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1.5" />
|
||||
<rect x="9" y="14" width="6" height="6" rx="1.5" />
|
||||
<path d="M10 7h4M12 10v4" />
|
||||
</svg>
|
||||
);
|
||||
case 'website':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M3.5 12h17" />
|
||||
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
|
||||
</svg>
|
||||
);
|
||||
case 'origin':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M12 4.5c3.3 0 6 2.5 6 5.6 0 4.4-6 9.4-6 9.4s-6-5-6-9.4c0-3.1 2.7-5.6 6-5.6Z" />
|
||||
<circle cx="12" cy="10.1" r="1.9" />
|
||||
</svg>
|
||||
);
|
||||
case 'domain':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4 8.5h16" />
|
||||
<path d="M4 15.5h16" />
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="3" />
|
||||
</svg>
|
||||
);
|
||||
case 'certificate':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M7 4.5h10l2 2V14a7 7 0 1 1-14 0V4.5Z" />
|
||||
<path d="M9 10h6" />
|
||||
<path d="M10 14h4" />
|
||||
</svg>
|
||||
);
|
||||
case 'proxy':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M7 7h10" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M7 17h6" />
|
||||
<path d="m14 15 3 2-3 2" />
|
||||
</svg>
|
||||
);
|
||||
case 'pages':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M5 5.5h14v13H5z" />
|
||||
<path d="M8 9h8" />
|
||||
<path d="M8 12h5" />
|
||||
<path d="M8 15h7" />
|
||||
</svg>
|
||||
);
|
||||
case 'waf':
|
||||
return <ShieldCheck className="h-[18px] w-[18px]" strokeWidth={1.8} />;
|
||||
case 'release':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M12 4v10" />
|
||||
<path d="m8.5 7.5 3.5-3.5 3.5 3.5" />
|
||||
<path d="M5 19.5h14" />
|
||||
</svg>
|
||||
);
|
||||
case 'log':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<rect x="5" y="4" width="14" height="16" rx="2.5" />
|
||||
<path d="M8 8h8" />
|
||||
<path d="M8 12h8" />
|
||||
<path d="M8 16h5" />
|
||||
</svg>
|
||||
);
|
||||
case 'performance':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4.5 19.5h15" />
|
||||
<path d="M7 16l3-4 3 2 4-6" />
|
||||
<path d="m15.5 8 1.5 0 0 1.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'user':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx="12" cy="8" r="3.25" />
|
||||
<path d="M5 19.5c1.7-3 4.1-4.5 7-4.5s5.3 1.5 7 4.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'setting':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx="12" cy="12" r="3.5" />
|
||||
<path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18.1 5.9l-1.6 1.6M7.5 16.5l-1.6 1.6M18.1 18.1l-1.6-1.6M7.5 7.5 5.9 5.9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarNavItem({
|
||||
item,
|
||||
currentPath,
|
||||
isSidebarCollapsed,
|
||||
forceExpanded,
|
||||
onNavigate,
|
||||
depth = 0,
|
||||
}: {
|
||||
item: NavigationItem;
|
||||
currentPath: string;
|
||||
isSidebarCollapsed: boolean;
|
||||
forceExpanded?: boolean;
|
||||
onNavigate?: () => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const active = isNavigationItemActive(currentPath, item);
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
const showLabel = forceExpanded || !isSidebarCollapsed;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors',
|
||||
depth > 0 && 'ml-3 rounded-xl',
|
||||
active
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]">
|
||||
<SidebarIcon icon={item.icon} />
|
||||
</span>
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
{showLabel && hasChildren ? (
|
||||
<div className="space-y-2">
|
||||
{item.children?.map((child) => (
|
||||
<SidebarNavItem
|
||||
key={child.href}
|
||||
item={child}
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
forceExpanded={forceExpanded}
|
||||
onNavigate={onNavigate}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
currentPath,
|
||||
isSidebarCollapsed,
|
||||
forceExpanded = false,
|
||||
onNavigate,
|
||||
}: {
|
||||
currentPath: string;
|
||||
isSidebarCollapsed: boolean;
|
||||
forceExpanded?: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const showLabel = forceExpanded || !isSidebarCollapsed;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-5">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-sm font-semibold text-[var(--brand-primary)]">
|
||||
AF
|
||||
</div>
|
||||
{showLabel ? (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
OpenFlare
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pr-1">
|
||||
{dashboardNavigation.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
forceExpanded={forceExpanded}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const currentPath = pathname ?? '/';
|
||||
const isSidebarCollapsed = useAppShellStore(
|
||||
(state) => state.isSidebarCollapsed,
|
||||
);
|
||||
const isMobileSidebarOpen = useAppShellStore(
|
||||
(state) => state.isMobileSidebarOpen,
|
||||
);
|
||||
const setMobileSidebarOpen = useAppShellStore(
|
||||
(state) => state.setMobileSidebarOpen,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileSidebarOpen(false);
|
||||
}, [currentPath, setMobileSidebarOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-30 bg-black/35 transition-opacity duration-200 min-[1000px]:hidden',
|
||||
isMobileSidebarOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-40 h-screen w-[200px] overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur transition-transform duration-200 min-[1000px]:hidden',
|
||||
isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={false}
|
||||
forceExpanded
|
||||
onNavigate={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'sticky top-0 z-10 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur min-[1000px]:block',
|
||||
isSidebarCollapsed ? 'w-[76px]' : 'w-[200px]',
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
createUpgradeLogsWebSocket,
|
||||
confirmManualServerUpgrade,
|
||||
getLatestRelease,
|
||||
parseUpgradeStreamSnapshot,
|
||||
upgradeServer,
|
||||
uploadServerBinary,
|
||||
} from '@/features/update/api/update';
|
||||
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
ReleaseChannel,
|
||||
UpgradeStreamSnapshot,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
import { publicEnv } from '@/lib/env/public-env';
|
||||
import { useAppShellStore } from '@/store/app-shell';
|
||||
|
||||
export function DashboardTopbar() {
|
||||
const router = useRouter();
|
||||
const { logout, user } = useAuth();
|
||||
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
|
||||
const isMobileSidebarOpen = useAppShellStore(
|
||||
(state) => state.isMobileSidebarOpen,
|
||||
);
|
||||
const setMobileSidebarOpen = useAppShellStore(
|
||||
(state) => state.setMobileSidebarOpen,
|
||||
);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [selectedReleaseChannel, setSelectedReleaseChannel] =
|
||||
useState<ReleaseChannel>('stable');
|
||||
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
|
||||
const [manualUpgradeStatus, setManualUpgradeStatus] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [manualUpgradeError, setManualUpgradeError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadedBinary, setUploadedBinary] =
|
||||
useState<UploadedServerBinaryInfo | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [upgradeStream, setUpgradeStream] =
|
||||
useState<UpgradeStreamSnapshot | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const upgradeRefreshPendingRef = useRef(false);
|
||||
const upgradeReloadStartedRef = useRef(false);
|
||||
const upgradeReloadTimerRef = useRef<number | null>(null);
|
||||
const isRoot = (user?.role ?? 0) >= 100;
|
||||
const upgradeStatusPollInterval = 3000;
|
||||
|
||||
const publicStatusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const stableReleaseQuery = useQuery({
|
||||
queryKey: ['update', 'latest-release', 'stable'],
|
||||
queryFn: () => getLatestRelease('stable'),
|
||||
enabled: isRoot,
|
||||
refetchInterval: (query) => {
|
||||
const release = query.state.data;
|
||||
if (isVersionModalOpen && release?.in_progress) {
|
||||
return upgradeStatusPollInterval;
|
||||
}
|
||||
return 60 * 60 * 1000;
|
||||
},
|
||||
});
|
||||
|
||||
const previewReleaseQuery = useQuery({
|
||||
queryKey: ['update', 'latest-release', 'preview'],
|
||||
queryFn: () => getLatestRelease('preview'),
|
||||
enabled: false,
|
||||
refetchInterval: (query) => {
|
||||
const release = query.state.data;
|
||||
if (isVersionModalOpen && release?.in_progress) {
|
||||
return upgradeStatusPollInterval;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: (channel: ReleaseChannel) => upgradeServer(channel),
|
||||
onSuccess: (release) => {
|
||||
upgradeRefreshPendingRef.current = true;
|
||||
setUploadedBinary(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
setVersionFeedback(
|
||||
`服务升级任务已启动,目标版本 ${release.tag_name}(${release.channel === 'preview' ? '预览版' : '正式版'})。页面可能短暂不可用。`,
|
||||
);
|
||||
void stableReleaseQuery.refetch();
|
||||
if (release.channel === 'preview') {
|
||||
void previewReleaseQuery.refetch();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setVersionFeedback(
|
||||
error instanceof Error ? error.message : '升级失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadBinaryMutation = useMutation({
|
||||
mutationFn: (binary: File) =>
|
||||
uploadServerBinary(binary, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
}),
|
||||
onSuccess: (candidate) => {
|
||||
setUploadProgress(0);
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeError(null);
|
||||
setUploadedBinary(candidate);
|
||||
setManualUpgradeStatus(candidate.comparison_message);
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadProgress(0);
|
||||
setUploadedBinary(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(
|
||||
error instanceof Error ? error.message : '上传升级包失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmManualUpgradeMutation = useMutation({
|
||||
mutationFn: confirmManualServerUpgrade,
|
||||
onSuccess: (candidate) => {
|
||||
upgradeRefreshPendingRef.current = true;
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeError(null);
|
||||
setUploadedBinary(candidate);
|
||||
setManualUpgradeStatus(
|
||||
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
|
||||
);
|
||||
void stableReleaseQuery.refetch();
|
||||
void previewReleaseQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '确认手动升级失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleUpgradePageReload = useCallback(() => {
|
||||
if (upgradeReloadStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
upgradeReloadStartedRef.current = true;
|
||||
setVersionFeedback('服务升级已进入重启阶段,页面将在服务恢复后自动刷新。');
|
||||
|
||||
const reloadWhenServerReady = async () => {
|
||||
try {
|
||||
await getPublicStatus();
|
||||
window.location.reload();
|
||||
} catch {
|
||||
upgradeReloadTimerRef.current = window.setTimeout(
|
||||
reloadWhenServerReady,
|
||||
1500,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
upgradeReloadTimerRef.current = window.setTimeout(
|
||||
reloadWhenServerReady,
|
||||
1200,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (upgradeReloadTimerRef.current !== null) {
|
||||
window.clearTimeout(upgradeReloadTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVersionModalOpen || !isRoot) {
|
||||
setUpgradeStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket = createUpgradeLogsWebSocket();
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const snapshot = parseUpgradeStreamSnapshot(String(event.data));
|
||||
if (snapshot) {
|
||||
if (snapshot.in_progress || snapshot.upgrade_status === 'succeeded') {
|
||||
upgradeRefreshPendingRef.current = true;
|
||||
}
|
||||
if (snapshot.upgrade_status === 'failed') {
|
||||
upgradeRefreshPendingRef.current = false;
|
||||
}
|
||||
setUpgradeStream(snapshot);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (upgradeRefreshPendingRef.current) {
|
||||
scheduleUpgradePageReload();
|
||||
return;
|
||||
}
|
||||
reconnectTimer = window.setTimeout(connect, 1500);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
}
|
||||
socket?.close();
|
||||
};
|
||||
}, [isRoot, isVersionModalOpen, scheduleUpgradePageReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
setIsUserMenuOpen(false);
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const handleSidebarToggle = () => {
|
||||
if (window.innerWidth < 1000) {
|
||||
setMobileSidebarOpen(!isMobileSidebarOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
toggleSidebar();
|
||||
};
|
||||
|
||||
const handleOpenVersionModal = () => {
|
||||
setSelectedReleaseChannel('stable');
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
setIsVersionModalOpen(true);
|
||||
if (isRoot) {
|
||||
void stableReleaseQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
upgradeMutation.mutate(selectedReleaseChannel);
|
||||
};
|
||||
|
||||
const handleCheckRelease = () => {
|
||||
setVersionFeedback(null);
|
||||
if (isRoot) {
|
||||
if (selectedReleaseChannel === 'preview') {
|
||||
void previewReleaseQuery.refetch();
|
||||
} else {
|
||||
void stableReleaseQuery.refetch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReleaseChannelChange = (channel: ReleaseChannel) => {
|
||||
setSelectedReleaseChannel(channel);
|
||||
setVersionFeedback(null);
|
||||
};
|
||||
|
||||
const handleUploadBinary = (binary: File) => {
|
||||
setUploadProgress(0);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
uploadBinaryMutation.mutate(binary);
|
||||
};
|
||||
|
||||
const handleConfirmManualUpgrade = () => {
|
||||
if (!uploadedBinary?.upload_token) {
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError('请先上传并检查升级包。');
|
||||
return;
|
||||
}
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
|
||||
};
|
||||
|
||||
const selectedRelease =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.data
|
||||
: stableReleaseQuery.data;
|
||||
const releaseWithStream = mergeReleaseWithUpgradeStream(
|
||||
selectedRelease,
|
||||
upgradeStream,
|
||||
);
|
||||
const selectedReleaseError =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.error
|
||||
: stableReleaseQuery.error;
|
||||
const isSelectedReleaseError =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isError
|
||||
: stableReleaseQuery.isError;
|
||||
const hasUpdate = Boolean(isRoot && stableReleaseQuery.data?.has_update);
|
||||
const currentVersion = publicStatusQuery.data?.version || 'unknown';
|
||||
const versionLabel = hasUpdate
|
||||
? `版本 ${publicEnv.appVersion} · 可升级`
|
||||
: `版本 ${publicEnv.appVersion}`;
|
||||
const versionButtonClassName = hasUpdate
|
||||
? 'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]'
|
||||
: 'border-[var(--border-default)]';
|
||||
const versionErrorMessage =
|
||||
versionFeedback ||
|
||||
(isSelectedReleaseError
|
||||
? selectedReleaseError instanceof Error
|
||||
? selectedReleaseError.message
|
||||
: '版本检查失败,请稍后重试。'
|
||||
: undefined);
|
||||
const manualUpgradeErrorMessage = manualUpgradeError ?? undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-20 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/88 px-4 py-4 backdrop-blur md:px-8">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSidebarToggle}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-label="切换侧边栏"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenVersionModal}
|
||||
className={[
|
||||
'inline-flex rounded-full border px-3 py-1.5 transition',
|
||||
versionButtonClassName,
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="sm:hidden">版本</span>
|
||||
<span className="hidden sm:inline">{versionLabel}</span>
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsUserMenuOpen((value) => !value)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent-soft)] text-xs font-semibold">
|
||||
{(user?.display_name || user?.username || 'U')
|
||||
.slice(0, 1)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
{user?.display_name || user?.username || '用户'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isUserMenuOpen ? (
|
||||
<div className="absolute top-[calc(100%+0.5rem)] right-0 w-52 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-2 shadow-[var(--shadow-lg)]">
|
||||
<div className="rounded-xl px-3 py-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{user?.display_name || user?.username || '用户'}
|
||||
</p>
|
||||
{user?.username ? (
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
@{user.username}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogout()}
|
||||
disabled={isLoggingOut}
|
||||
className="flex w-full items-center rounded-xl px-3 py-2 text-left text-sm text-[var(--status-danger-foreground)] transition hover:bg-[var(--status-danger-soft)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isLoggingOut ? '退出中...' : '退出登录'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<VersionUpgradeModal
|
||||
isOpen={isVersionModalOpen}
|
||||
onClose={() => setIsVersionModalOpen(false)}
|
||||
currentVersion={currentVersion}
|
||||
release={releaseWithStream}
|
||||
selectedChannel={selectedReleaseChannel}
|
||||
uploadedBinary={uploadedBinary}
|
||||
isLoading={
|
||||
(selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isLoading && !previewReleaseQuery.data
|
||||
: stableReleaseQuery.isLoading && !stableReleaseQuery.data) &&
|
||||
isRoot
|
||||
}
|
||||
releaseErrorMessage={versionErrorMessage}
|
||||
manualStatusMessage={manualUpgradeStatus ?? undefined}
|
||||
manualErrorMessage={manualUpgradeErrorMessage}
|
||||
canUpgrade={isRoot}
|
||||
isChecking={
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isFetching
|
||||
: stableReleaseQuery.isFetching
|
||||
}
|
||||
isUpgrading={upgradeMutation.isPending}
|
||||
isUploadingBinary={uploadBinaryMutation.isPending}
|
||||
uploadProgress={uploadProgress}
|
||||
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
|
||||
onChannelChange={handleReleaseChannelChange}
|
||||
onCheck={handleCheckRelease}
|
||||
onUpgrade={handleUpgrade}
|
||||
onUploadBinary={handleUploadBinary}
|
||||
onConfirmManualUpgrade={handleConfirmManualUpgrade}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeReleaseWithUpgradeStream(
|
||||
release: LatestReleaseInfo | null | undefined,
|
||||
stream: UpgradeStreamSnapshot | null,
|
||||
) {
|
||||
if (!release || !stream) {
|
||||
return release;
|
||||
}
|
||||
|
||||
return {
|
||||
...release,
|
||||
in_progress: stream.in_progress,
|
||||
upgrade_status: stream.upgrade_status,
|
||||
upgrade_logs: stream.upgrade_logs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium tracking-[0.24em] text-[var(--brand-primary)] uppercase">
|
||||
OpenFlare
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? <div className="flex flex-wrap gap-3">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from 'next/link';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
import {publicEnv} from '@/lib/env/public-env';
|
||||
|
||||
interface PublicShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PublicShell({ children }: PublicShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur">
|
||||
<div className="mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-medium text-[var(--brand-primary)]">
|
||||
OpenFlare
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end">
|
||||
<span className="rounded-full border border-[var(--border-default)] px-3 py-1.5">
|
||||
{publicEnv.appVersion}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">{children}</div>
|
||||
|
||||
<div className="mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
关于
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
logout as logoutRequest,
|
||||
getCurrentUser,
|
||||
} from '@/features/auth/api/auth';
|
||||
import { clearStoredOpenFlareToken } from '@/lib/api/auth-token';
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
refreshUser: () => Promise<AuthUser | null>;
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUserState] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
const nextUser = await getCurrentUser();
|
||||
setUserState(nextUser);
|
||||
return nextUser;
|
||||
} catch {
|
||||
clearStoredOpenFlareToken();
|
||||
setUserState(null);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUser();
|
||||
}, [refreshUser]);
|
||||
|
||||
const setUser = useCallback((nextUser: AuthUser | null) => {
|
||||
setUserState(nextUser);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutRequest();
|
||||
} finally {
|
||||
setUserState(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: user !== null,
|
||||
refreshUser,
|
||||
setUser,
|
||||
logout,
|
||||
}),
|
||||
[isLoading, logout, refreshUser, setUser, user],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
isThemeMode,
|
||||
resolveTheme,
|
||||
THEME_MEDIA_QUERY,
|
||||
THEME_STORAGE_KEY,
|
||||
type ResolvedTheme,
|
||||
type ThemeMode,
|
||||
} from '@/lib/theme/theme';
|
||||
|
||||
interface ThemeContextValue {
|
||||
themeMode: ThemeMode;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function getSystemPreference() {
|
||||
return window.matchMedia(THEME_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
function applyTheme(mode: ThemeMode) {
|
||||
const resolvedTheme = resolveTheme(mode, getSystemPreference());
|
||||
const root = document.documentElement;
|
||||
|
||||
root.dataset.themeMode = mode;
|
||||
root.dataset.theme = resolvedTheme;
|
||||
root.style.colorScheme = resolvedTheme;
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
|
||||
return resolvedTheme;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const domThemeMode = root.dataset.themeMode;
|
||||
const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system';
|
||||
const nextResolvedTheme = applyTheme(nextThemeMode);
|
||||
|
||||
setThemeModeState(nextThemeMode);
|
||||
setResolvedTheme(nextResolvedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY);
|
||||
|
||||
const handleChange = () => {
|
||||
if (themeMode !== 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedTheme(applyTheme('system'));
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [themeMode]);
|
||||
|
||||
const setThemeMode = useCallback((mode: ThemeMode) => {
|
||||
setThemeModeState(mode);
|
||||
setResolvedTheme(applyTheme(mode));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
themeMode,
|
||||
resolvedTheme,
|
||||
setThemeMode,
|
||||
}),
|
||||
[resolvedTheme, setThemeMode, themeMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface AppCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AppCardProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-card)]/90 shadow-[var(--shadow-soft)] backdrop-blur',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{(title || description || action) && (
|
||||
<header className="flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
{title ? (
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
) : null}
|
||||
{description ? (
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</header>
|
||||
)}
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface AppModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
onClose: () => void;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const sizeClassNameMap = {
|
||||
md: 'max-w-2xl',
|
||||
lg: 'max-w-4xl',
|
||||
xl: 'max-w-5xl',
|
||||
} satisfies Record<NonNullable<AppModalProps['size']>, string>;
|
||||
|
||||
export function AppModal({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
size = 'lg',
|
||||
}: AppModalProps) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹窗"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex max-h-[calc(100vh-3rem)] w-full flex-col overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||
sizeClassNameMap[size],
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--border-default)] px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="max-w-2xl text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-label="关闭弹窗"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="border-t border-[var(--border-default)] px-6 py-5">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
type DrawerDirection = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
type DrawerContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
direction: DrawerDirection;
|
||||
};
|
||||
|
||||
const DrawerContext = createContext<DrawerContextValue | null>(null);
|
||||
|
||||
function useDrawerContext() {
|
||||
const context = useContext(DrawerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Drawer components must be used within Drawer.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function renderWithOptionalChild(
|
||||
child: ReactNode,
|
||||
props: Record<string, unknown>,
|
||||
fallback: ReactNode,
|
||||
) {
|
||||
if (isValidElement(child)) {
|
||||
return cloneElement(child as ReactElement, props);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
children,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
direction = 'bottom',
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
direction?: DrawerDirection;
|
||||
title?: string;
|
||||
description?: string;
|
||||
footer?: ReactNode;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const resolvedOpen = isControlled ? open : internalOpen;
|
||||
|
||||
const value = useMemo<DrawerContextValue>(
|
||||
() => ({
|
||||
open: resolvedOpen,
|
||||
direction,
|
||||
setOpen: (nextOpen) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
}),
|
||||
[direction, isControlled, onOpenChange, resolvedOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={value}>
|
||||
{title || description || footer ? (
|
||||
<DrawerContent
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'w-full md:w-[50vw] md:max-w-none',
|
||||
direction === 'right' || direction === 'left' ? '' : 'max-h-[85vh]',
|
||||
)}
|
||||
>
|
||||
<DrawerHeader className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
{title ? <DrawerTitle>{title}</DrawerTitle> : null}
|
||||
{description ? (
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => value.setOpen(false)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-label="关闭抽屉"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <DrawerFooter>{footer}</DrawerFooter> : null}
|
||||
</DrawerContent>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</DrawerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerTrigger({
|
||||
children,
|
||||
asChild = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { setOpen } = useDrawerContext();
|
||||
|
||||
if (asChild) {
|
||||
return renderWithOptionalChild(
|
||||
children,
|
||||
{
|
||||
onClick: () => setOpen(true),
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerClose({
|
||||
children,
|
||||
asChild = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { setOpen } = useDrawerContext();
|
||||
|
||||
if (asChild) {
|
||||
return renderWithOptionalChild(
|
||||
children,
|
||||
{
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerContent({
|
||||
children,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const { open, setOpen, direction } = useDrawerContext();
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionClassName =
|
||||
direction === 'right'
|
||||
? 'inset-y-0 right-0 h-full border-l'
|
||||
: direction === 'left'
|
||||
? 'inset-y-0 left-0 h-full border-r'
|
||||
: direction === 'top'
|
||||
? 'inset-x-0 top-0 border-b'
|
||||
: 'inset-x-0 bottom-0 border-t';
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="关闭抽屉"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : titleId}
|
||||
aria-describedby={descriptionId}
|
||||
className={cn(
|
||||
'absolute flex w-full flex-col overflow-hidden border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||
positionClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DrawerMetaContext.Provider value={{ titleId, descriptionId }}>
|
||||
{children}
|
||||
</DrawerMetaContext.Provider>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type DrawerMetaContextValue = {
|
||||
titleId: string;
|
||||
descriptionId: string;
|
||||
};
|
||||
|
||||
const DrawerMetaContext = createContext<DrawerMetaContextValue | null>(null);
|
||||
|
||||
function useDrawerMetaContext() {
|
||||
const context = useContext(DrawerMetaContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Drawer title and description must be used within DrawerContent.',
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function DrawerHeader({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b border-[var(--border-default)] px-6 py-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerFooter({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-[var(--border-default)] px-6 py-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const { titleId } = useDrawerMetaContext();
|
||||
|
||||
return (
|
||||
<h2
|
||||
id={titleId}
|
||||
className={cn(
|
||||
'text-xl font-semibold text-[var(--foreground-primary)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement>) {
|
||||
const { descriptionId } = useDrawerMetaContext();
|
||||
|
||||
return (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={cn(
|
||||
'mt-2 text-sm leading-6 text-[var(--foreground-secondary)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none transition outline-none placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, React.ComponentProps<'label'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm leading-none font-medium text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {Separator as SeparatorPrimitive} from 'radix-ui';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
export function Toaster({ ...props }: ToasterProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
position="bottom-right"
|
||||
theme={resolvedTheme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-[var(--surface-elevated)] group-[.toaster]:text-[var(--foreground-primary)] group-[.toaster]:border-[var(--border-default)] group-[.toaster]:shadow-lg group-[.toaster]:rounded-2xl group-[.toaster]:font-sans',
|
||||
description: 'group-[.toast]:text-[var(--foreground-secondary)]',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-[var(--primary)] group-[.toast]:text-[var(--primary-foreground)]',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-[var(--muted)] group-[.toast]:text-[var(--muted-foreground)]',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const variantClasses = {
|
||||
success:
|
||||
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
||||
warning:
|
||||
'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]',
|
||||
danger:
|
||||
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
|
||||
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
|
||||
} as const;
|
||||
|
||||
interface StatusBadgeProps {
|
||||
label: string;
|
||||
variant?: keyof typeof variantClasses;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
label,
|
||||
variant = 'info',
|
||||
className,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: StatusBadgeProps) {
|
||||
const badgeClassName = cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium tracking-wide',
|
||||
variantClasses[variant],
|
||||
onClick
|
||||
? 'cursor-pointer transition hover:opacity-85 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
: undefined,
|
||||
className,
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={badgeClassName}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className={badgeClassName}>{label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type SwitchProps = Omit<React.ComponentProps<'button'>, 'onChange'> & {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [internalChecked, setInternalChecked] =
|
||||
React.useState(defaultChecked);
|
||||
const isControlled = checked !== undefined;
|
||||
const isChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const updateChecked = React.useCallback(
|
||||
(nextChecked: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
},
|
||||
[isControlled, onCheckedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isChecked}
|
||||
data-state={isChecked ? 'checked' : 'unchecked'}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-transparent bg-[var(--control-background-hover)] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--brand-primary)]',
|
||||
className,
|
||||
)}
|
||||
onClick={() => updateChecked(!isChecked)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
updateChecked(!isChecked);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={isChecked ? 'checked' : 'unchecked'}
|
||||
className="pointer-events-none block h-5 w-5 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-5"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<'textarea'>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none transition outline-none placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { themeModes, type ThemeMode } from '@/lib/theme/theme';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||
const { themeMode, resolvedTheme, setThemeMode } = useTheme();
|
||||
const currentIndex = themeModes.indexOf(themeMode);
|
||||
const nextTheme = themeModes[
|
||||
(currentIndex + 1) % themeModes.length
|
||||
] as ThemeMode;
|
||||
|
||||
const icon =
|
||||
themeMode === 'light' ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6M18.7 18.7l-1.6-1.6M6.9 6.9 5.3 5.3" />
|
||||
</svg>
|
||||
) : themeMode === 'dark' ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 15.2A7.8 7.8 0 1 1 8.8 4 6.4 6.4 0 0 0 20 15.2Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3.5" y="4" width="17" height="12" rx="2.5" />
|
||||
<path d="M8 20h8M12 16v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const label =
|
||||
themeMode === 'light'
|
||||
? '浅色模式'
|
||||
: themeMode === 'dark'
|
||||
? '深色模式'
|
||||
: '跟随系统';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThemeMode(nextTheme)}
|
||||
className={cn(
|
||||
'inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]',
|
||||
className,
|
||||
)}
|
||||
aria-label={`当前${label},点击切换到${nextTheme === 'light' ? '浅色模式' : nextTheme === 'dark' ? '深色模式' : '跟随系统'}`}
|
||||
title={`${label}(当前生效:${resolvedTheme === 'dark' ? '深色' : '浅色'}),点击切换到${nextTheme === 'light' ? '浅色模式' : nextTheme === 'dark' ? '深色模式' : '跟随系统'}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user