refactor(web): 前端目录重构 — consoles/pages → layouts/features + shared

- consoles/admin/ → layouts/admin-layout/
- consoles/workbench/ → layouts/workbench-layout/ + features/chat/
- pages/ → features/ (dashboard, models, projects, not-found)
- components/ → shared/components/
- hooks/ → shared/hooks/
- utils/ → shared/utils/
- 更新所有 import 路径 (src/web/ + tests/web/)
- 更新开发文档 (README.md, frontend.md, architecture.md)
This commit is contained in:
2026-06-02 23:17:28 +08:00
parent 1f05f259d0
commit b1dec691e9
76 changed files with 249 additions and 111 deletions

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router";
export function ConsoleOutlet() {
return <Outlet />;
}

View File

@@ -0,0 +1,75 @@
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { XProvider } from "@ant-design/x";
import zhCN_X from "@ant-design/x/locale/zh_CN";
import { App as AntApp, Layout, Segmented, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import type { ConsoleShellProps } from "./types";
import { APP } from "../../../../shared/app";
import { useMeta } from "../../hooks/use-meta";
import { useSidebarCollapsed } from "../../hooks/use-sidebar-collapsed";
import { useThemePreference } from "../../hooks/use-theme-preference";
import { Sidebar } from "../Sidebar";
import { ConsoleOutlet } from "./ConsoleOutlet";
const { Content, Header, Sider } = Layout;
const THEME_OPTIONS = [
{ label: "系统", value: "system" },
{ label: "明亮", value: "light" },
{ label: "黑暗", value: "dark" },
] as const;
export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) {
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const { collapsed, setCollapsed } = useSidebarCollapsed();
const { data: meta } = useMeta();
const versionDisplay = meta?.version ? `v${meta.version}` : null;
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
return (
<XProvider locale={{ ...zhCN, ...zhCN_X }} theme={{ algorithm: themeAlgorithm }}>
<AntApp>
<Layout className="app-layout">
<Header className="app-header">
<div className="app-header-left">
<span className="app-brand-group">
<span className="app-brand">{APP.title}</span>
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
<span className="app-console-title">{title}</span>
</span>
</div>
<div className="app-header-right">
{headerExtra}
<Segmented
onChange={(value) => setThemePreference(value)}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
value={themePreference}
/>
</div>
</Header>
<Layout>
<Sider
collapsed={collapsed}
collapsedWidth={64}
collapsible
onCollapse={(c) => setCollapsed(c)}
theme="light"
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
width={232}
>
<Sidebar menuItems={menuItems} />
</Sider>
<Layout>
<Content className="app-content">
<ConsoleOutlet />
</Content>
</Layout>
</Layout>
</Layout>
</AntApp>
</XProvider>
);
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import type { MenuItemConfig } from "../../../menu";
export interface ConsoleShellProps {
headerExtra?: ReactNode;
menuItems: readonly MenuItemConfig[];
title: ReactNode;
}

View File

@@ -0,0 +1,44 @@
import type { ErrorInfo, ReactNode } from "react";
import { Button, Result } from "antd";
import { Component } from "react";
import { createConsoleLogger } from "../utils/logger";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
override state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
createConsoleLogger().error("渲染错误", { componentStack: info.componentStack, error });
}
override render() {
if (this.state.hasError) {
return (
<Result
extra={
<Button onClick={() => window.location.reload()} type="primary">
</Button>
}
status="500"
subTitle="页面渲染出现异常,请刷新重试"
title="渲染错误"
/>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,36 @@
import type { MenuProps } from "antd";
import { Menu } from "antd";
import { useLocation, useNavigate } from "react-router";
import type { MenuItemConfig } from "../../../menu";
type MenuItem = Required<MenuProps>["items"][number];
interface SidebarProps {
menuItems: readonly MenuItemConfig[];
}
export function Sidebar({ menuItems }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const currentItem = menuItems.find((item) => item.path === currentPath);
const selectedKeys = currentItem ? [currentItem.value] : [];
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({
icon: item.icon,
key: item.value,
label: item.label,
}));
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
const item = menuItems.find((i) => i.value === key);
if (item) {
void navigate(item.path);
}
};
return <Menu items={antdMenuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
}

View File

@@ -0,0 +1,72 @@
import type {
Conversation,
ConversationListResponse,
ConversationResponse,
MessageListResponse,
UpdateConversationRequest,
} from "../../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const logger = createConsoleLogger();
export async function createConversation(projectId: string, modelId?: string): Promise<Conversation> {
const response = await fetch(`/api/projects/${projectId}/conversations`, {
body: JSON.stringify({ modelId }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
}
export async function deleteConversation(projectId: string, conversationId: string): Promise<void> {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`, { method: "DELETE" });
return handleVoidResponse(response);
}
export async function fetchConversation(projectId: string, conversationId: string): Promise<Conversation> {
try {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`);
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
} catch (err: unknown) {
logger.error("获取会话失败", {
conversationId,
error: err instanceof Error ? err.message : String(err),
projectId,
});
throw err;
}
}
export async function fetchConversations(projectId: string): Promise<ConversationListResponse> {
const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=100`);
return handleResponse(response, (data) => data as ConversationListResponse);
}
export async function fetchMessages(projectId: string, conversationId: string): Promise<MessageListResponse> {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}/messages?pageSize=200`);
return handleResponse(response, (data) => data as MessageListResponse);
}
export async function updateConversation(
projectId: string,
conversationId: string,
data: UpdateConversationRequest,
): Promise<Conversation> {
try {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
} catch (err: unknown) {
logger.error("更新会话失败", {
conversationId,
error: err instanceof Error ? err.message : String(err),
projectId,
});
throw err;
}
}

View File

@@ -0,0 +1,6 @@
import { theme } from "antd";
export function useIsDark(): boolean {
const { token } = theme.useToken();
return token.colorBgBase === "#000";
}

View File

@@ -0,0 +1,25 @@
import { App } from "antd";
import { useMemo, useState } from "react";
import type { Logger } from "../utils/logger";
import { AntdMessageSink, ConsoleSink, createDefaultLogger } from "../utils/logger";
export function useLogger(bindings?: Record<string, unknown>): Logger {
const { message } = App.useApp();
const [stableJson, setStableJson] = useState(() => JSON.stringify(bindings ?? {}));
const [stableBindings, setStableBindings] = useState(() => bindings);
const currentJson = JSON.stringify(bindings ?? {});
if (currentJson !== stableJson) {
setStableJson(currentJson);
setStableBindings(bindings);
}
return useMemo(() => {
const isProduction = !!import.meta.env["PROD"];
const base = createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction);
if (!stableBindings || Object.keys(stableBindings).length === 0) return base;
return base.child(stableBindings);
}, [message, stableBindings]);
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import type { MetaResponse } from "../../../shared/api";
export function useMeta() {
return useQuery({
queryFn: fetchMeta,
queryKey: ["meta"],
refetchInterval: 30000,
staleTime: 5000,
});
}
async function fetchMeta(): Promise<MetaResponse> {
const response = await fetch("/api/meta");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<MetaResponse>;
}

View File

@@ -0,0 +1,135 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CreateModelRequest,
Model,
ModelListResponse,
ModelResponse,
ModelTestResponse,
ModelTestResultResponse,
TestModelRequest,
UpdateModelRequest,
} from "../../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const MODELS_KEY = ["models"] as const;
const logger = createConsoleLogger();
export async function createModel(data: CreateModelRequest): Promise<Model> {
const response = await fetch("/api/models", {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "POST",
});
return handleResponse(response, (data) => (data as ModelResponse).model);
}
export async function deleteModel(id: string): Promise<void> {
const response = await fetch(`/api/models/${id}`, { method: "DELETE" });
return handleVoidResponse(response);
}
export async function fetchModel(id: string): Promise<Model> {
const response = await fetch(`/api/models/${id}`);
return handleResponse(response, (data) => (data as ModelResponse).model);
}
export async function fetchModelList(params: {
keyword?: string;
page?: number;
pageSize?: number;
providerId?: string;
}): Promise<ModelListResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
if (params.providerId) searchParams.set("providerId", params.providerId);
const qs = searchParams.toString();
const url = `/api/models${qs ? `?${qs}` : ""}`;
const response = await fetch(url);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<ModelListResponse>;
}
export async function testModelConnection(data: TestModelRequest): Promise<ModelTestResponse> {
const response = await fetch("/api/models/test", {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "POST",
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const result = (await response.json()) as ModelTestResultResponse;
return result.modelTestResponse;
}
export async function updateModel(id: string, data: UpdateModelRequest): Promise<Model> {
const response = await fetch(`/api/models/${id}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
return handleResponse(response, (data) => (data as ModelResponse).model);
}
export function useCreateModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createModel,
onSuccess: (data) => {
logger.info("模型创建成功", { modelId: data.modelId, providerId: data.providerId });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
});
}
export function useDeleteModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteModel,
onSuccess: (_data, variables) => {
logger.info("模型删除成功", { modelId: variables });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
});
}
export function useModel(id: string) {
return useQuery({
enabled: !!id,
queryFn: () => fetchModel(id),
queryKey: [...MODELS_KEY, "detail", id],
});
}
export function useModelList(params: { keyword?: string; page?: number; pageSize?: number; providerId?: string }) {
return useQuery({
queryFn: () => fetchModelList(params),
queryKey: [...MODELS_KEY, "list", params],
});
}
export function useTestModelConnection() {
return useMutation({
mutationFn: testModelConnection,
});
}
export function useUpdateModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data),
onSuccess: (data) => {
logger.info("模型更新成功", { modelId: data.modelId, providerId: data.providerId });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
});
}

View File

@@ -0,0 +1,145 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CreateProjectRequest,
Project,
ProjectListResponse,
ProjectResponse,
ProjectStatus,
UpdateProjectRequest,
} from "../../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const PROJECTS_KEY = ["projects"] as const;
const logger = createConsoleLogger();
export async function archiveProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
return handleResponse(response, (data) => (data as ProjectResponse).project);
}
export async function createProject(data: CreateProjectRequest): Promise<Project> {
const response = await fetch("/api/projects", {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "POST",
});
return handleResponse(response, (data) => (data as ProjectResponse).project);
}
export async function deleteProject(id: string): Promise<void> {
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
return handleVoidResponse(response);
}
export async function fetchProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}`);
return handleResponse(response, (data) => (data as ProjectResponse).project);
}
export async function fetchProjectList(params: {
keyword?: string;
page?: number;
pageSize?: number;
status?: ProjectStatus;
}): Promise<ProjectListResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
if (params.status) searchParams.set("status", params.status);
const qs = searchParams.toString();
const url = `/api/projects${qs ? `?${qs}` : ""}`;
const response = await fetch(url);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<ProjectListResponse>;
}
export async function restoreProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
return handleResponse(response, (data) => (data as ProjectResponse).project);
}
export async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
const response = await fetch(`/api/projects/${id}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
return handleResponse(response, (data) => (data as ProjectResponse).project);
}
export function useArchiveProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: archiveProject,
onSuccess: (data) => {
logger.info("项目归档成功", { name: data.name, projectId: data.id });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
}
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProject,
onSuccess: (data) => {
logger.info("项目创建成功", { name: data.name, projectId: data.id });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
}
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteProject,
onSuccess: (_data, variables) => {
logger.info("项目删除成功", { projectId: variables });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
}
export function useProject(id: string) {
return useQuery({
enabled: !!id,
queryFn: () => fetchProject(id),
queryKey: [...PROJECTS_KEY, "detail", id],
});
}
export function useProjectList(params: { keyword?: string; page?: number; pageSize?: number; status?: ProjectStatus }) {
return useQuery({
queryFn: () => fetchProjectList(params),
queryKey: [...PROJECTS_KEY, "list", params],
});
}
export function useRestoreProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: restoreProject,
onSuccess: (data) => {
logger.info("项目恢复成功", { name: data.name, projectId: data.id });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
}
export function useUpdateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: { data: UpdateProjectRequest; id: string }) => updateProject(args.id, args.data),
onSuccess: (data) => {
logger.info("项目更新成功", { name: data.name, projectId: data.id });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
}

View File

@@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CreateProviderRequest,
Provider,
ProviderListResponse,
ProviderOptionsResponse,
ProviderResponse,
ProviderTestResponse,
ProviderTestResultResponse,
UpdateProviderRequest,
} from "../../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const PROVIDERS_KEY = ["providers"] as const;
const MODELS_KEY = ["models"] as const;
const logger = createConsoleLogger();
export async function createProvider(data: CreateProviderRequest): Promise<Provider> {
const response = await fetch("/api/providers", {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "POST",
});
return handleResponse(response, (data) => (data as ProviderResponse).provider);
}
export async function deleteProvider(id: string): Promise<void> {
const response = await fetch(`/api/providers/${id}`, { method: "DELETE" });
return handleVoidResponse(response);
}
export async function fetchProvider(id: string): Promise<Provider> {
const response = await fetch(`/api/providers/${id}`);
return handleResponse(response, (data) => (data as ProviderResponse).provider);
}
export async function fetchProviderList(params: {
keyword?: string;
page?: number;
pageSize?: number;
}): Promise<ProviderListResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
const qs = searchParams.toString();
const url = `/api/providers${qs ? `?${qs}` : ""}`;
const response = await fetch(url);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<ProviderListResponse>;
}
export async function fetchProviderOptions(): Promise<ProviderOptionsResponse> {
const response = await fetch("/api/providers/options");
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<ProviderOptionsResponse>;
}
export async function testProviderConfig(data: CreateProviderRequest): Promise<ProviderTestResponse> {
const response = await fetch("/api/providers/test", {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "POST",
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const result = (await response.json()) as ProviderTestResultResponse;
return result.providerTestResponse;
}
export async function updateProvider(id: string, data: UpdateProviderRequest): Promise<Provider> {
const response = await fetch(`/api/providers/${id}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
return handleResponse(response, (data) => (data as ProviderResponse).provider);
}
export function useCreateProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProvider,
onSuccess: (data) => {
logger.info("供应商创建成功", { name: data.name, providerId: data.id });
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
},
});
}
export function useDeleteProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteProvider,
onSuccess: (_data, variables) => {
logger.info("供应商删除成功", { providerId: variables });
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
});
}
export function useProvider(id: string) {
return useQuery({
enabled: !!id,
queryFn: () => fetchProvider(id),
queryKey: [...PROVIDERS_KEY, "detail", id],
});
}
export function useProviderList(params: { keyword?: string; page?: number; pageSize?: number }) {
return useQuery({
queryFn: () => fetchProviderList(params),
queryKey: [...PROVIDERS_KEY, "list", params],
});
}
export function useProviderOptions() {
return useQuery({
queryFn: fetchProviderOptions,
queryKey: [...PROVIDERS_KEY, "options"],
});
}
export function useTestProviderConfig() {
return useMutation({
mutationFn: testProviderConfig,
});
}
export function useUpdateProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: { data: UpdateProviderRequest; id: string }) => updateProvider(args.id, args.data),
onSuccess: (data) => {
logger.info("供应商更新成功", { name: data.name, providerId: data.id });
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
},
});
}

View File

@@ -0,0 +1,34 @@
import { useState } from "react";
export const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebar.collapsed";
export function parseSidebarCollapsed(value: unknown): boolean {
return value === "true";
}
export function readSidebarCollapsed(storage: Storage = window.localStorage): boolean {
try {
return parseSidebarCollapsed(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY));
} catch {
return false;
}
}
export function useSidebarCollapsed() {
const [collapsed, setCollapsedState] = useState<boolean>(() => readSidebarCollapsed());
const setCollapsed = (nextCollapsed: boolean) => {
setCollapsedState(nextCollapsed);
writeSidebarCollapsed(nextCollapsed);
};
return { collapsed, setCollapsed };
}
export function writeSidebarCollapsed(collapsed: boolean, storage: Storage = window.localStorage) {
try {
storage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
} catch {
// 存储不可用时仅使用当前内存状态
}
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
export type EffectiveTheme = "dark" | "light";
export type ThemePreference = "dark" | "light" | "system";
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
try {
return matchMedia(THEME_MEDIA_QUERY).matches;
} catch {
return false;
}
}
export function parseThemePreference(value: unknown): ThemePreference {
return value === "dark" || value === "light" || value === "system" ? value : "system";
}
export function readThemePreference(storage: Storage = window.localStorage): ThemePreference {
try {
return parseThemePreference(storage.getItem(THEME_PREFERENCE_STORAGE_KEY));
} catch {
return "system";
}
}
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
if (preference === "dark" || preference === "light") return preference;
return systemPrefersDark ? "dark" : "light";
}
export function useThemePreference() {
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
useEffect(() => {
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches);
mediaQueryList.addEventListener("change", handleChange);
return () => mediaQueryList.removeEventListener("change", handleChange);
}, []);
const setPreference = (nextPreference: ThemePreference) => {
setPreferenceState(nextPreference);
writeThemePreference(nextPreference);
};
return { effectiveTheme, preference, setPreference };
}
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
try {
storage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference);
} catch {
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
}
}

View File

@@ -0,0 +1,49 @@
import { createConsoleLogger } from "./logger";
const logger = createConsoleLogger();
export async function handleResponse<T>(response: Response, extract: (data: unknown) => T): Promise<T> {
const start = performance.now();
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
const errorBody = body?.error ?? `HTTP ${response.status}`;
logger.warn("API request failed", {
duration: Math.round(performance.now() - start),
errorBody,
status: response.status,
url: response.url,
});
throw new Error(errorBody);
}
const data: unknown = await response.json();
if (import.meta.env["DEV"]) {
logger.debug("API request", {
duration: Math.round(performance.now() - start),
status: response.status,
url: response.url,
});
}
return extract(data);
}
export async function handleVoidResponse(response: Response): Promise<void> {
const start = performance.now();
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
const errorBody = body?.error ?? `HTTP ${response.status}`;
logger.warn("API request failed", {
duration: Math.round(performance.now() - start),
errorBody,
status: response.status,
url: response.url,
});
throw new Error(errorBody);
}
if (import.meta.env["DEV"]) {
logger.debug("API request", {
duration: Math.round(performance.now() - start),
status: response.status,
url: response.url,
});
}
}

View File

@@ -0,0 +1,176 @@
import type { MessageInstance } from "antd/es/message/interface";
export type LogLevel = "debug" | "error" | "info" | "warn";
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 0, error: 3, info: 1, warn: 2 };
export interface Logger {
child(bindings: Record<string, unknown>): Logger;
debug(message: string, data?: unknown): void;
error(message: string, data?: unknown): void;
info(message: string, data?: unknown): void;
setLevel(level: LogLevel): void;
warn(message: string, data?: unknown): void;
}
export interface Sink {
write(level: LogLevel, message: string, data: unknown, bindings: Record<string, unknown>): void;
}
class AntdMessageSink implements Sink {
constructor(private messageApi: MessageInstance) {
// 仅存储依赖,无需初始化操作
}
write(level: LogLevel, message: string, _data: unknown, _bindings: Record<string, unknown>): void {
if (level === "warn") this.messageApi.warning(message);
else if (level === "error") this.messageApi.error(message);
}
}
class BaseLogger implements Logger {
private minLevel: LogLevel;
constructor(
minLevel: LogLevel,
protected sinks: Sink[],
protected bindings: Record<string, unknown>,
) {
this.minLevel = minLevel;
}
child(bindings: Record<string, unknown>): Logger {
return new BaseLogger(this.minLevel, this.sinks, { ...this.bindings, ...bindings });
}
debug(message: string, data?: unknown): void {
this.log("debug", message, data);
}
error(message: string, data?: unknown): void {
this.log("error", message, data);
}
info(message: string, data?: unknown): void {
this.log("info", message, data);
}
setLevel(level: LogLevel): void {
this.minLevel = level;
}
warn(message: string, data?: unknown): void {
this.log("warn", message, data);
}
private log(level: LogLevel, message: string, data?: unknown): void {
if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) return;
for (const sink of this.sinks) {
sink.write(level, message, data, this.bindings);
}
}
}
class ConsoleSink implements Sink {
constructor(private isProduction: boolean) {
// 仅存储配置,无需初始化操作
}
write(level: LogLevel, message: string, data: unknown, bindings: Record<string, unknown>): void {
if (this.isProduction && LEVEL_ORDER[level] < LEVEL_ORDER.warn) return;
const prefix = `[Alfred:${level.toUpperCase()}]`;
const bindingStr = formatBindings(bindings);
const fullMessage = `${prefix} ${message}${bindingStr}`;
if (level === "error") console.error(fullMessage, data);
else if (level === "warn") console.warn(fullMessage, data);
else console.log(fullMessage, data);
}
}
class NoopLogger implements Logger {
child(_bindings: Record<string, unknown>): Logger {
return this;
}
debug(_message: string, _data?: unknown): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
error(_message: string, _data?: unknown): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
info(_message: string, _data?: unknown): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
setLevel(_level: LogLevel): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
warn(_message: string, _data?: unknown): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
}
export class MemoryLogger implements Logger {
entries: Array<{ data?: unknown; level: LogLevel; message: string }> = [];
child(bindings: Record<string, unknown>): Logger {
void bindings;
return this;
}
debug(message: string, data?: unknown): void {
this.capture("debug", message, data);
}
error(message: string, data?: unknown): void {
this.capture("error", message, data);
}
info(message: string, data?: unknown): void {
this.capture("info", message, data);
}
setLevel(_level: LogLevel): void {
// MemoryLogger.setLevel 为接口兼容,无需实际过滤
}
warn(message: string, data?: unknown): void {
this.capture("warn", message, data);
}
private capture(level: LogLevel, message: string, data?: unknown): void {
this.entries.push({ data, level, message });
}
}
export function createConsoleLogger(): Logger {
const isProduction = !!import.meta.env["PROD"];
const minLevel: LogLevel = isProduction ? "warn" : "debug";
return new BaseLogger(minLevel, [new ConsoleSink(isProduction)], {});
}
export function createDefaultLogger(sinks: Sink[], isProduction: boolean): Logger {
const minLevel: LogLevel = isProduction ? "warn" : "debug";
return new BaseLogger(minLevel, sinks, {});
}
export function createMemoryLogger(): MemoryLogger {
return new MemoryLogger();
}
export { AntdMessageSink, ConsoleSink };
export function createNoopLogger(): Logger {
return new NoopLogger();
}
function formatBindings(bindings: Record<string, unknown>): string {
const entries = Object.entries(bindings);
if (entries.length === 0) return "";
return " " + entries.map(([k, v]) => `[${k}=${String(v)}]`).join("");
}

View File

@@ -0,0 +1,46 @@
export function formatCountdown(seconds: number): string {
if (seconds < 60) return `${seconds}`;
return `${Math.floor(seconds / 60)}${seconds % 60}`;
}
export function formatDurationUnit(ms: null | number): { suffix: string; value: number } {
if (ms === null) return { suffix: "", value: 0 };
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };
if (ms < 3600000) return { suffix: "分钟", value: roundToOne(ms / 60000) };
return { suffix: "小时", value: roundToOne(ms / 3600000) };
}
export function formatRelativeTime(timestamp: null | string, now = new Date()): string {
if (!timestamp) return "尚无检查数据";
const time = new Date(timestamp).getTime();
if (Number.isNaN(time)) return "尚无检查数据";
const diffSeconds = Math.max(0, Math.floor((now.getTime() - time) / 1000));
if (diffSeconds < 60) return `${diffSeconds}秒前`;
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}小时前`;
return `${Math.floor(diffHours / 24)}天前`;
}
export function isOlderThan(timestamp: null | string, ageMs: number, now = new Date()): boolean {
if (!timestamp) return false;
const time = new Date(timestamp).getTime();
if (Number.isNaN(time)) return false;
return now.getTime() - time > ageMs;
}
export function subtractHours(date: Date, hours: number): Date {
const result = new Date(date);
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
return result;
}
function roundToOne(value: number): number {
return Math.round(value * 10) / 10;
}