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:
5
src/web/shared/components/ConsoleShell/ConsoleOutlet.tsx
Normal file
5
src/web/shared/components/ConsoleShell/ConsoleOutlet.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export function ConsoleOutlet() {
|
||||
return <Outlet />;
|
||||
}
|
||||
75
src/web/shared/components/ConsoleShell/ConsoleShell.tsx
Normal file
75
src/web/shared/components/ConsoleShell/ConsoleShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/web/shared/components/ConsoleShell/types.ts
Normal file
9
src/web/shared/components/ConsoleShell/types.ts
Normal 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;
|
||||
}
|
||||
44
src/web/shared/components/ErrorBoundary.tsx
Normal file
44
src/web/shared/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
36
src/web/shared/components/Sidebar/index.tsx
Normal file
36
src/web/shared/components/Sidebar/index.tsx
Normal 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} />;
|
||||
}
|
||||
72
src/web/shared/hooks/use-conversations.ts
Normal file
72
src/web/shared/hooks/use-conversations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/web/shared/hooks/use-is-dark.ts
Normal file
6
src/web/shared/hooks/use-is-dark.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { theme } from "antd";
|
||||
|
||||
export function useIsDark(): boolean {
|
||||
const { token } = theme.useToken();
|
||||
return token.colorBgBase === "#000";
|
||||
}
|
||||
25
src/web/shared/hooks/use-logger.ts
Normal file
25
src/web/shared/hooks/use-logger.ts
Normal 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]);
|
||||
}
|
||||
18
src/web/shared/hooks/use-meta.ts
Normal file
18
src/web/shared/hooks/use-meta.ts
Normal 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>;
|
||||
}
|
||||
135
src/web/shared/hooks/use-models.ts
Normal file
135
src/web/shared/hooks/use-models.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
145
src/web/shared/hooks/use-projects.ts
Normal file
145
src/web/shared/hooks/use-projects.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
151
src/web/shared/hooks/use-providers.ts
Normal file
151
src/web/shared/hooks/use-providers.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
34
src/web/shared/hooks/use-sidebar-collapsed.ts
Normal file
34
src/web/shared/hooks/use-sidebar-collapsed.ts
Normal 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 {
|
||||
// 存储不可用时仅使用当前内存状态
|
||||
}
|
||||
}
|
||||
61
src/web/shared/hooks/use-theme-preference.ts
Normal file
61
src/web/shared/hooks/use-theme-preference.ts
Normal 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 渲染。
|
||||
}
|
||||
}
|
||||
49
src/web/shared/utils/api.ts
Normal file
49
src/web/shared/utils/api.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
176
src/web/shared/utils/logger.ts
Normal file
176
src/web/shared/utils/logger.ts
Normal 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("");
|
||||
}
|
||||
46
src/web/shared/utils/time.ts
Normal file
46
src/web/shared/utils/time.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user