279 lines
9.6 KiB
TypeScript
279 lines
9.6 KiB
TypeScript
import {
|
|
createEffect,
|
|
createMemo,
|
|
createSignal,
|
|
For,
|
|
onCleanup,
|
|
onMount,
|
|
Show,
|
|
} from "solid-js";
|
|
import { accountStore } from "~/store/account";
|
|
import { settingsStore } from "~/store/settings";
|
|
|
|
type LogRow = {
|
|
id: string;
|
|
accountId: string;
|
|
accountName: string;
|
|
host: string;
|
|
seq: number;
|
|
timestamp: string;
|
|
timestampValue: number;
|
|
content: string;
|
|
};
|
|
|
|
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 normalizeContent = (message: string) => {
|
|
const value = stripTimestamp(message).replace(/\s+/g, " ").trim();
|
|
return value || "-";
|
|
};
|
|
|
|
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 logRows = createMemo<LogRow[]>(() => {
|
|
const rows = Object.entries(accountState().studyLogsMap).flatMap(
|
|
([accountId, messages]) => {
|
|
const accountInfo = accountInfoMap()[accountId];
|
|
return messages.map((message, index) => {
|
|
const timestamp = extractTimestamp(message);
|
|
return {
|
|
id: `${accountId}-${index}`,
|
|
accountId,
|
|
accountName: accountInfo?.name ?? accountId,
|
|
host: accountInfo?.host ?? "-",
|
|
seq: index + 1,
|
|
timestamp: timestamp ?? "--:--:--",
|
|
timestampValue: parseTimestampValue(timestamp),
|
|
content: normalizeContent(message),
|
|
};
|
|
});
|
|
},
|
|
);
|
|
|
|
return rows.sort((left, right) => {
|
|
const leftTs =
|
|
left.timestampValue >= 0 ? left.timestampValue : Number.MAX_SAFE_INTEGER;
|
|
const rightTs =
|
|
right.timestampValue >= 0
|
|
? right.timestampValue
|
|
: Number.MAX_SAFE_INTEGER;
|
|
|
|
if (leftTs !== rightTs) {
|
|
return leftTs - rightTs;
|
|
}
|
|
if (left.accountName !== right.accountName) {
|
|
return left.accountName.localeCompare(right.accountName, "zh-CN");
|
|
}
|
|
if (left.seq !== right.seq) {
|
|
return left.seq - right.seq;
|
|
}
|
|
return left.accountId.localeCompare(right.accountId);
|
|
});
|
|
});
|
|
|
|
const totalAccountsWithLogs = createMemo(() => {
|
|
return Object.values(accountState().studyLogsMap).filter(
|
|
(messages) => messages.length > 0,
|
|
).length;
|
|
});
|
|
|
|
const latestLog = createMemo(() => {
|
|
const rows = logRows();
|
|
return rows.length > 0 ? rows[rows.length - 1] : null;
|
|
});
|
|
|
|
createEffect(() => {
|
|
logRows().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">
|
|
Study 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">
|
|
{logRows().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>
|
|
<Show when={latestLog()}>
|
|
<div class="rounded-full border border-white/80 bg-white/85 px-3 py-1.5 text-xs text-zinc-600 shadow-sm">
|
|
最新: {latestLog()?.timestamp} / {latestLog()?.accountName}
|
|
</div>
|
|
</Show>
|
|
<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>
|
|
|
|
<section class="mt-3 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-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>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={logContainerRef}
|
|
class="min-h-0 flex-1 overflow-auto px-3 pt-0 pb-3 font-mono text-emerald-200"
|
|
>
|
|
<Show
|
|
when={logRows().length > 0}
|
|
fallback={
|
|
<div class="flex h-full items-center justify-center rounded-[26px] border border-dashed border-white/10 bg-white/3 px-6 text-slate-500">
|
|
暂无日志输出
|
|
</div>
|
|
}
|
|
>
|
|
<div class="min-w-[980px]">
|
|
<div class="sticky top-0 z-20 grid grid-cols-[100px_140px_220px_180px_80px_minmax(320px,1fr)] gap-3 border-b border-white/10 bg-zinc-950 px-3 py-2 text-[11px] tracking-wide text-slate-300 uppercase shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
|
|
<span>时间</span>
|
|
<span>姓名</span>
|
|
<span>账号</span>
|
|
<span>主机</span>
|
|
<span>序号</span>
|
|
<span>内容</span>
|
|
</div>
|
|
|
|
<div class="relative z-0">
|
|
<For each={logRows()}>
|
|
{(row) => (
|
|
<div class="grid grid-cols-[100px_140px_220px_180px_80px_minmax(320px,1fr)] items-center gap-3 border-b border-white/5 px-3 py-2 hover:bg-white/4">
|
|
<span class="truncate text-emerald-300">
|
|
{settingsState().showLogTimestamps
|
|
? row.timestamp
|
|
: "--:--:--"}
|
|
</span>
|
|
<span class="truncate text-amber-200" title={row.accountName}>
|
|
{row.accountName}
|
|
</span>
|
|
<span class="truncate text-slate-300" title={row.accountId}>
|
|
{row.accountId}
|
|
</span>
|
|
<span class="truncate text-slate-400" title={row.host}>
|
|
{row.host}
|
|
</span>
|
|
<span class="truncate text-cyan-200">#{row.seq}</span>
|
|
<span class="truncate text-emerald-100" title={row.content}>
|
|
{row.content}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Logs;
|