release: v0.1.3

This commit is contained in:
2026-04-03 14:20:26 +08:00
parent 1396592141
commit 13f0be162b
10 changed files with 520 additions and 79 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.1.2", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -6,10 +6,12 @@ import {
createMemo, createMemo,
createResource, createResource,
createSignal, createSignal,
onMount,
onCleanup, onCleanup,
} from "solid-js"; } from "solid-js";
import { A, useLocation } from "@solidjs/router"; import { A, useLocation } from "@solidjs/router";
import Dialog from "~/components/dialog/Dialog"; import Dialog from "~/components/dialog/Dialog";
import { updateDebugConfig } from "~/service/debugLog";
import { import {
RELEASES_PAGE_URL, RELEASES_PAGE_URL,
type LatestRelease, type LatestRelease,
@@ -23,6 +25,7 @@ import {
resolveReleaseLink, resolveReleaseLink,
} from "~/service/update"; } from "~/service/update";
import { versionApi } from "~/service/wk"; import { versionApi } from "~/service/wk";
import { settingsStore } from "~/store/settings";
type DownloadState = "idle" | "downloading" | "done" | "error"; type DownloadState = "idle" | "downloading" | "done" | "error";
type UpdateCheckState = type UpdateCheckState =
@@ -155,7 +158,9 @@ const App: ParentComponent = (props) => {
const [downloadState, setDownloadState] = createSignal<DownloadState>("idle"); const [downloadState, setDownloadState] = createSignal<DownloadState>("idle");
const [downloadProgress, setDownloadProgress] = createSignal(0); const [downloadProgress, setDownloadProgress] = createSignal(0);
const [downloadError, setDownloadError] = createSignal(""); const [downloadError, setDownloadError] = createSignal("");
const [settingsState, setSettingsState] = createSignal(settingsStore.getState());
let updateAbortController: AbortController | null = null; let updateAbortController: AbortController | null = null;
let lastDebugSyncValue: boolean | undefined;
const isActive = (url: string) => const isActive = (url: string) =>
location.pathname === url || location.pathname === url ||
@@ -170,9 +175,7 @@ const App: ParentComponent = (props) => {
const authorText = createMemo(() => version()?.data.GitAuthor ?? "unknown"); const authorText = createMemo(() => version()?.data.GitAuthor ?? "unknown");
const emailText = createMemo(() => version()?.data.GitEmail ?? "unknown"); const emailText = createMemo(() => version()?.data.GitEmail ?? "unknown");
const modeText = createMemo(() => version()?.data.Mode ?? "unknown"); const modeText = createMemo(() => version()?.data.Mode ?? "unknown");
const isDebugMode = createMemo( const isDebugMode = createMemo(() => settingsState().debugEnabled);
() => modeText().toLowerCase() === "debug",
);
const asideList = createMemo(() => { const asideList = createMemo(() => {
const items = [ const items = [
{ label: "账号", url: "/account" }, { label: "账号", url: "/account" },
@@ -229,6 +232,16 @@ const App: ParentComponent = (props) => {
() => latestRelease()?.html_url || RELEASES_PAGE_URL, () => latestRelease()?.html_url || RELEASES_PAGE_URL,
); );
onMount(() => {
const unsubscribe = settingsStore.subscribe((state) => {
setSettingsState(state);
});
onCleanup(() => {
unsubscribe();
});
});
const handleCopyVersion = async () => { const handleCopyVersion = async () => {
try { try {
await navigator.clipboard.writeText(versionPayloadText()); await navigator.clipboard.writeText(versionPayloadText());
@@ -322,6 +335,17 @@ const App: ParentComponent = (props) => {
void performUpdateCheck(false); void performUpdateCheck(false);
}); });
createEffect(() => {
const enabled = settingsState().debugEnabled;
if (lastDebugSyncValue === enabled) {
return;
}
lastDebugSyncValue = enabled;
void updateDebugConfig(enabled).catch(() => {
// Keep the local preference and let settings page surface sync errors.
});
});
onCleanup(() => { onCleanup(() => {
if (updateAbortController) { if (updateAbortController) {
updateAbortController.abort(); updateAbortController.abort();
@@ -404,6 +428,9 @@ const App: ParentComponent = (props) => {
<p class="mt-1 text-xs text-zinc-500"> <p class="mt-1 text-xs text-zinc-500">
: {modeText()} : {modeText()}
</p> </p>
<p class="mt-1 text-xs text-zinc-500">
: {isDebugMode() ? "已开启" : "已关闭"}
</p>
<p class="mt-3 text-xs font-medium tracking-[0.18em] text-cyan-700/75 uppercase"> <p class="mt-3 text-xs font-medium tracking-[0.18em] text-cyan-700/75 uppercase">
Runtime Runtime
</p> </p>

View File

@@ -14,11 +14,11 @@ interface AccountSidebarProps {
statusOptions: StatusOption[]; statusOptions: StatusOption[];
currentCourseKind: CourseKind; currentCourseKind: CourseKind;
hostLabels: Record<string, string>; hostLabels: Record<string, string>;
isRefreshingAccount: boolean; refreshingAccountId: string;
loggingOutId: string; loggingOutId: string;
densityMode: "comfortable" | "compact"; densityMode: "comfortable" | "compact";
sidebarWidth: number; sidebarWidth: number;
onRefreshAccount: () => void; onRefreshAccount: (accountId: string) => void;
onSelectAccount: (accountId: string) => void; onSelectAccount: (accountId: string) => void;
onToggleExpand: (accountId: string) => void; onToggleExpand: (accountId: string) => void;
onLogout: (accountId: string) => void; onLogout: (accountId: string) => void;
@@ -42,15 +42,6 @@ const AccountSidebar = (props: AccountSidebarProps) => {
</p> </p>
</div> </div>
<button
type="button"
class="self-start rounded-xl border border-zinc-200 bg-white px-3 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 sm:self-auto sm:text-sm"
disabled={!props.selectedAccountId || props.isRefreshingAccount}
onClick={props.onRefreshAccount}
>
{props.isRefreshingAccount ? "刷新中..." : "刷新账号"}
</button>
</div> </div>
<div <div
@@ -81,6 +72,7 @@ const AccountSidebar = (props: AccountSidebarProps) => {
: `登录类型:${statusLabel}`; : `登录类型:${statusLabel}`;
const badgeTypeLabel = selected() ? currentCourseLabel : statusLabel; const badgeTypeLabel = selected() ? currentCourseLabel : statusLabel;
const badgeCountLabel = `${account.courses.length}`; const badgeCountLabel = `${account.courses.length}`;
const isRefreshing = () => props.refreshingAccountId === account.id;
return ( return (
<div <div
@@ -145,6 +137,17 @@ const AccountSidebar = (props: AccountSidebarProps) => {
<p class="sm:col-span-2">{courseTypeLabel}</p> <p class="sm:col-span-2">{courseTypeLabel}</p>
<div class="mt-1.5 flex flex-wrap gap-1.5 sm:col-span-2"> <div class="mt-1.5 flex flex-wrap gap-1.5 sm:col-span-2">
<button
type="button"
class="rounded-xl border border-emerald-200 bg-white px-2.5 py-1 text-xs text-emerald-700 transition hover:bg-emerald-50 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isRefreshing()}
onClick={(event) => {
event.stopPropagation();
props.onRefreshAccount(account.id);
}}
>
{isRefreshing() ? "刷新中..." : "刷新账号"}
</button>
<button <button
type="button" type="button"
class="rounded-xl border border-cyan-200 bg-white px-2.5 py-1 text-xs text-cyan-700 transition hover:bg-cyan-50" class="rounded-xl border border-cyan-200 bg-white px-2.5 py-1 text-xs text-cyan-700 transition hover:bg-cyan-50"

View File

@@ -13,6 +13,7 @@ import AddAccountDialog, {
import CourseWorkspace from "~/components/account/CourseWorkspace"; import CourseWorkspace from "~/components/account/CourseWorkspace";
import { import {
createWkClient, createWkClient,
createSessionWkClient,
hostApi, hostApi,
loginApi, loginApi,
runStudyQueue, runStudyQueue,
@@ -93,7 +94,7 @@ const Account = () => {
const [showDialog, setShowDialog] = createSignal(false); const [showDialog, setShowDialog] = createSignal(false);
const [isSubmitting, setIsSubmitting] = createSignal(false); const [isSubmitting, setIsSubmitting] = createSignal(false);
const [loggingOutId, setLoggingOutId] = createSignal(""); const [loggingOutId, setLoggingOutId] = createSignal("");
const [isRefreshingAccount, setIsRefreshingAccount] = createSignal(false); const [refreshingAccountId, setRefreshingAccountId] = createSignal("");
const [errorMessage, setErrorMessage] = createSignal(""); const [errorMessage, setErrorMessage] = createSignal("");
const [form, setForm] = createSignal<LoginForm>( const [form, setForm] = createSignal<LoginForm>(
createDefaultForm("cqcst.leykeji.com"), createDefaultForm("cqcst.leykeji.com"),
@@ -300,6 +301,12 @@ const Account = () => {
return client; return client;
}; };
const fetchUserInfoBySession = async (sessionId: string) => {
const client = createSessionWkClient(sessionId);
const res = await client.userInfoApi();
return res.data.user;
};
const reloginBySession = async (sessionId: string) => { const reloginBySession = async (sessionId: string) => {
if (!sessionId) { if (!sessionId) {
return false; return false;
@@ -321,14 +328,14 @@ const Account = () => {
status: target.status, status: target.status,
host: target.host, host: target.host,
}); });
const user = await fetchUserInfoBySession(res.data.session_id);
accountStore.getState().upsertAccount({ replaceAccountPreservingView({
...target, ...target,
sessionId: res.data.session_id, sessionId: res.data.session_id,
user: res.data.user, user,
courses: res.data.courses ?? target.courses, courses: target.courses,
}); });
await loadCourses(target.id, target.status);
appendStudyLog(`会话失效,已重新登录:${target.user.name}`, target.id); appendStudyLog(`会话失效,已重新登录:${target.user.name}`, target.id);
return true; return true;
} catch (error) { } catch (error) {
@@ -376,11 +383,11 @@ const Account = () => {
status: payload.status, status: payload.status,
host: payload.host, host: payload.host,
}); });
const user = await fetchUserInfoBySession(res.data.session_id);
const accountId = `${res.data.user.id}-${payload.host}-${payload.status}`; const accountId = `${user.id}-${payload.host}-${payload.status}`;
const nextAccount: AccountItem = { const nextAccount: AccountItem = {
id: accountId, id: accountId,
username: payload.username.trim() || res.data.user.id, username: payload.username.trim() || user.id,
host: payload.host, host: payload.host,
status: payload.status, status: payload.status,
sessionId: res.data.session_id, sessionId: res.data.session_id,
@@ -388,12 +395,11 @@ const Account = () => {
password: payload.password.trim(), password: payload.password.trim(),
token: payload.token.trim(), token: payload.token.trim(),
}, },
user: res.data.user, user,
courses: res.data.courses ?? [], courses: [],
}; };
accountStore.getState().upsertAccount(nextAccount); accountStore.getState().upsertAccount(nextAccount);
await loadCourses(nextAccount.id, nextAccount.status);
accountStore.getState().setSelectedCourseId(null); accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]); accountStore.getState().setRecords([]);
setShowDialog(false); setShowDialog(false);
@@ -420,6 +426,24 @@ const Account = () => {
accountStore.getState().setExpandedAccountId(nextId); accountStore.getState().setExpandedAccountId(nextId);
}; };
const replaceAccountPreservingView = (nextAccount: AccountItem) => {
const snapshot = accountStore.getState();
const previousSelectedAccountId = snapshot.selectedAccountId;
const previousExpandedAccountId = snapshot.expandedAccountId;
const previousSelectedCourseId = snapshot.selectedCourseId;
snapshot.upsertAccount(nextAccount);
if (previousSelectedAccountId !== nextAccount.id) {
snapshot.setSelectedAccountId(previousSelectedAccountId);
snapshot.setSelectedCourseId(previousSelectedCourseId);
}
if (previousExpandedAccountId !== nextAccount.id) {
snapshot.setExpandedAccountId(previousExpandedAccountId);
}
};
const loadCourses = async ( const loadCourses = async (
accountId = selectedAccount()?.id, accountId = selectedAccount()?.id,
status = courseKind(), status = courseKind(),
@@ -465,14 +489,14 @@ const Account = () => {
} }
}; };
const handleRefreshAccount = async () => { const handleRefreshAccount = async (accountId: string) => {
const account = selectedAccount(); const account = accounts().find((item) => item.id === accountId);
if (!account) { if (!account) {
setErrorMessage("请先选择账号。"); setErrorMessage("请先选择账号。");
return; return;
} }
setIsRefreshingAccount(true); setRefreshingAccountId(accountId);
setErrorMessage(""); setErrorMessage("");
try { try {
@@ -483,20 +507,21 @@ const Account = () => {
status: account.status, status: account.status,
host: account.host, host: account.host,
}); });
const user = await fetchUserInfoBySession(res.data.session_id);
accountStore.getState().upsertAccount({ replaceAccountPreservingView({
...account, ...account,
sessionId: res.data.session_id, sessionId: res.data.session_id,
user: res.data.user, user,
courses: res.data.courses ?? account.courses, courses: account.courses,
}); });
await loadCourses(account.id, account.status); await loadCourses(accountId, account.status);
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "刷新账号失败,请稍后重试。"; error instanceof Error ? error.message : "刷新账号失败,请稍后重试。";
setErrorMessage(message); setErrorMessage(message);
} finally { } finally {
setIsRefreshingAccount(false); setRefreshingAccountId("");
} }
}; };
@@ -771,11 +796,11 @@ const Account = () => {
statusOptions={statusOptions} statusOptions={statusOptions}
currentCourseKind={courseKind()} currentCourseKind={courseKind()}
hostLabels={hostLabels()} hostLabels={hostLabels()}
isRefreshingAccount={isRefreshingAccount()} refreshingAccountId={refreshingAccountId()}
loggingOutId={loggingOutId()} loggingOutId={loggingOutId()}
densityMode={settingsState().densityMode} densityMode={settingsState().densityMode}
sidebarWidth={settingsState().sidebarWidth} sidebarWidth={settingsState().sidebarWidth}
onRefreshAccount={() => void handleRefreshAccount()} onRefreshAccount={(accountId) => void handleRefreshAccount(accountId)}
onSelectAccount={handleSelectAccount} onSelectAccount={handleSelectAccount}
onToggleExpand={handleToggleExpand} onToggleExpand={handleToggleExpand}
onLogout={(accountId) => void handleLogout(accountId)} onLogout={(accountId) => void handleLogout(accountId)}

View File

@@ -1,7 +1,6 @@
import { import {
createEffect, createEffect,
createMemo, createMemo,
createResource,
createSignal, createSignal,
For, For,
onCleanup, onCleanup,
@@ -9,7 +8,6 @@ import {
Show, Show,
} from "solid-js"; } from "solid-js";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { versionApi } from "~/service/wk";
import { import {
fetchDebugLogSnapshot, fetchDebugLogSnapshot,
resolveDebugLogDownloadUrl, resolveDebugLogDownloadUrl,
@@ -19,6 +17,7 @@ import {
import { settingsStore } from "~/store/settings"; import { settingsStore } from "~/store/settings";
type DebugSocketState = "connecting" | "open" | "closed" | "error"; type DebugSocketState = "connecting" | "open" | "closed" | "error";
type DebugDetailTab = "overview" | "request" | "response" | "raw";
const MAX_DEBUG_ENTRIES = 1000; const MAX_DEBUG_ENTRIES = 1000;
@@ -44,18 +43,145 @@ const stringifyDebugFields = (fields?: Record<string, unknown>) => {
return JSON.stringify(fields, null, 2); return JSON.stringify(fields, null, 2);
}; };
const summarizeDebugFields = (fields?: Record<string, unknown>) => { const stringifyDebugValue = (value: unknown) => {
const value = stringifyDebugFields(fields); if (
if (!value) { value === null ||
return "-"; value === undefined ||
(typeof value === "string" && value.trim() === "")
) {
return "";
} }
return value.replace(/\s+/g, " ").slice(0, 180); if (typeof value === "string") {
return value;
}
return JSON.stringify(value, null, 2);
};
const getField = (value: unknown, path: string[]) => {
let current = value;
for (const key of path) {
if (!current || typeof current !== "object" || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
};
const joinPathAndQuery = (path: string, query: string) => {
if (!path) {
return query ? `?${query}` : "";
}
return query ? `${path}?${query}` : path;
};
const resolveEntrySummary = (entry: DebugLogEntry) => {
const fields = entry.fields;
const requestUri = stringifyDebugValue(getField(fields, ["request", "uri"]));
if (requestUri) {
return requestUri;
}
const requestHost = stringifyDebugValue(getField(fields, ["request", "host"]));
if (requestHost) {
return requestHost;
}
const path = stringifyDebugValue(fields?.path);
const rawQuery = stringifyDebugValue(fields?.rawQuery);
const url = joinPathAndQuery(path, rawQuery);
if (url) {
return url;
}
return entry.message || "-";
};
const resolveEntryMethod = (entry: DebugLogEntry) => {
return (
stringifyDebugValue(getField(entry.fields, ["request", "method"])) ||
stringifyDebugValue(entry.fields?.method) ||
"-"
);
};
const resolveRequestMeta = (entry: DebugLogEntry) => {
const fields = entry.fields;
const path = stringifyDebugValue(fields?.path);
const rawQuery = stringifyDebugValue(fields?.rawQuery);
const url = resolveEntrySummary(entry);
return {
method: resolveEntryMethod(entry),
url,
host: stringifyDebugValue(getField(fields, ["request", "host"])),
path: path ? joinPathAndQuery(path, rawQuery) : "",
proto:
stringifyDebugValue(getField(fields, ["request", "proto"])) ||
stringifyDebugValue(fields?.proto),
attempt: stringifyDebugValue(getField(fields, ["request", "attempt"])),
clientIP: stringifyDebugValue(fields?.clientIP),
handler: stringifyDebugValue(fields?.handler),
headers:
stringifyDebugValue(getField(fields, ["request", "header"])) ||
stringifyDebugValue(fields?.requestHeader),
body:
stringifyDebugValue(getField(fields, ["request", "body"])) ||
stringifyDebugValue(fields?.requestBody),
};
};
const resolveResponseMeta = (entry: DebugLogEntry) => {
const fields = entry.fields;
return {
status:
stringifyDebugValue(getField(fields, ["response", "status"])) ||
stringifyDebugValue(fields?.status),
statusCode:
stringifyDebugValue(getField(fields, ["response", "statusCode"])) ||
stringifyDebugValue(fields?.status),
proto: stringifyDebugValue(getField(fields, ["response", "proto"])),
durationMs:
stringifyDebugValue(getField(fields, ["response", "durationMs"])) ||
stringifyDebugValue(fields?.durationMs),
size:
stringifyDebugValue(getField(fields, ["response", "size"])) ||
stringifyDebugValue(fields?.responseSize),
receivedAt: stringifyDebugValue(getField(fields, ["response", "receivedAt"])),
abortWithErrors: stringifyDebugValue(fields?.abortWithErrors),
headers:
stringifyDebugValue(getField(fields, ["response", "header"])) ||
stringifyDebugValue(fields?.responseHeader),
body:
stringifyDebugValue(getField(fields, ["response", "body"])) ||
stringifyDebugValue(fields?.responseBody),
};
};
const detailTabs: { id: DebugDetailTab; label: string }[] = [
{ id: "overview", label: "概览" },
{ id: "request", label: "请求" },
{ id: "response", label: "响应" },
{ id: "raw", label: "原始字段" },
];
const DetailCodeBlock = (props: { title: string; value: string }) => {
return (
<div class="rounded-[18px] border border-white/10 bg-zinc-950/90 p-3">
<p class="mb-2 text-xs font-medium tracking-[0.2em] text-slate-400 uppercase">
{props.title}
</p>
<pre class="whitespace-pre-wrap break-all text-xs leading-6 text-emerald-200">
{props.value || "-"}
</pre>
</div>
);
}; };
const DebugLogs = () => { const DebugLogs = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [version] = createResource(versionApi);
const [settingsState, setSettingsState] = createSignal( const [settingsState, setSettingsState] = createSignal(
settingsStore.getState(), settingsStore.getState(),
); );
@@ -64,6 +190,8 @@ const DebugLogs = () => {
createSignal<DebugSocketState>("connecting"); createSignal<DebugSocketState>("connecting");
const [debugError, setDebugError] = createSignal(""); const [debugError, setDebugError] = createSignal("");
const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal(0); const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal(0);
const [selectedDetailTab, setSelectedDetailTab] =
createSignal<DebugDetailTab>("overview");
let debugLogContainerRef: HTMLDivElement | undefined; let debugLogContainerRef: HTMLDivElement | undefined;
let debugSocket: WebSocket | null = null; let debugSocket: WebSocket | null = null;
let reconnectTimer: number | undefined; let reconnectTimer: number | undefined;
@@ -71,7 +199,7 @@ const DebugLogs = () => {
const debugEntryKeySet = new Set<number>(); const debugEntryKeySet = new Set<number>();
const isDebugMode = createMemo(() => { const isDebugMode = createMemo(() => {
return (version()?.data.Mode ?? "").toLowerCase() === "debug"; return settingsState().debugEnabled;
}); });
const latestDebugEntry = createMemo(() => { const latestDebugEntry = createMemo(() => {
const rows = debugEntries(); const rows = debugEntries();
@@ -81,6 +209,14 @@ const DebugLogs = () => {
const currentId = selectedDebugEntryId(); const currentId = selectedDebugEntryId();
return debugEntries().find((item) => item.id === currentId) ?? null; return debugEntries().find((item) => item.id === currentId) ?? null;
}); });
const selectedRequestMeta = createMemo(() => {
const entry = selectedDebugEntry();
return entry ? resolveRequestMeta(entry) : null;
});
const selectedResponseMeta = createMemo(() => {
const entry = selectedDebugEntry();
return entry ? resolveResponseMeta(entry) : null;
});
const debugSourceCount = createMemo(() => { const debugSourceCount = createMemo(() => {
return new Set(debugEntries().map((item) => item.source)).size; return new Set(debugEntries().map((item) => item.source)).size;
}); });
@@ -196,7 +332,7 @@ const DebugLogs = () => {
socket.addEventListener("error", () => { socket.addEventListener("error", () => {
setDebugSocketState("error"); setDebugSocketState("error");
setDebugError("调试日志流连接失败,请确认后端处于 debug 模式。"); setDebugError("调试日志流连接失败,请确认设置页中的调试开关已开启。");
}); });
socket.addEventListener("close", () => { socket.addEventListener("close", () => {
@@ -228,10 +364,6 @@ const DebugLogs = () => {
}); });
createEffect(() => { createEffect(() => {
if (version.loading) {
return;
}
if (!isDebugMode()) { if (!isDebugMode()) {
disconnectDebugSocket(); disconnectDebugSocket();
resetDebugEntries(); resetDebugEntries();
@@ -264,6 +396,11 @@ const DebugLogs = () => {
} }
}); });
createEffect(() => {
selectedDebugEntryId();
setSelectedDetailTab("overview");
});
const clearDebugLogs = () => { const clearDebugLogs = () => {
resetDebugEntries(); resetDebugEntries();
}; };
@@ -284,7 +421,7 @@ const DebugLogs = () => {
</h1> </h1>
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm"> <p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
debug
</p> </p>
</div> </div>
@@ -313,7 +450,7 @@ const DebugLogs = () => {
when={isDebugMode()} when={isDebugMode()}
fallback={ fallback={
<div class="mt-3 flex flex-1 items-center justify-center rounded-[26px] border border-dashed border-zinc-300 bg-white/60 px-6 text-zinc-500"> <div class="mt-3 flex flex-1 items-center justify-center rounded-[26px] border border-dashed border-zinc-300 bg-white/60 px-6 text-zinc-500">
debug
</div> </div>
} }
> >
@@ -384,7 +521,7 @@ const DebugLogs = () => {
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span>URL / Path</span>
</div> </div>
<div <div
ref={debugLogContainerRef} ref={debugLogContainerRef}
@@ -420,9 +557,9 @@ const DebugLogs = () => {
</span> </span>
<span <span
class="truncate text-slate-400" class="truncate text-slate-400"
title={stringifyDebugFields(entry.fields)} title={resolveEntrySummary(entry)}
> >
{summarizeDebugFields(entry.fields)} {resolveEntrySummary(entry)}
</span> </span>
</button> </button>
)} )}
@@ -436,7 +573,7 @@ const DebugLogs = () => {
<div class="border-b border-white/10 px-4 py-3"> <div class="border-b border-white/10 px-4 py-3">
<p class="text-sm font-semibold text-white"></p> <p class="text-sm font-semibold text-white"></p>
<p class="mt-1 text-xs text-slate-400"> <p class="mt-1 text-xs text-slate-400">
</p> </p>
</div> </div>
<div class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-sm text-slate-200"> <div class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-sm text-slate-200">
@@ -445,12 +582,40 @@ const DebugLogs = () => {
fallback={<p class="text-slate-500"></p>} fallback={<p class="text-slate-500"></p>}
> >
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-wrap gap-2">
<For each={detailTabs}>
{(tab) => (
<button
type="button"
class={`rounded-full border px-3 py-1.5 text-xs transition ${
selectedDetailTab() === tab.id
? "border-cyan-300/35 bg-cyan-400/12 text-cyan-100"
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10"
}`}
onClick={() => setSelectedDetailTab(tab.id)}
>
{tab.label}
</button>
)}
</For>
</div>
<Show when={selectedDetailTab() === "overview"}>
<div class="grid gap-3">
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300"> <div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
<p>ID: {selectedDebugEntry()?.id}</p> <p>ID: {selectedDebugEntry()?.id}</p>
<p>: {selectedDebugEntry()?.time}</p> <p>: {selectedDebugEntry()?.time}</p>
<p>: {selectedDebugEntry()?.source}</p> <p>: {selectedDebugEntry()?.source}</p>
<p>: {selectedDebugEntry()?.level}</p> <p>: {selectedDebugEntry()?.level}</p>
<p>: {selectedDebugEntry()?.message}</p> <p>: {selectedDebugEntry()?.message}</p>
<p>URL / Path: {resolveEntrySummary(selectedDebugEntry()!)}</p>
<p>Method: {selectedRequestMeta()?.method || "-"}</p>
<Show when={selectedResponseMeta()?.status}>
<p>: {selectedResponseMeta()?.status}</p>
</Show>
<Show when={selectedResponseMeta()?.durationMs}>
<p>: {selectedResponseMeta()?.durationMs} ms</p>
</Show>
<Show when={selectedDebugEntry()?.caller}> <Show when={selectedDebugEntry()?.caller}>
<p>: {selectedDebugEntry()?.caller}</p> <p>: {selectedDebugEntry()?.caller}</p>
</Show> </Show>
@@ -458,11 +623,84 @@ const DebugLogs = () => {
<p>Logger: {selectedDebugEntry()?.logger}</p> <p>Logger: {selectedDebugEntry()?.logger}</p>
</Show> </Show>
</div> </div>
<div class="rounded-[18px] border border-white/10 bg-zinc-950/90 p-3">
<pre class="whitespace-pre-wrap break-all text-xs leading-6 text-emerald-200">
{stringifyDebugFields(selectedDebugEntry()?.fields)}
</pre>
</div> </div>
</Show>
<Show when={selectedDetailTab() === "request"}>
<div class="space-y-3">
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
<p>Method: {selectedRequestMeta()?.method || "-"}</p>
<p>URL: {selectedRequestMeta()?.url || "-"}</p>
<Show when={selectedRequestMeta()?.path}>
<p>Path: {selectedRequestMeta()?.path}</p>
</Show>
<Show when={selectedRequestMeta()?.host}>
<p>Host: {selectedRequestMeta()?.host}</p>
</Show>
<Show when={selectedRequestMeta()?.proto}>
<p>Protocol: {selectedRequestMeta()?.proto}</p>
</Show>
<Show when={selectedRequestMeta()?.attempt}>
<p>Attempt: {selectedRequestMeta()?.attempt}</p>
</Show>
<Show when={selectedRequestMeta()?.clientIP}>
<p>Client IP: {selectedRequestMeta()?.clientIP}</p>
</Show>
<Show when={selectedRequestMeta()?.handler}>
<p>Handler: {selectedRequestMeta()?.handler}</p>
</Show>
</div>
<DetailCodeBlock
title="请求头"
value={selectedRequestMeta()?.headers || ""}
/>
<DetailCodeBlock
title="请求体"
value={selectedRequestMeta()?.body || ""}
/>
</div>
</Show>
<Show when={selectedDetailTab() === "response"}>
<div class="space-y-3">
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
<p>: {selectedResponseMeta()?.status || "-"}</p>
<Show when={selectedResponseMeta()?.statusCode}>
<p>Status Code: {selectedResponseMeta()?.statusCode}</p>
</Show>
<Show when={selectedResponseMeta()?.proto}>
<p>Protocol: {selectedResponseMeta()?.proto}</p>
</Show>
<Show when={selectedResponseMeta()?.durationMs}>
<p>: {selectedResponseMeta()?.durationMs} ms</p>
</Show>
<Show when={selectedResponseMeta()?.size}>
<p>: {selectedResponseMeta()?.size}</p>
</Show>
<Show when={selectedResponseMeta()?.receivedAt}>
<p>: {selectedResponseMeta()?.receivedAt}</p>
</Show>
<Show when={selectedResponseMeta()?.abortWithErrors}>
<p>: {selectedResponseMeta()?.abortWithErrors}</p>
</Show>
</div>
<DetailCodeBlock
title="响应头"
value={selectedResponseMeta()?.headers || ""}
/>
<DetailCodeBlock
title="响应体"
value={selectedResponseMeta()?.body || ""}
/>
</div>
</Show>
<Show when={selectedDetailTab() === "raw"}>
<DetailCodeBlock
title="原始字段"
value={stringifyDebugFields(selectedDebugEntry()?.fields)}
/>
</Show>
</div> </div>
</Show> </Show>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"; import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js";
import { fetchDebugConfig, updateDebugConfig } from "~/service/debugLog";
import { hostApi } from "~/service/wk"; import { hostApi } from "~/service/wk";
import { getMergedHosts, settingsStore } from "~/store/settings"; import { getMergedHosts, settingsStore } from "~/store/settings";
@@ -8,6 +9,15 @@ const Setting = () => {
const [hostValue, setHostValue] = createSignal(""); const [hostValue, setHostValue] = createSignal("");
const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false); const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false);
const [hostError, setHostError] = createSignal(""); const [hostError, setHostError] = createSignal("");
const [debugSyncing, setDebugSyncing] = createSignal(false);
const [debugError, setDebugError] = createSignal("");
const [backendDebugState, setBackendDebugState] = createSignal<{
enabled: boolean;
proxy: string;
skipSSLVerify: boolean;
buildMode: string;
proxyConfigured: boolean;
} | null>(null);
onMount(() => { onMount(() => {
const unsubscribe = settingsStore.subscribe((nextState) => { const unsubscribe = settingsStore.subscribe((nextState) => {
@@ -44,6 +54,49 @@ const Setting = () => {
} }
}; };
const refreshDebugConfig = async () => {
try {
const res = await fetchDebugConfig();
setBackendDebugState({
enabled: res.data.enabled,
proxy: res.data.proxy,
skipSSLVerify: res.data.skip_ssl_verify,
buildMode: res.data.build_mode,
proxyConfigured: res.data.proxy_configured,
});
setDebugError("");
} catch (error) {
const message =
error instanceof Error ? error.message : "获取调试配置失败。";
setDebugError(message);
}
};
const handleDebugToggle = async (enabled: boolean) => {
const previous = state().debugEnabled;
settingsStore.getState().setDebugEnabled(enabled);
setDebugSyncing(true);
setDebugError("");
try {
const res = await updateDebugConfig(enabled);
setBackendDebugState({
enabled: res.data.enabled,
proxy: res.data.proxy,
skipSSLVerify: res.data.skip_ssl_verify,
buildMode: res.data.build_mode,
proxyConfigured: res.data.proxy_configured,
});
} catch (error) {
settingsStore.getState().setDebugEnabled(previous);
const message =
error instanceof Error ? error.message : "更新调试开关失败。";
setDebugError(message);
} finally {
setDebugSyncing(false);
}
};
const addLocalHost = () => { const addLocalHost = () => {
if (!hostLabel().trim() || !hostValue().trim()) { if (!hostLabel().trim() || !hostValue().trim()) {
return; return;
@@ -61,6 +114,7 @@ const Setting = () => {
if (state().remoteHosts.length === 0) { if (state().remoteHosts.length === 0) {
void loadRemoteHosts(); void loadRemoteHosts();
} }
void refreshDebugConfig();
}); });
return ( return (
@@ -208,6 +262,46 @@ const Setting = () => {
</div> </div>
<div class="mt-4 grid gap-4"> <div class="mt-4 grid gap-4">
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
/ SSL
</p>
</div>
<input
type="checkbox"
checked={state().debugEnabled}
disabled={debugSyncing()}
onChange={(event) =>
void handleDebugToggle(event.currentTarget.checked)
}
/>
</div>
<div class="mt-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600">
<p>{backendDebugState()?.enabled ? "已开启" : "已关闭"}</p>
<p class="mt-1">
{backendDebugState()?.buildMode ?? "-"}
</p>
<p class="mt-1">
{backendDebugState()?.proxyConfigured
? backendDebugState()?.proxy
: "未配置"}
</p>
<p class="mt-1">
SSL
{backendDebugState()?.skipSSLVerify ? "已配置" : "未配置"}
</p>
</div>
{debugError() ? (
<div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{debugError()}
</div>
) : null}
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4"> <div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div> <div>

View File

@@ -1,3 +1,5 @@
import http from "~/service/http";
export type DebugLogEntry = { export type DebugLogEntry = {
id: number; id: number;
time: string; time: string;
@@ -17,6 +19,18 @@ type DebugLogListResponse = {
}; };
}; };
type DebugConfigResponse = {
code: number;
message: string;
data: {
enabled: boolean;
proxy: string;
skip_ssl_verify: boolean;
build_mode: string;
proxy_configured: boolean;
};
};
const toWsProtocol = (protocol: string) => { const toWsProtocol = (protocol: string) => {
return protocol === "https:" ? "wss:" : "ws:"; return protocol === "https:" ? "wss:" : "ws:";
}; };
@@ -61,3 +75,13 @@ export const fetchDebugLogSnapshot = async () => {
const payload = (await response.json()) as DebugLogListResponse; const payload = (await response.json()) as DebugLogListResponse;
return payload.data.list ?? []; return payload.data.list ?? [];
}; };
export const fetchDebugConfig = async () => {
return await http.get<DebugConfigResponse>("/api/debug/config");
};
export const updateDebugConfig = async (enabled: boolean) => {
return await http.post<DebugConfigResponse>("/api/debug/config", {
enabled,
});
};

View File

@@ -5,6 +5,8 @@ type UnauthorizedHandler = (sessionId: string) => Promise<boolean>;
type SessionResolver = () => string | undefined; type SessionResolver = () => string | undefined;
const DEFAULT_HTTP_TIMEOUT_MS = 15000;
export type HttpClient = { export type HttpClient = {
get<T>(url: string, config?: AxiosRequestConfig): Promise<T>; get<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>; post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>;
@@ -46,7 +48,7 @@ export const createHttpClient = (
): HttpClient => { ): HttpClient => {
const instance = axios.create({ const instance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL, baseURL: import.meta.env.VITE_BASE_URL,
timeout: 15000, timeout: DEFAULT_HTTP_TIMEOUT_MS,
}); });
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
@@ -106,3 +108,4 @@ export const createHttpClient = (
const http = createHttpClient(); const http = createHttpClient();
export default http; export default http;
export { DEFAULT_HTTP_TIMEOUT_MS };

View File

@@ -1,5 +1,9 @@
import type { Accessor } from "solid-js"; import type { Accessor } from "solid-js";
import http, { createHttpClient, type HttpClient } from "~/service/http"; import http, {
DEFAULT_HTTP_TIMEOUT_MS,
createHttpClient,
type HttpClient,
} from "~/service/http";
import type { CourseType } from "~/types/Course"; import type { CourseType } from "~/types/Course";
import type { userInfoType } from "~/types/Userinfo"; import type { userInfoType } from "~/types/Userinfo";
@@ -24,9 +28,7 @@ export type LoginReq = {
}; };
export type LoginData = { export type LoginData = {
courses?: CourseType[];
session_id: string; session_id: string;
user: userInfoType;
}; };
export type LoginRes = ApiResponse<LoginData>; export type LoginRes = ApiResponse<LoginData>;
@@ -130,6 +132,12 @@ export type CourseData = {
export type CourseRes = ApiResponse<CourseData>; export type CourseRes = ApiResponse<CourseData>;
export type UserInfoData = {
user: userInfoType;
};
export type UserInfoRes = ApiResponse<UserInfoData>;
export type StudyReq = { export type StudyReq = {
node_id: string; node_id: string;
study_id: string; study_id: string;
@@ -158,23 +166,34 @@ export type StudyRunnerPayload = {
}; };
export type WkClient = { export type WkClient = {
userInfoApi: () => Promise<UserInfoRes>;
courseApi: (payload: CourseReq) => Promise<CourseRes>; courseApi: (payload: CourseReq) => Promise<CourseRes>;
recordApi: (payload: RecordReq) => Promise<RecordRes>; recordApi: (payload: RecordReq) => Promise<RecordRes>;
studyApi: (payload: StudyReq) => Promise<StudyRes>; studyApi: (payload: StudyReq) => Promise<StudyRes>;
logoutApi: () => Promise<LogoutRes>; logoutApi: () => Promise<LogoutRes>;
}; };
const RECORD_API_TIMEOUT_MS = 60000;
const COURSE_API_TIMEOUT_MS = Math.max(DEFAULT_HTTP_TIMEOUT_MS, 30000);
export const loginApi = async (payload: LoginReq) => { export const loginApi = async (payload: LoginReq) => {
const res = await http.post<LoginRes>("/api/login", payload); const res = await http.post<LoginRes>("/api/login", payload);
return res; return res;
}; };
const createWkClientFromHttp = (client: HttpClient): WkClient => ({ const createWkClientFromHttp = (client: HttpClient): WkClient => ({
userInfoApi() {
return client.post<UserInfoRes>("/api/v2/userinfo");
},
courseApi(payload) { courseApi(payload) {
return client.post<CourseRes>("/api/v2/course", payload); return client.post<CourseRes>("/api/v2/course", payload, {
timeout: COURSE_API_TIMEOUT_MS,
});
}, },
recordApi(payload) { recordApi(payload) {
return client.post<RecordRes>("/api/v2/record", payload); return client.post<RecordRes>("/api/v2/record", payload, {
timeout: RECORD_API_TIMEOUT_MS,
});
}, },
studyApi(payload) { studyApi(payload) {
return client.post<StudyRes>("/api/v2/study", payload); return client.post<StudyRes>("/api/v2/study", payload);
@@ -190,6 +209,10 @@ export const createWkClient = (
return createWkClientFromHttp(createHttpClient(resolveSessionId)); return createWkClientFromHttp(createHttpClient(resolveSessionId));
}; };
export const createSessionWkClient = (sessionId: string): WkClient => {
return createWkClient(() => sessionId);
};
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
export const runStudyQueue = async (_payload: StudyRunnerPayload) => { export const runStudyQueue = async (_payload: StudyRunnerPayload) => {

View File

@@ -14,6 +14,7 @@ type SettingsState = {
persistAccounts: boolean; persistAccounts: boolean;
persistRecords: boolean; persistRecords: boolean;
persistLogs: boolean; persistLogs: boolean;
debugEnabled: boolean;
autoScrollLogs: boolean; autoScrollLogs: boolean;
showLogTimestamps: boolean; showLogTimestamps: boolean;
densityMode: DensityMode; densityMode: DensityMode;
@@ -24,6 +25,7 @@ type SettingsState = {
setPersistSection: (section: CacheSection, value: boolean) => void; setPersistSection: (section: CacheSection, value: boolean) => void;
clearPersistedSection: (section: CacheSection) => void; clearPersistedSection: (section: CacheSection) => void;
clearAllPersistedData: () => void; clearAllPersistedData: () => void;
setDebugEnabled: (value: boolean) => void;
setAutoScrollLogs: (value: boolean) => void; setAutoScrollLogs: (value: boolean) => void;
setShowLogTimestamps: (value: boolean) => void; setShowLogTimestamps: (value: boolean) => void;
setDensityMode: (value: DensityMode) => void; setDensityMode: (value: DensityMode) => void;
@@ -93,6 +95,7 @@ export const settingsStore = createStore<SettingsState>()(
persistAccounts: true, persistAccounts: true,
persistRecords: true, persistRecords: true,
persistLogs: true, persistLogs: true,
debugEnabled: false,
autoScrollLogs: true, autoScrollLogs: true,
showLogTimestamps: false, showLogTimestamps: false,
densityMode: "comfortable", densityMode: "comfortable",
@@ -160,6 +163,7 @@ export const settingsStore = createStore<SettingsState>()(
clearAllPersistedData: () => { clearAllPersistedData: () => {
localStorage.removeItem(accountStorageKey); localStorage.removeItem(accountStorageKey);
}, },
setDebugEnabled: (value) => set({ debugEnabled: value }),
setAutoScrollLogs: (value) => set({ autoScrollLogs: value }), setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),
setShowLogTimestamps: (value) => set({ showLogTimestamps: value }), setShowLogTimestamps: (value) => set({ showLogTimestamps: value }),
setDensityMode: (value) => set({ densityMode: value }), setDensityMode: (value) => set({ densityMode: value }),