From 58d551eda6688ec07bd12ac48d32e7e97ec75e1b Mon Sep 17 00:00:00 2001 From: zhilv Date: Fri, 27 Mar 2026 17:55:01 +0800 Subject: [PATCH] feat: add multi-account log center --- src/App.tsx | 3 +- src/components/account/CourseWorkspace.tsx | 33 +- src/index.tsx | 4 + src/pages/accouts/Account.tsx | 38 ++- src/pages/logs/Logs.tsx | 331 +++++++++++++++++++++ src/service/http.ts | 117 ++++---- src/service/wk.ts | 63 ++-- src/store/account.ts | 26 +- 8 files changed, 509 insertions(+), 106 deletions(-) create mode 100644 src/pages/logs/Logs.tsx diff --git a/src/App.tsx b/src/App.tsx index f7daf80..a6b2567 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { A, useLocation } from "@solidjs/router"; const asideList = [ { label: "账号", url: "/account" }, + { label: "日志", url: "/logs" }, { label: "设置", url: "/setting" }, ]; @@ -52,7 +53,7 @@ const App: ParentComponent = (props) => { Navigation

- 在这里切换账号管理与系统设置。 + 在这里切换账号管理、全局日志与系统设置。

diff --git a/src/components/account/CourseWorkspace.tsx b/src/components/account/CourseWorkspace.tsx index b8cce37..6f8e496 100644 --- a/src/components/account/CourseWorkspace.tsx +++ b/src/components/account/CourseWorkspace.tsx @@ -38,11 +38,20 @@ interface CourseWorkspaceProps { } const EmptyState = (props: { children: JSX.Element }) => ( -
+
{props.children}
); +const extractTimestamp = (message: string) => { + const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/); + return match?.[1] ?? null; +}; + +const stripTimestamp = (message: string) => { + return message.replace(/^\[(\d{2}:\d{2}:\d{2})\]\s*/, ""); +}; + const CourseWorkspace = (props: CourseWorkspaceProps) => { let logContainerRef: HTMLDivElement | undefined; @@ -98,7 +107,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { : "grid min-h-0 flex-1 gap-4 p-4 xl:grid-cols-[340px_minmax(0,1fr)]" } > -
+

课程列表

点击课程查看对应记录

@@ -113,16 +122,16 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { > {(course) => { - const selected = course.id === props.selectedCourseId; + const selected = () => course.id === props.selectedCourseId; return ( +
+
+
+ +
+
+
+

账号概览

+

最新输出与日志数量

+
+ +
+ + {(account) => ( +
+ {/* 顶部 */} +
+
+

+ {account.name} +

+

{account.host}

+
+ +
+ {account.total} +
+
+ + {/* 最新日志 */} +
+
+ Latest + {account.latestTime ?? "--:--"} +
+ +

+ {account.latestMessage} +

+
+
+ )} +
+
+
+ +
+
+
+
+

实时日志流

+

+ 保留关键信息,把更多高度让给正文 +

+
+ +
+ + {settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"} + + + {settingsState().autoScrollLogs + ? "自动滚动中" + : "自动滚动关闭"} + + + + 最近来源 {latestLog()?.accountName} + + +
+
+
+ +
+ 0} + fallback={ +
+ 暂无日志输出 +
+ } + > +
+ + {(log) => ( +
+ {/* 头部 */} +
+
+ {log.accountName} + #{log.index} + + + + {log.timestamp} + + +
+ + {log.accountId} +
+ + {/* 内容 */} +

+ {log.content} +

+
+ )} +
+
+
+
+
+
+
+ ); +}; + +export default Logs; diff --git a/src/service/http.ts b/src/service/http.ts index 93c7c55..8acb133 100644 --- a/src/service/http.ts +++ b/src/service/http.ts @@ -1,7 +1,16 @@ import axios from "axios"; import type { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios"; -type UnauthorizedHandler = () => Promise; +type UnauthorizedHandler = (sessionId: string) => Promise; + +type SessionResolver = () => string | undefined; + +export type HttpClient = { + get(url: string, config?: AxiosRequestConfig): Promise; + post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise; + put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise; + delete(url: string, config?: AxiosRequestConfig): Promise; +}; let unauthorizedHandler: UnauthorizedHandler | null = null; @@ -9,59 +18,67 @@ export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => { unauthorizedHandler = handler; }; -const instance = axios.create({ - baseURL: "http://127.0.0.1:8080", -}); +export const createHttpClient = ( + resolveSessionId?: SessionResolver, +): HttpClient => { + const instance = axios.create({ + baseURL: "http://127.0.0.1:8080", + }); -instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { - const sessionID = sessionStorage.getItem("session_id"); - if (sessionID) { - config.headers["X-Session-Id"] = sessionID; - } - return config; -}); - -instance.interceptors.response.use( - (response) => response.data, - async (error) => { - const config = error.config as - | (AxiosRequestConfig & { _retry?: boolean }) - | undefined; - const status = error.response?.status; - const url = config?.url ?? ""; - - if ( - status === 401 && - config && - !config._retry && - unauthorizedHandler && - !url.includes("/api/login") - ) { - config._retry = true; - const ok = await unauthorizedHandler(); - - if (ok) { - return instance.request(config); - } + instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const sessionId = resolveSessionId?.(); + if (sessionId) { + config.headers["X-Session-Id"] = sessionId; } + return config; + }); - return Promise.reject(error); - }, -); + instance.interceptors.response.use( + (response) => response.data, + async (error) => { + const config = error.config as + | (AxiosRequestConfig & { _retry?: boolean }) + | undefined; + const status = error.response?.status; + const url = config?.url ?? ""; + const sessionId = resolveSessionId?.(); -const http = { - get(url: string, config?: AxiosRequestConfig) { - return instance.get(url, config); - }, - post(url: string, data?: unknown, config?: AxiosRequestConfig) { - return instance.post(url, data, config); - }, - put(url: string, data?: unknown, config?: AxiosRequestConfig) { - return instance.put(url, data, config); - }, - delete(url: string, config?: AxiosRequestConfig) { - return instance.delete(url, config); - }, + if ( + status === 401 && + config && + !config._retry && + unauthorizedHandler && + sessionId && + !url.includes("/api/login") + ) { + config._retry = true; + const ok = await unauthorizedHandler(sessionId); + + if (ok) { + return instance.request(config); + } + } + + return Promise.reject(error); + }, + ); + + return { + get(url: string, config?: AxiosRequestConfig) { + return instance.get(url, config); + }, + post(url: string, data?: unknown, config?: AxiosRequestConfig) { + return instance.post(url, data, config); + }, + put(url: string, data?: unknown, config?: AxiosRequestConfig) { + return instance.put(url, data, config); + }, + delete(url: string, config?: AxiosRequestConfig) { + return instance.delete(url, config); + }, + }; }; +const http = createHttpClient(); + export default http; diff --git a/src/service/wk.ts b/src/service/wk.ts index 4581c56..9f9693d 100644 --- a/src/service/wk.ts +++ b/src/service/wk.ts @@ -1,5 +1,5 @@ import type { Accessor } from "solid-js"; -import http from "~/service/http"; +import http, { createHttpClient, type HttpClient } from "~/service/http"; import type { CourseType } from "~/types/Course"; import type { userInfoType } from "~/types/Userinfo"; @@ -131,25 +131,37 @@ export type StudyRunnerPayload = { intervalSeconds: number; items: StudyRunnerItem[]; isRunningStudy: Accessor; - onLog?: (message: string) => void; + client: WkClient; + onLog?: (message: string, accoundID: string) => void; +}; + +export type WkClient = { + recordApi: (payload: RecordReq) => Promise; + studyApi: (payload: StudyReq) => Promise; + logoutApi: () => Promise; }; export const loginApi = async (payload: LoginReq) => { const res = await http.post("/api/login", payload); - - if (res.data?.session_id) { - sessionStorage.setItem("session_id", res.data.session_id); - } - return res; }; -export const recordApi = async (payload: RecordReq) => { - return await http.post("/api/v2/record", payload); -}; +const createWkClientFromHttp = (client: HttpClient): WkClient => ({ + recordApi(payload) { + return client.post("/api/v2/record", payload); + }, + studyApi(payload) { + return client.post("/api/v2/study", payload); + }, + logoutApi() { + return client.post("/api/v2/logout"); + }, +}); -export const studyApi = async (payload: StudyReq) => { - return await http.post("/api/v2/study", payload); +export const createWkClient = ( + resolveSessionId: () => string | undefined, +): WkClient => { + return createWkClientFromHttp(createHttpClient(resolveSessionId)); }; const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); @@ -160,33 +172,32 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => { let currentTime = 0; let count = 0; let study_id = 0; + let total = item.totalTime - item.currentTime; - while (currentTime <= item.totalTime) { + while (currentTime <= total) { if (!stopFlag()) { - _payload.onLog?.("⛔ 已手动停止"); + _payload.onLog?.("⛔ 已手动停止", _payload.accountId); return; } - const message = `[${item.name}]: ${currentTime}/${item.totalTime}`; - console.log(message); - _payload.onLog?.(message); + const message = `[${item.name}]: ${currentTime}/${total}`; + _payload.onLog?.(message, _payload.accountId); try { - const resp = await studyApi({ + const resp = await _payload.client.studyApi({ node_id: item.nodeId, study_id: String(study_id), study_time: String(currentTime), - status: count === 0 ? 1 : currentTime >= item.totalTime ? 3 : 2, + status: count === 0 ? 1 : currentTime >= total ? 3 : 2, }); study_id = resp.data.studyId; - if (currentTime === item.totalTime) break; + if (currentTime === total) break; - currentTime = Math.min(currentTime + 5, item.totalTime); + currentTime = Math.min(currentTime + 5, total); count++; } catch (error) { const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`; - console.log(errorMessage); - _payload.onLog?.(errorMessage); + _payload.onLog?.(errorMessage, _payload.accountId); } await sleep(5000); @@ -194,12 +205,6 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => { } }; -export const logoutApi = async () => { - const res = await http.post("/api/v2/logout"); - sessionStorage.removeItem("session_id"); - return res; -}; - export const hostApi = async () => { return await http.get("/api/v1/host"); }; diff --git a/src/store/account.ts b/src/store/account.ts index 85de2f4..47aea4c 100644 --- a/src/store/account.ts +++ b/src/store/account.ts @@ -4,6 +4,22 @@ import type { CourseKind, RecordItem, RecordType } from "~/service/wk"; import type { CourseType } from "~/types/Course"; import type { userInfoType } from "~/types/Userinfo"; +const withLogTimestamp = (message: string) => { + if (/^\[\d{2}:\d{2}:\d{2}\]\s/.test(message)) { + return message; + } + + const now = new Date(); + const timestamp = now.toLocaleTimeString("zh-CN", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + return `[${timestamp}] ${message}`; +}; + export type AccountAuth = { password: string; token: string; @@ -37,6 +53,7 @@ type AccountState = { setAccountRunningStudy: (accountId: string, value: boolean) => void; appendStudyLog: (accountId: string, message: string) => void; clearStudyLogs: (accountId: string) => void; + clearAllStudyLogs: () => void; upsertAccount: (account: AccountItem) => void; removeAccount: (accountId: string) => void; }; @@ -70,7 +87,10 @@ export const accountStore = createStore()( set((state) => ({ studyLogsMap: { ...state.studyLogsMap, - [accountId]: [...(state.studyLogsMap[accountId] ?? []), message], + [accountId]: [ + ...(state.studyLogsMap[accountId] ?? []), + withLogTimestamp(message), + ], }, })), clearStudyLogs: (accountId) => @@ -80,6 +100,10 @@ export const accountStore = createStore()( [accountId]: [], }, })), + clearAllStudyLogs: () => + set({ + studyLogsMap: {}, + }), upsertAccount: (account) => set((state) => ({ accounts: [