feat: add multi-account log center

This commit is contained in:
2026-03-27 17:55:01 +08:00
parent 45d4f2b008
commit 58d551eda6
8 changed files with 509 additions and 106 deletions

View File

@@ -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
View 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;