feat: add multi-account log center
This commit is contained in:
@@ -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
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
在这里切换账号管理与系统设置。
|
||||
在这里切换账号管理、全局日志与系统设置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,11 +38,20 @@ interface CourseWorkspaceProps {
|
||||
}
|
||||
|
||||
const EmptyState = (props: { children: JSX.Element }) => (
|
||||
<div class="rounded-[24px] border border-dashed border-zinc-300 bg-white px-4 py-8 text-center text-zinc-500">
|
||||
<div class="rounded-3xl border border-dashed border-zinc-300 bg-white px-4 py-8 text-center text-zinc-500">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
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)]"
|
||||
}
|
||||
>
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-[24px] border border-zinc-200 bg-[linear-gradient(180deg,_rgba(248,250,252,0.9),_rgba(255,255,255,0.95))]">
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
|
||||
<div class="border-b border-zinc-200 px-4 py-3">
|
||||
<p class="text-sm font-semibold text-zinc-800">课程列表</p>
|
||||
<p class="mt-1 text-xs text-zinc-500">点击课程查看对应记录</p>
|
||||
@@ -113,16 +122,16 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
||||
>
|
||||
<For each={props.selectedCourseList}>
|
||||
{(course) => {
|
||||
const selected = course.id === props.selectedCourseId;
|
||||
const selected = () => course.id === props.selectedCourseId;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={
|
||||
selected
|
||||
selected()
|
||||
? compact()
|
||||
? "rounded-[20px] border border-cyan-300 bg-[linear-gradient(145deg,_rgba(236,254,255,0.95),_rgba(240,253,244,0.95))] px-3 py-3 text-left shadow-sm"
|
||||
: "rounded-[22px] border border-cyan-300 bg-[linear-gradient(145deg,_rgba(236,254,255,0.95),_rgba(240,253,244,0.95))] px-4 py-4 text-left shadow-sm"
|
||||
? "rounded-[20px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-3 py-3 text-left shadow-sm"
|
||||
: "rounded-[22px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-4 py-4 text-left shadow-sm"
|
||||
: compact()
|
||||
? "rounded-[20px] border border-zinc-200 bg-white px-3 py-3 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30"
|
||||
: "rounded-[22px] border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30"
|
||||
@@ -151,7 +160,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
||||
: "grid min-h-0 gap-4 xl:grid-rows-[minmax(0,1fr)_260px]"
|
||||
}
|
||||
>
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-[24px] border border-zinc-200 bg-[linear-gradient(180deg,_rgba(248,250,252,0.9),_rgba(255,255,255,0.95))]">
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-zinc-800">记录列表</p>
|
||||
@@ -218,7 +227,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
||||
</Show>
|
||||
|
||||
<Show when={!props.recordsLoading && props.recordError}>
|
||||
<div class="rounded-[24px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
|
||||
<div class="rounded-3xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
|
||||
{props.recordError}
|
||||
</div>
|
||||
</Show>
|
||||
@@ -284,7 +293,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-[24px] border border-zinc-200 bg-white">
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-zinc-800">运行日志</p>
|
||||
@@ -325,10 +334,10 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
||||
{(log, index) => (
|
||||
<p>
|
||||
[{index() + 1}]{" "}
|
||||
{props.showLogTimestamps
|
||||
? `${new Date().toLocaleTimeString()} `
|
||||
{props.showLogTimestamps && extractTimestamp(log)
|
||||
? `${extractTimestamp(log)} `
|
||||
: ""}
|
||||
{log}
|
||||
{stripTimestamp(log)}
|
||||
</p>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -19,6 +19,10 @@ render(
|
||||
path="account"
|
||||
component={lazy(() => import("./pages/accouts/Account.tsx"))}
|
||||
/>
|
||||
<Route
|
||||
path="logs"
|
||||
component={lazy(() => import("./pages/logs/Logs.tsx"))}
|
||||
/>
|
||||
<Route
|
||||
path="setting"
|
||||
component={lazy(() => import("./pages/settings/Setting.tsx"))}
|
||||
|
||||
@@ -11,9 +11,8 @@ import AddAccountDialog, {
|
||||
} from "~/components/account/AddAccountDialog";
|
||||
import CourseWorkspace from "~/components/account/CourseWorkspace";
|
||||
import {
|
||||
createWkClient,
|
||||
loginApi,
|
||||
logoutApi,
|
||||
recordApi,
|
||||
runStudyQueue,
|
||||
type CourseKind,
|
||||
type RecordType,
|
||||
@@ -74,6 +73,7 @@ const Account = () => {
|
||||
const [isRefreshingRecords, setIsRefreshingRecords] = createSignal(false);
|
||||
const [isRefreshingLogs, setIsRefreshingLogs] = createSignal(false);
|
||||
const [hasRestoredRecords, setHasRestoredRecords] = createSignal(false);
|
||||
const accountClients = new Map<string, ReturnType<typeof createWkClient>>();
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribeAccount = accountStore.subscribe((state) => {
|
||||
@@ -161,8 +161,23 @@ const Account = () => {
|
||||
accountStore.getState().appendStudyLog(targetAccountId, message);
|
||||
};
|
||||
|
||||
const reloginBySession = async () => {
|
||||
const sessionId = sessionStorage.getItem("session_id");
|
||||
const getAccountClient = (accountId: string) => {
|
||||
const existingClient = accountClients.get(accountId);
|
||||
if (existingClient) {
|
||||
return existingClient;
|
||||
}
|
||||
|
||||
const client = createWkClient(() => {
|
||||
return accountStore
|
||||
.getState()
|
||||
.accounts.find((item) => item.id === accountId)?.sessionId;
|
||||
});
|
||||
|
||||
accountClients.set(accountId, client);
|
||||
return client;
|
||||
};
|
||||
|
||||
const reloginBySession = async (sessionId: string) => {
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -200,10 +215,6 @@ const Account = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const applySessionId = (sessionId: string) => {
|
||||
sessionStorage.setItem("session_id", sessionId);
|
||||
};
|
||||
|
||||
const openDialog = () => {
|
||||
setErrorMessage("");
|
||||
setForm(createDefaultForm(defaultHost()));
|
||||
@@ -326,8 +337,8 @@ const Account = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
applySessionId(target.sessionId);
|
||||
await logoutApi();
|
||||
await getAccountClient(accountId).logoutApi();
|
||||
accountClients.delete(accountId);
|
||||
|
||||
accountStore.getState().removeAccount(accountId);
|
||||
if (selectedAccountId() === accountId) {
|
||||
@@ -357,8 +368,7 @@ const Account = () => {
|
||||
setRecordError("");
|
||||
|
||||
try {
|
||||
applySessionId(account.sessionId);
|
||||
const res = await recordApi({
|
||||
const res = await getAccountClient(account.id).recordApi({
|
||||
course_id: String(courseId),
|
||||
page: 0,
|
||||
record_type: nextRecordType,
|
||||
@@ -435,9 +445,11 @@ const Account = () => {
|
||||
courseId: course.id,
|
||||
intervalSeconds: 5,
|
||||
items: queueItems,
|
||||
client: getAccountClient(account.id),
|
||||
isRunningStudy: () =>
|
||||
!!accountStore.getState().runningStudyMap[account.id],
|
||||
onLog: (message: string) => appendStudyLog(message, account.id),
|
||||
onLog: (message: string, accoundID: string) =>
|
||||
appendStudyLog(message, accoundID),
|
||||
});
|
||||
if (accountStore.getState().runningStudyMap[account.id]) {
|
||||
appendStudyLog(`刷课完成:${course.name}`, account.id);
|
||||
|
||||
331
src/pages/logs/Logs.tsx
Normal file
331
src/pages/logs/Logs.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { accountStore } from "~/store/account";
|
||||
import { settingsStore } from "~/store/settings";
|
||||
|
||||
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 parseTimestampValue = (timestamp: string | null) => {
|
||||
if (!timestamp) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const [hours, minutes, seconds] = timestamp.split(":").map(Number);
|
||||
|
||||
if ([hours, minutes, seconds].some((item) => Number.isNaN(item))) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
};
|
||||
|
||||
const Logs = () => {
|
||||
const [accountState, setAccountState] = createSignal(accountStore.getState());
|
||||
const [settingsState, setSettingsState] = createSignal(
|
||||
settingsStore.getState(),
|
||||
);
|
||||
let logContainerRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribeAccount = accountStore.subscribe((state) => {
|
||||
setAccountState(state);
|
||||
});
|
||||
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
||||
setSettingsState(state);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribeAccount();
|
||||
unsubscribeSettings();
|
||||
});
|
||||
});
|
||||
|
||||
const accountInfoMap = createMemo(() => {
|
||||
return Object.fromEntries(
|
||||
accountState().accounts.map((account) => [
|
||||
account.id,
|
||||
{
|
||||
name: account.user.name,
|
||||
host: account.host,
|
||||
},
|
||||
]),
|
||||
) as Record<string, { name: string; host: string }>;
|
||||
});
|
||||
|
||||
const allLogs = createMemo(() => {
|
||||
return Object.entries(accountState().studyLogsMap)
|
||||
.flatMap(([accountId, messages]) => {
|
||||
const accountInfo = accountInfoMap()[accountId];
|
||||
const accountName = accountInfo?.name ?? accountId;
|
||||
|
||||
return messages.map((message, index) => {
|
||||
const timestamp = extractTimestamp(message);
|
||||
|
||||
return {
|
||||
id: `${accountId}-${index}`,
|
||||
accountId,
|
||||
accountName,
|
||||
index: index + 1,
|
||||
timestamp,
|
||||
timestampValue: parseTimestampValue(timestamp),
|
||||
content: stripTimestamp(message),
|
||||
raw: message,
|
||||
};
|
||||
});
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.timestampValue !== right.timestampValue) {
|
||||
return left.timestampValue - right.timestampValue;
|
||||
}
|
||||
|
||||
if (left.index !== right.index) {
|
||||
return left.index - right.index;
|
||||
}
|
||||
|
||||
return left.accountId.localeCompare(right.accountId);
|
||||
});
|
||||
});
|
||||
|
||||
const accountSummaries = createMemo(() => {
|
||||
const accountIds = Array.from(
|
||||
new Set([
|
||||
...accountState().accounts.map((account) => account.id),
|
||||
...Object.keys(accountState().studyLogsMap),
|
||||
]),
|
||||
);
|
||||
|
||||
return accountIds.map((accountId) => {
|
||||
const accountInfo = accountInfoMap()[accountId];
|
||||
const logs = accountState().studyLogsMap[accountId] ?? [];
|
||||
const latestMessage = logs[logs.length - 1] ?? "暂无日志";
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
name: accountInfo?.name ?? accountId,
|
||||
host: accountInfo?.host ?? "未知账号来源",
|
||||
total: logs.length,
|
||||
latestMessage: stripTimestamp(latestMessage),
|
||||
latestTime: extractTimestamp(latestMessage),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const latestLog = createMemo(() => {
|
||||
const logs = allLogs();
|
||||
return logs.length > 0 ? logs[logs.length - 1] : null;
|
||||
});
|
||||
|
||||
const totalAccountsWithLogs = createMemo(() => {
|
||||
return accountSummaries().filter((item) => item.total > 0).length;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
allLogs().length;
|
||||
|
||||
if (!settingsState().autoScrollLogs) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const element = logContainerRef;
|
||||
if (element) {
|
||||
element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (logContainerRef) {
|
||||
logContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
const clearAllLogs = () => {
|
||||
accountStore.getState().clearAllStudyLogs();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div class="rounded-[26px] border border-white/80 bg-[linear-gradient(135deg,rgba(255,248,235,0.98),rgba(255,255,255,0.94))] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(180,83,9,0.28)]">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="max-w-xl">
|
||||
<p class="text-[10px] font-medium tracking-[0.28em] text-amber-700/80 uppercase">
|
||||
Unified Stream
|
||||
</p>
|
||||
<h1 class="mt-1.5 text-2xl font-semibold tracking-tight text-zinc-950">
|
||||
日志中心
|
||||
</h1>
|
||||
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
|
||||
聚合全部账号日志,优先把空间留给正文。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
|
||||
<span class="text-[10px] tracking-[0.18em] text-zinc-400 uppercase">
|
||||
TOTAL LOGS
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-zinc-950">
|
||||
{allLogs().length}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
|
||||
<span class="text-[10px] tracking-[0.18em] text-zinc-400 uppercase">
|
||||
ACTIVE ACCOUNTS
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-zinc-950">
|
||||
{totalAccountsWithLogs()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
|
||||
<span class="text-[10px] tracking-[0.18em] text-zinc-400 uppercase">
|
||||
AUTO SCROLL
|
||||
</span>
|
||||
<span class="text-sm font-medium text-zinc-900">
|
||||
{settingsState().autoScrollLogs ? "跟随最新" : "手动查看"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))] px-3.5 py-1.5 text-sm font-medium text-rose-600 transition hover:-translate-y-px hover:bg-rose-50"
|
||||
onClick={clearAllLogs}
|
||||
>
|
||||
清空全部日志
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
|
||||
<section class="flex min-h-0 flex-col overflow-hidden rounded-[26px] border border-white/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.92),rgba(248,250,252,0.9))] shadow-[0_18px_48px_-30px_rgba(15,23,42,0.24)]">
|
||||
<div class="shrink-0 border-b border-zinc-200/80 px-4 py-3">
|
||||
<p class="text-base font-semibold text-zinc-950">账号概览</p>
|
||||
<p class="mt-1 text-xs text-zinc-500">最新输出与日志数量</p>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 shrink-0 flex-row gap-3 space-y-2.5 overflow-y-auto px-3 py-3">
|
||||
<For each={accountSummaries()}>
|
||||
{(account) => (
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 transition hover:shadow-md">
|
||||
{/* 顶部 */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-zinc-900">
|
||||
{account.name}
|
||||
</p>
|
||||
<p class="text-xs text-zinc-400">{account.host}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full bg-amber-100 px-2.5 py-1 text-xs text-amber-700">
|
||||
{account.total}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最新日志 */}
|
||||
<div class="mt-3 rounded-xl bg-zinc-50 p-3">
|
||||
<div class="flex justify-between text-[11px] text-zinc-400">
|
||||
<span>Latest</span>
|
||||
<span>{account.latestTime ?? "--:--"}</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 line-clamp-2 text-xs text-zinc-600">
|
||||
{account.latestMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(11,18,32,0.98),rgba(10,10,16,1))] shadow-[0_24px_60px_-32px_rgba(15,23,42,0.45)]">
|
||||
<div class="shrink-0 border-b border-white/10 px-4 py-3">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-base font-semibold text-slate-400">实时日志流</p>
|
||||
<p class="mt-1 text-xs text-slate-400">
|
||||
保留关键信息,把更多高度让给正文
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-emerald-200">
|
||||
{settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"}
|
||||
</span>
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-slate-300">
|
||||
{settingsState().autoScrollLogs
|
||||
? "自动滚动中"
|
||||
: "自动滚动关闭"}
|
||||
</span>
|
||||
<Show when={latestLog()}>
|
||||
<span class="rounded-full border border-amber-400/20 bg-amber-400/10 px-3 py-1 text-amber-200">
|
||||
最近来源 {latestLog()?.accountName}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 font-mono text-emerald-200"
|
||||
>
|
||||
<Show
|
||||
when={allLogs().length > 0}
|
||||
fallback={
|
||||
<div class="flex h-full items-center justify-center rounded-[26px] border border-dashed border-white/10 bg-white/[0.03] px-6 text-slate-500">
|
||||
暂无日志输出
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<For each={allLogs()}>
|
||||
{(log) => (
|
||||
<div class="rounded-lg px-3 py-2 transition hover:bg-white/[0.04]">
|
||||
{/* 头部 */}
|
||||
<div class="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-amber-300">{log.accountName}</span>
|
||||
<span>#{log.index}</span>
|
||||
|
||||
<Show when={settingsState().showLogTimestamps}>
|
||||
<span class="text-emerald-300/80">
|
||||
{log.timestamp}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<span class="text-slate-500">{log.accountId}</span>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<p class="mt-1 text-sm leading-5 whitespace-pre-wrap text-emerald-200">
|
||||
{log.content}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -1,7 +1,16 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
type UnauthorizedHandler = () => Promise<boolean>;
|
||||
type UnauthorizedHandler = (sessionId: string) => Promise<boolean>;
|
||||
|
||||
type SessionResolver = () => string | undefined;
|
||||
|
||||
export type HttpClient = {
|
||||
get<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
|
||||
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>;
|
||||
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>;
|
||||
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
|
||||
};
|
||||
|
||||
let unauthorizedHandler: UnauthorizedHandler | null = null;
|
||||
|
||||
@@ -9,19 +18,22 @@ export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => {
|
||||
unauthorizedHandler = handler;
|
||||
};
|
||||
|
||||
const instance = axios.create({
|
||||
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;
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const sessionId = resolveSessionId?.();
|
||||
if (sessionId) {
|
||||
config.headers["X-Session-Id"] = sessionId;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
instance.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const config = error.config as
|
||||
@@ -29,16 +41,18 @@ instance.interceptors.response.use(
|
||||
| undefined;
|
||||
const status = error.response?.status;
|
||||
const url = config?.url ?? "";
|
||||
const sessionId = resolveSessionId?.();
|
||||
|
||||
if (
|
||||
status === 401 &&
|
||||
config &&
|
||||
!config._retry &&
|
||||
unauthorizedHandler &&
|
||||
sessionId &&
|
||||
!url.includes("/api/login")
|
||||
) {
|
||||
config._retry = true;
|
||||
const ok = await unauthorizedHandler();
|
||||
const ok = await unauthorizedHandler(sessionId);
|
||||
|
||||
if (ok) {
|
||||
return instance.request(config);
|
||||
@@ -47,9 +61,9 @@ instance.interceptors.response.use(
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
const http = {
|
||||
return {
|
||||
get<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return instance.get<unknown, T>(url, config);
|
||||
},
|
||||
@@ -62,6 +76,9 @@ const http = {
|
||||
delete<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return instance.delete<unknown, T>(url, config);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const http = createHttpClient();
|
||||
|
||||
export default http;
|
||||
|
||||
@@ -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<boolean>;
|
||||
onLog?: (message: string) => void;
|
||||
client: WkClient;
|
||||
onLog?: (message: string, accoundID: string) => void;
|
||||
};
|
||||
|
||||
export type WkClient = {
|
||||
recordApi: (payload: RecordReq) => Promise<RecordRes>;
|
||||
studyApi: (payload: StudyReq) => Promise<StudyRes>;
|
||||
logoutApi: () => Promise<LogoutRes>;
|
||||
};
|
||||
|
||||
export const loginApi = async (payload: LoginReq) => {
|
||||
const res = await http.post<LoginRes>("/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<RecordRes>("/api/v2/record", payload);
|
||||
};
|
||||
const createWkClientFromHttp = (client: HttpClient): WkClient => ({
|
||||
recordApi(payload) {
|
||||
return client.post<RecordRes>("/api/v2/record", payload);
|
||||
},
|
||||
studyApi(payload) {
|
||||
return client.post<StudyRes>("/api/v2/study", payload);
|
||||
},
|
||||
logoutApi() {
|
||||
return client.post<LogoutRes>("/api/v2/logout");
|
||||
},
|
||||
});
|
||||
|
||||
export const studyApi = async (payload: StudyReq) => {
|
||||
return await http.post<StudyRes>("/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<LogoutRes>("/api/v2/logout");
|
||||
sessionStorage.removeItem("session_id");
|
||||
return res;
|
||||
};
|
||||
|
||||
export const hostApi = async () => {
|
||||
return await http.get<HostRes>("/api/v1/host");
|
||||
};
|
||||
|
||||
@@ -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<AccountState>()(
|
||||
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<AccountState>()(
|
||||
[accountId]: [],
|
||||
},
|
||||
})),
|
||||
clearAllStudyLogs: () =>
|
||||
set({
|
||||
studyLogsMap: {},
|
||||
}),
|
||||
upsertAccount: (account) =>
|
||||
set((state) => ({
|
||||
accounts: [
|
||||
|
||||
Reference in New Issue
Block a user