feat: 添加 dashboard 页面 (#377)
This commit is contained in:
@@ -28,8 +28,8 @@ html {
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-1: oklch(0.37 0.04 257);
|
||||
--chart-2: oklch(0.13 0.04 265);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
|
||||
@@ -17,7 +17,9 @@ import type {
|
||||
InsertSubmissionRequest,
|
||||
VideoSourcesDetailsResponse,
|
||||
UpdateVideoSourceRequest,
|
||||
Config
|
||||
Config,
|
||||
DashBoardResponse,
|
||||
SysInfoResponse
|
||||
} from './types';
|
||||
|
||||
// API 基础配置
|
||||
@@ -216,6 +218,42 @@ class ApiClient {
|
||||
async updateConfig(config: Config): Promise<ApiResponse<Config>> {
|
||||
return this.put<Config>('/config', config);
|
||||
}
|
||||
|
||||
async getDashboard(): Promise<ApiResponse<DashBoardResponse>> {
|
||||
return this.get<DashBoardResponse>('/dashboard');
|
||||
}
|
||||
|
||||
// 获取系统信息流(SSE)
|
||||
async getSysInfoStream(): Promise<EventSource> {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
return new EventSource(url);
|
||||
}
|
||||
|
||||
// 创建系统信息流的便捷方法
|
||||
createSysInfoStream(
|
||||
onMessage: (data: SysInfoResponse) => void,
|
||||
onError?: (error: Event) => void
|
||||
): EventSource {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SysInfoResponse;
|
||||
onMessage(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (onError) {
|
||||
eventSource.onerror = onError;
|
||||
}
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认的 API 客户端实例
|
||||
@@ -243,6 +281,12 @@ const api = {
|
||||
apiClient.updateVideoSource(type, id, request),
|
||||
getConfig: () => apiClient.getConfig(),
|
||||
updateConfig: (config: Config) => apiClient.updateConfig(config),
|
||||
getDashboard: () => apiClient.getDashboard(),
|
||||
getSysInfoStream: () => apiClient.getSysInfoStream(),
|
||||
createSysInfoStream: (
|
||||
onMessage: (data: SysInfoResponse) => void,
|
||||
onError?: (error: Event) => void
|
||||
) => apiClient.createSysInfoStream(onMessage, onError),
|
||||
setAuthToken: (token: string) => apiClient.setAuthToken(token),
|
||||
clearAuthToken: () => apiClient.clearAuthToken()
|
||||
};
|
||||
|
||||
171
web/src/lib/components/custom/my-chart-tooltip.svelte
Normal file
171
web/src/lib/components/custom/my-chart-tooltip.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import {
|
||||
getPayloadConfigFromPayload,
|
||||
useChart,
|
||||
type TooltipPayload
|
||||
} from '../ui/chart/chart-utils.js';
|
||||
import { getTooltipContext, Tooltip as TooltipPrimitive } from 'layerchart';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function defaultFormatter(value: any, _payload: TooltipPayload[]) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function defaultValueFormatter(value: any) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
hideLabel = false,
|
||||
indicator = 'dot',
|
||||
hideIndicator = false,
|
||||
labelKey,
|
||||
label,
|
||||
labelFormatter = defaultFormatter,
|
||||
valueFormatter = defaultValueFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
nameKey,
|
||||
color,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
|
||||
hideLabel?: boolean;
|
||||
label?: string;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
hideIndicator?: boolean;
|
||||
labelClassName?: string;
|
||||
labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
valueFormatter?: ((value: any) => string | number | Snippet) | null;
|
||||
formatter?: Snippet<
|
||||
[
|
||||
{
|
||||
value: unknown;
|
||||
name: string;
|
||||
item: TooltipPayload;
|
||||
index: number;
|
||||
payload: TooltipPayload[];
|
||||
}
|
||||
]
|
||||
>;
|
||||
} = $props();
|
||||
|
||||
const chart = useChart();
|
||||
const tooltipCtx = getTooltipContext();
|
||||
|
||||
const formattedLabel = $derived.by(() => {
|
||||
if (hideLabel || !tooltipCtx.payload?.length) return null;
|
||||
|
||||
const [item] = tooltipCtx.payload;
|
||||
const key = labelKey ?? item?.label ?? item?.name ?? 'value';
|
||||
|
||||
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
|
||||
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? (chart.config[label as keyof typeof chart.config]?.label ?? label)
|
||||
: (itemConfig?.label ?? item.label);
|
||||
|
||||
if (value === undefined) return null;
|
||||
if (!labelFormatter) return value;
|
||||
return labelFormatter(value, tooltipCtx.payload);
|
||||
});
|
||||
|
||||
const nestLabel = $derived(tooltipCtx.payload.length === 1 && indicator !== 'dot');
|
||||
</script>
|
||||
|
||||
{#snippet TooltipLabel()}
|
||||
{#if formattedLabel}
|
||||
<div class={cn('font-medium', labelClassName)}>
|
||||
{#if typeof formattedLabel === 'function'}
|
||||
{@render formattedLabel()}
|
||||
{:else}
|
||||
{formattedLabel}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<TooltipPrimitive.Root variant="none">
|
||||
<div
|
||||
class={cn(
|
||||
'border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if !nestLabel}
|
||||
{@render TooltipLabel()}
|
||||
{/if}
|
||||
<div class="grid gap-1.5">
|
||||
{#each tooltipCtx.payload as item, i (item.key + i)}
|
||||
{@const key = `${nameKey || item.key || item.name || 'value'}`}
|
||||
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
|
||||
{@const indicatorColor = color || item.payload?.color || item.color}
|
||||
<div
|
||||
class={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5',
|
||||
indicator === 'dot' && 'items-center'
|
||||
)}
|
||||
>
|
||||
{#if formatter && item.value !== undefined && item.name}
|
||||
{@render formatter({
|
||||
value: item.value,
|
||||
name: item.name,
|
||||
item,
|
||||
index: i,
|
||||
payload: tooltipCtx.payload
|
||||
})}
|
||||
{:else}
|
||||
{#if itemConfig?.icon}
|
||||
<itemConfig.icon />
|
||||
{:else if !hideIndicator}
|
||||
<div
|
||||
style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
|
||||
class={cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
|
||||
'size-2.5': indicator === 'dot',
|
||||
'h-full w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed'
|
||||
})}
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-1 shrink-0 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<div class="grid gap-1.5">
|
||||
{#if nestLabel}
|
||||
{@render TooltipLabel()}
|
||||
{/if}
|
||||
<span class="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.value !== undefined}
|
||||
<span class="text-foreground font-mono font-medium tabular-nums">
|
||||
{#if valueFormatter}
|
||||
{valueFormatter(item.value)}
|
||||
{:else}
|
||||
{item.value.toLocaleString()}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipPrimitive.Root>
|
||||
80
web/src/lib/components/ui/chart/chart-container.svelte
Normal file
80
web/src/lib/components/ui/chart/chart-container.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import ChartStyle from './chart-style.svelte';
|
||||
import { setChartContext, type ChartConfig } from './chart-utils.js';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = uid,
|
||||
class: className,
|
||||
children,
|
||||
config,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
||||
config: ChartConfig;
|
||||
} = $props();
|
||||
|
||||
const chartId = `chart-${id || uid.replace(/:/g, '')}`;
|
||||
|
||||
setChartContext({
|
||||
get config() {
|
||||
return config;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-chart={chartId}
|
||||
data-slot="chart"
|
||||
class={cn(
|
||||
'flex aspect-video justify-center overflow-visible text-xs',
|
||||
// Overrides
|
||||
//
|
||||
// Stroke around dots/marks when hovering
|
||||
'[&_.stroke-white]:stroke-transparent',
|
||||
// override the default stroke color of lines
|
||||
'[&_.lc-line]:stroke-border/50',
|
||||
|
||||
// by default, layerchart shows a line intersecting the point when hovering, this hides that
|
||||
'[&_.lc-highlight-line]:stroke-0',
|
||||
|
||||
// by default, when you hover a point on a stacked series chart, it will drop the opacity
|
||||
// of the other series, this overrides that
|
||||
'[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible',
|
||||
|
||||
// We don't want the little tick lines between the axis labels and the chart, so we remove
|
||||
// the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
|
||||
// chart.
|
||||
'[&_.lc-axis-tick]:stroke-0',
|
||||
|
||||
// We don't want to display the rule on the x/y axis, as there is already going to be
|
||||
// a grid line there and rule ends up overlapping the marks because it is rendered after
|
||||
// the marks
|
||||
'[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0',
|
||||
'[&_.lc-grid-x-radial-line]:stroke-border [&_.lc-grid-x-radial-circle]:stroke-border',
|
||||
'[&_.lc-grid-y-radial-line]:stroke-border [&_.lc-grid-y-radial-circle]:stroke-border',
|
||||
|
||||
// Legend adjustments
|
||||
'[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5',
|
||||
'[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4',
|
||||
'[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]',
|
||||
|
||||
// Labels
|
||||
'[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent',
|
||||
|
||||
// Tick labels on th x/y axes
|
||||
'[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal',
|
||||
'[&_.lc-tooltip-rects-g]:fill-transparent',
|
||||
'[&_.lc-layout-svg-g]:fill-transparent',
|
||||
'[&_.lc-root-container]:w-full',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChartStyle id={chartId} {config} />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
36
web/src/lib/components/ui/chart/chart-style.svelte
Normal file
36
web/src/lib/components/ui/chart/chart-style.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { THEMES, type ChartConfig } from './chart-utils.js';
|
||||
|
||||
let { id, config }: { id: string; config: ChartConfig } = $props();
|
||||
|
||||
const colorConfig = $derived(
|
||||
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
|
||||
);
|
||||
|
||||
const themeContents = $derived.by(() => {
|
||||
if (!colorConfig || !colorConfig.length) return;
|
||||
|
||||
const themeContents = [];
|
||||
for (let [_theme, prefix] of Object.entries(THEMES)) {
|
||||
let content = `${prefix} [data-chart=${id}] {\n`;
|
||||
const color = colorConfig.map(([key, itemConfig]) => {
|
||||
const theme = _theme as keyof typeof itemConfig.theme;
|
||||
const color = itemConfig.theme?.[theme] || itemConfig.color;
|
||||
return color ? `\t--color-${key}: ${color};` : null;
|
||||
});
|
||||
|
||||
content += color.join('\n') + '\n}';
|
||||
|
||||
themeContents.push(content);
|
||||
}
|
||||
|
||||
return themeContents.join('\n');
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if themeContents}
|
||||
{#key id}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html `<style>${themeContents}</style>`}
|
||||
{/key}
|
||||
{/if}
|
||||
155
web/src/lib/components/ui/chart/chart-tooltip.svelte
Normal file
155
web/src/lib/components/ui/chart/chart-tooltip.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from './chart-utils.js';
|
||||
import { getTooltipContext, Tooltip as TooltipPrimitive } from 'layerchart';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function defaultFormatter(value: any, _payload: TooltipPayload[]) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
hideLabel = false,
|
||||
indicator = 'dot',
|
||||
hideIndicator = false,
|
||||
labelKey,
|
||||
label,
|
||||
labelFormatter = defaultFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
nameKey,
|
||||
color,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
|
||||
hideLabel?: boolean;
|
||||
label?: string;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
hideIndicator?: boolean;
|
||||
labelClassName?: string;
|
||||
labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
|
||||
formatter?: Snippet<
|
||||
[
|
||||
{
|
||||
value: unknown;
|
||||
name: string;
|
||||
item: TooltipPayload;
|
||||
index: number;
|
||||
payload: TooltipPayload[];
|
||||
}
|
||||
]
|
||||
>;
|
||||
} = $props();
|
||||
|
||||
const chart = useChart();
|
||||
const tooltipCtx = getTooltipContext();
|
||||
|
||||
const formattedLabel = $derived.by(() => {
|
||||
if (hideLabel || !tooltipCtx.payload?.length) return null;
|
||||
|
||||
const [item] = tooltipCtx.payload;
|
||||
const key = labelKey ?? item?.label ?? item?.name ?? 'value';
|
||||
|
||||
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
|
||||
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? (chart.config[label as keyof typeof chart.config]?.label ?? label)
|
||||
: (itemConfig?.label ?? item.label);
|
||||
|
||||
if (value === undefined) return null;
|
||||
if (!labelFormatter) return value;
|
||||
return labelFormatter(value, tooltipCtx.payload);
|
||||
});
|
||||
|
||||
const nestLabel = $derived(tooltipCtx.payload.length === 1 && indicator !== 'dot');
|
||||
</script>
|
||||
|
||||
{#snippet TooltipLabel()}
|
||||
{#if formattedLabel}
|
||||
<div class={cn('font-medium', labelClassName)}>
|
||||
{#if typeof formattedLabel === 'function'}
|
||||
{@render formattedLabel()}
|
||||
{:else}
|
||||
{formattedLabel}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<TooltipPrimitive.Root variant="none">
|
||||
<div
|
||||
class={cn(
|
||||
'border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if !nestLabel}
|
||||
{@render TooltipLabel()}
|
||||
{/if}
|
||||
<div class="grid gap-1.5">
|
||||
{#each tooltipCtx.payload as item, i (item.key + i)}
|
||||
{@const key = `${nameKey || item.key || item.name || 'value'}`}
|
||||
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
|
||||
{@const indicatorColor = color || item.payload?.color || item.color}
|
||||
<div
|
||||
class={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5',
|
||||
indicator === 'dot' && 'items-center'
|
||||
)}
|
||||
>
|
||||
{#if formatter && item.value !== undefined && item.name}
|
||||
{@render formatter({
|
||||
value: item.value,
|
||||
name: item.name,
|
||||
item,
|
||||
index: i,
|
||||
payload: tooltipCtx.payload
|
||||
})}
|
||||
{:else}
|
||||
{#if itemConfig?.icon}
|
||||
<itemConfig.icon />
|
||||
{:else if !hideIndicator}
|
||||
<div
|
||||
style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
|
||||
class={cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
|
||||
'size-2.5': indicator === 'dot',
|
||||
'h-full w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed'
|
||||
})}
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-1 shrink-0 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<div class="grid gap-1.5">
|
||||
{#if nestLabel}
|
||||
{@render TooltipLabel()}
|
||||
{/if}
|
||||
<span class="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.value !== undefined}
|
||||
<span class="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipPrimitive.Root>
|
||||
66
web/src/lib/components/ui/chart/chart-utils.ts
Normal file
66
web/src/lib/components/ui/chart/chart-utils.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Tooltip } from 'layerchart';
|
||||
import { getContext, setContext, type Component, type ComponentProps, type Snippet } from 'svelte';
|
||||
|
||||
export const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: string;
|
||||
icon?: Component;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
|
||||
|
||||
export type TooltipPayload = ExtractSnippetParams<
|
||||
ComponentProps<typeof Tooltip.Root>['children']
|
||||
>['payload'][number];
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
export function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: TooltipPayload,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) return undefined;
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (payload.key === key) {
|
||||
configLabelKey = payload.key;
|
||||
} else if (payload.name === key) {
|
||||
configLabelKey = payload.name;
|
||||
} else if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload !== undefined &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
type ChartContextValue = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const chartContextKey = Symbol('chart-context');
|
||||
|
||||
export function setChartContext(value: ChartContextValue) {
|
||||
return setContext(chartContextKey, value);
|
||||
}
|
||||
|
||||
export function useChart() {
|
||||
return getContext<ChartContextValue>(chartContextKey);
|
||||
}
|
||||
6
web/src/lib/components/ui/chart/index.ts
Normal file
6
web/src/lib/components/ui/chart/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import ChartContainer from './chart-container.svelte';
|
||||
import ChartTooltip from './chart-tooltip.svelte';
|
||||
|
||||
export { getPayloadConfigFromPayload, type ChartConfig } from './chart-utils.js';
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip };
|
||||
7
web/src/lib/components/ui/progress/index.ts
Normal file
7
web/src/lib/components/ui/progress/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from './progress.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress
|
||||
};
|
||||
27
web/src/lib/components/ui/progress/progress.svelte
Normal file
27
web/src/lib/components/ui/progress/progress.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Progress as ProgressPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
max = 100,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<ProgressPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="progress"
|
||||
class={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
|
||||
{value}
|
||||
{max}
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
data-slot="progress-indicator"
|
||||
class="bg-primary h-full w-full flex-1 transition-all"
|
||||
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
|
||||
></div>
|
||||
</ProgressPrimitive.Root>
|
||||
@@ -241,3 +241,31 @@ export interface Config {
|
||||
cdn_sorting: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// 日期计数对类型
|
||||
export interface DayCountPair {
|
||||
day: string;
|
||||
cnt: number;
|
||||
}
|
||||
|
||||
// 仪表盘响应类型
|
||||
export interface DashBoardResponse {
|
||||
enabled_favorites: number;
|
||||
enabled_collections: number;
|
||||
enabled_submissions: number;
|
||||
enable_watch_later: boolean;
|
||||
videos_by_day: DayCountPair[];
|
||||
}
|
||||
|
||||
// 系统信息响应类型
|
||||
export interface SysInfoResponse {
|
||||
total_memory: number;
|
||||
used_memory: number;
|
||||
process_memory: number;
|
||||
used_cpu: number;
|
||||
process_cpu: number;
|
||||
total_disk: number;
|
||||
used_disk: number;
|
||||
available_disk: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
425
web/src/routes/dashboard/+page.svelte
Normal file
425
web/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,425 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
||||
import { Progress } from '$lib/components/ui/progress/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Chart from '$lib/components/ui/chart/index.js';
|
||||
import MyChartTooltip from '$lib/components/custom/my-chart-tooltip.svelte';
|
||||
import { curveNatural } from 'd3-shape';
|
||||
import { BarChart, AreaChart } from 'layerchart';
|
||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import api from '$lib/api';
|
||||
import type { DashBoardResponse, SysInfoResponse, ApiError } from '$lib/types';
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database';
|
||||
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
|
||||
import CpuIcon from '@lucide/svelte/icons/cpu';
|
||||
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
|
||||
|
||||
let dashboardData: DashBoardResponse | null = null;
|
||||
let sysInfo: SysInfoResponse | null = null;
|
||||
let loading = false;
|
||||
let sysInfoEventSource: EventSource | null = null;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatCpu(cpu: number): string {
|
||||
return `${cpu.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await api.getDashboard();
|
||||
dashboardData = response.data;
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error);
|
||||
toast.error('加载仪表盘数据失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 启动系统信息流
|
||||
function startSysInfoStream() {
|
||||
try {
|
||||
sysInfoEventSource = api.createSysInfoStream(
|
||||
(data) => {
|
||||
sysInfo = data;
|
||||
},
|
||||
(_error) => {
|
||||
toast.error('系统信息流异常中断');
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('启动系统信息流失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 停止系统信息流
|
||||
function stopSysInfoStream() {
|
||||
if (sysInfoEventSource) {
|
||||
sysInfoEventSource.close();
|
||||
sysInfoEventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setBreadcrumb([{ label: '仪表盘', isActive: true }]);
|
||||
loadDashboard();
|
||||
startSysInfoStream();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopSysInfoStream();
|
||||
});
|
||||
|
||||
// 图表配置
|
||||
const videoChartConfig = {
|
||||
videos: {
|
||||
label: '视频数量',
|
||||
color: 'var(--chart-1)'
|
||||
}
|
||||
} satisfies Chart.ChartConfig;
|
||||
|
||||
const memoryChartConfig = {
|
||||
used: {
|
||||
label: '整体占用',
|
||||
color: 'var(--chart-1)'
|
||||
},
|
||||
process: {
|
||||
label: '程序占用',
|
||||
color: 'var(--chart-2)'
|
||||
}
|
||||
} satisfies Chart.ChartConfig;
|
||||
|
||||
const cpuChartConfig = {
|
||||
used: {
|
||||
label: '整体占用',
|
||||
color: 'var(--chart-1)'
|
||||
},
|
||||
process: {
|
||||
label: '程序占用',
|
||||
color: 'var(--chart-2)'
|
||||
}
|
||||
} satisfies Chart.ChartConfig;
|
||||
|
||||
// 内存和 CPU 数据历史记录
|
||||
let memoryHistory: Array<{ time: Date; used: number; process: number }> = [];
|
||||
let cpuHistory: Array<{ time: Date; used: number; process: number }> = [];
|
||||
|
||||
// 更新历史数据
|
||||
$: if (sysInfo) {
|
||||
memoryHistory = [
|
||||
...memoryHistory.slice(-19),
|
||||
{
|
||||
time: new Date(),
|
||||
used: sysInfo.used_memory,
|
||||
process: sysInfo.process_memory
|
||||
}
|
||||
];
|
||||
cpuHistory = [
|
||||
...cpuHistory.slice(-19),
|
||||
{
|
||||
time: new Date(),
|
||||
used: sysInfo.used_cpu,
|
||||
process: sysInfo.process_cpu
|
||||
}
|
||||
];
|
||||
}
|
||||
// 计算磁盘使用率
|
||||
$: diskUsagePercent = sysInfo
|
||||
? ((sysInfo.total_disk - sysInfo.available_disk) / sysInfo.total_disk) * 100
|
||||
: 0;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>仪表盘 - Bili Sync</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<!-- 存储空间卡片 -->
|
||||
<Card class="md:col-span-1">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">存储空间</CardTitle>
|
||||
<HardDriveIcon class="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if sysInfo}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-2xl font-bold">{formatBytes(sysInfo.available_disk)} 可用</div>
|
||||
<div class="text-muted-foreground text-sm">
|
||||
共 {formatBytes(sysInfo.total_disk)}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={diskUsagePercent} class="h-2" />
|
||||
<div class="text-muted-foreground text-xs">
|
||||
已使用 {diskUsagePercent.toFixed(1)}% 的存储空间
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">加载中...</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card class="md:col-span-2">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">当前监听</CardTitle>
|
||||
<DatabaseIcon class="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if dashboardData}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<HeartIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">收藏夹</span>
|
||||
</div>
|
||||
<Badge variant="outline">{dashboardData.enabled_favorites}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">合集</span>
|
||||
</div>
|
||||
<Badge variant="outline">{dashboardData.enabled_collections}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">投稿</span>
|
||||
</div>
|
||||
<Badge variant="outline">{dashboardData.enabled_submissions}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<ClockIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">稍后再看</span>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{dashboardData.enable_watch_later ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">加载中...</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">最近入库</CardTitle>
|
||||
<VideoIcon class="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if dashboardData && dashboardData.videos_by_day.length > 0}
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>近七日共新增视频</span>
|
||||
<span class="font-medium"
|
||||
>{dashboardData.videos_by_day.reduce((sum, v) => sum + v.cnt, 0)} 个</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<Chart.Container config={videoChartConfig} class="h-[200px] w-full">
|
||||
<BarChart
|
||||
data={dashboardData.videos_by_day}
|
||||
x="day"
|
||||
axis="x"
|
||||
series={[
|
||||
{
|
||||
key: 'cnt',
|
||||
label: '新增视频',
|
||||
color: videoChartConfig.videos.color
|
||||
}
|
||||
]}
|
||||
props={{
|
||||
bars: {
|
||||
stroke: 'none',
|
||||
rounded: 'all',
|
||||
radius: 8,
|
||||
initialHeight: 0
|
||||
},
|
||||
highlight: { area: { fill: 'none' } },
|
||||
xAxis: { format: () => '' }
|
||||
}}
|
||||
>
|
||||
{#snippet tooltip()}
|
||||
<MyChartTooltip indicator="line" />
|
||||
{/snippet}
|
||||
</BarChart>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<div class="text-muted-foreground flex h-[300px] items-center justify-center text-sm">
|
||||
暂无视频统计数据
|
||||
</div>
|
||||
{/if}</CardContent
|
||||
>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:系统监控 -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- 内存使用情况 -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">内存使用情况</CardTitle>
|
||||
<MemoryStickIcon class="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if sysInfo}
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>当前内存使用</span>
|
||||
<span class="font-medium"
|
||||
>{formatBytes(sysInfo.used_memory)} / {formatBytes(sysInfo.total_memory)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if memoryHistory.length > 0}
|
||||
<Chart.Container config={memoryChartConfig} class="h-[150px] w-full">
|
||||
<AreaChart
|
||||
data={memoryHistory}
|
||||
x="time"
|
||||
axis="x"
|
||||
series={[
|
||||
{
|
||||
key: 'used',
|
||||
label: memoryChartConfig.used.label,
|
||||
color: memoryChartConfig.used.color
|
||||
},
|
||||
{
|
||||
key: 'process',
|
||||
label: memoryChartConfig.process.label,
|
||||
color: memoryChartConfig.process.color
|
||||
}
|
||||
]}
|
||||
props={{
|
||||
area: {
|
||||
curve: curveNatural,
|
||||
line: { class: 'stroke-1' },
|
||||
'fill-opacity': 0.4
|
||||
},
|
||||
xAxis: {
|
||||
format: () => ''
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet tooltip()}
|
||||
<MyChartTooltip
|
||||
labelFormatter={(v: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
}).format(v);
|
||||
}}
|
||||
valueFormatter={(v: number) => formatBytes(v)}
|
||||
indicator="line"
|
||||
/>
|
||||
{/snippet}
|
||||
</AreaChart>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<div class="text-muted-foreground flex h-[200px] items-center justify-center text-sm">
|
||||
等待数据...
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- CPU 使用情况 -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">CPU 使用情况</CardTitle>
|
||||
<CpuIcon class="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if sysInfo}
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>当前 CPU 使用率</span>
|
||||
<span class="font-medium">{formatCpu(sysInfo.used_cpu)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if cpuHistory.length > 0}
|
||||
<Chart.Container config={cpuChartConfig} class="h-[150px] w-full">
|
||||
<AreaChart
|
||||
data={cpuHistory}
|
||||
x="time"
|
||||
axis="x"
|
||||
series={[
|
||||
{
|
||||
key: 'used',
|
||||
label: cpuChartConfig.used.label,
|
||||
color: cpuChartConfig.used.color
|
||||
},
|
||||
{
|
||||
key: 'process',
|
||||
label: cpuChartConfig.process.label,
|
||||
color: cpuChartConfig.process.color
|
||||
}
|
||||
]}
|
||||
props={{
|
||||
area: {
|
||||
curve: curveNatural,
|
||||
line: { class: 'stroke-1' },
|
||||
'fill-opacity': 0.4
|
||||
},
|
||||
xAxis: {
|
||||
format: () => ''
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet tooltip()}
|
||||
<MyChartTooltip
|
||||
labelFormatter={(v: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
}).format(v);
|
||||
}}
|
||||
valueFormatter={(v: number) => formatCpu(v)}
|
||||
indicator="line"
|
||||
/>
|
||||
{/snippet}
|
||||
</AreaChart>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<div class="text-muted-foreground flex h-[150px] items-center justify-center text-sm">
|
||||
等待数据...
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user