Compare commits
2 Commits
7e102b3b76
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 13f0be162b | |||
| 1396592141 |
@@ -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",
|
||||||
|
|||||||
58
src/App.tsx
58
src/App.tsx
@@ -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,12 +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";
|
||||||
const asideList = [
|
|
||||||
{ label: "账号", url: "/account" },
|
|
||||||
{ label: "日志", url: "/logs" },
|
|
||||||
{ label: "设置", url: "/setting" },
|
|
||||||
];
|
|
||||||
|
|
||||||
type DownloadState = "idle" | "downloading" | "done" | "error";
|
type DownloadState = "idle" | "downloading" | "done" | "error";
|
||||||
type UpdateCheckState =
|
type UpdateCheckState =
|
||||||
@@ -161,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 ||
|
||||||
@@ -175,6 +174,21 @@ const App: ParentComponent = (props) => {
|
|||||||
const buildText = createMemo(() => version()?.data.BuildAt ?? "unknown");
|
const buildText = createMemo(() => version()?.data.BuildAt ?? "unknown");
|
||||||
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 isDebugMode = createMemo(() => settingsState().debugEnabled);
|
||||||
|
const asideList = createMemo(() => {
|
||||||
|
const items = [
|
||||||
|
{ label: "账号", url: "/account" },
|
||||||
|
{ label: "日志", url: "/logs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isDebugMode()) {
|
||||||
|
items.push({ label: "后端日志", url: "/debug-logs" });
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ label: "设置", url: "/setting" });
|
||||||
|
return items;
|
||||||
|
});
|
||||||
const versionErrorText = createMemo(() => {
|
const versionErrorText = createMemo(() => {
|
||||||
const error = version.error;
|
const error = version.error;
|
||||||
if (!error) {
|
if (!error) {
|
||||||
@@ -186,6 +200,7 @@ const App: ParentComponent = (props) => {
|
|||||||
const versionPayloadText = createMemo(() =>
|
const versionPayloadText = createMemo(() =>
|
||||||
[
|
[
|
||||||
`Version: ${versionText()}`,
|
`Version: ${versionText()}`,
|
||||||
|
`Mode: ${modeText()}`,
|
||||||
`Commit: ${commitText()}`,
|
`Commit: ${commitText()}`,
|
||||||
`Build: ${buildText()}`,
|
`Build: ${buildText()}`,
|
||||||
`Author: ${authorText()}`,
|
`Author: ${authorText()}`,
|
||||||
@@ -217,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());
|
||||||
@@ -310,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();
|
||||||
@@ -355,7 +391,7 @@ const App: ParentComponent = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex gap-2 overflow-x-auto pb-1 xl:flex-col xl:overflow-visible xl:pb-0">
|
<nav class="flex gap-2 overflow-x-auto pb-1 xl:flex-col xl:overflow-visible xl:pb-0">
|
||||||
{asideList.map((item) => {
|
{asideList().map((item) => {
|
||||||
const active = isActive(item.url);
|
const active = isActive(item.url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -387,7 +423,13 @@ const App: ParentComponent = (props) => {
|
|||||||
<div class="mt-3 rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4 xl:mt-auto">
|
<div class="mt-3 rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4 xl:mt-auto">
|
||||||
<p class="text-sm font-medium text-zinc-800">当前页面</p>
|
<p class="text-sm font-medium text-zinc-800">当前页面</p>
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
{asideList.find((item) => isActive(item.url))?.label ?? "账号"}
|
{asideList().find((item) => isActive(item.url))?.label ?? "账号"}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">
|
||||||
|
模式: {modeText()}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">
|
||||||
|
调试: {isDebugMode() ? "已开启" : "已关闭"}
|
||||||
</p>
|
</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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ render(
|
|||||||
path="logs"
|
path="logs"
|
||||||
component={lazy(() => import("./pages/logs/Logs.tsx"))}
|
component={lazy(() => import("./pages/logs/Logs.tsx"))}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="debug-logs"
|
||||||
|
component={lazy(() => import("./pages/debug-logs/DebugLogs.tsx"))}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="setting"
|
path="setting"
|
||||||
component={lazy(() => import("./pages/settings/Setting.tsx"))}
|
component={lazy(() => import("./pages/settings/Setting.tsx"))}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
715
src/pages/debug-logs/DebugLogs.tsx
Normal file
715
src/pages/debug-logs/DebugLogs.tsx
Normal file
@@ -0,0 +1,715 @@
|
|||||||
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
} from "solid-js";
|
||||||
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
import {
|
||||||
|
fetchDebugLogSnapshot,
|
||||||
|
resolveDebugLogDownloadUrl,
|
||||||
|
resolveDebugLogWsUrl,
|
||||||
|
type DebugLogEntry,
|
||||||
|
} from "~/service/debugLog";
|
||||||
|
import { settingsStore } from "~/store/settings";
|
||||||
|
|
||||||
|
type DebugSocketState = "connecting" | "open" | "closed" | "error";
|
||||||
|
type DebugDetailTab = "overview" | "request" | "response" | "raw";
|
||||||
|
|
||||||
|
const MAX_DEBUG_ENTRIES = 1000;
|
||||||
|
|
||||||
|
const debugStatusLabelMap: Record<DebugSocketState, string> = {
|
||||||
|
connecting: "连接中",
|
||||||
|
open: "已连接",
|
||||||
|
closed: "已断开",
|
||||||
|
error: "连接异常",
|
||||||
|
};
|
||||||
|
|
||||||
|
const debugStatusClassMap: Record<DebugSocketState, string> = {
|
||||||
|
connecting: "border-amber-400/20 bg-amber-400/10 text-amber-100",
|
||||||
|
open: "border-emerald-400/20 bg-emerald-400/10 text-emerald-100",
|
||||||
|
closed: "border-white/10 bg-white/5 text-slate-300",
|
||||||
|
error: "border-rose-400/20 bg-rose-400/10 text-rose-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringifyDebugFields = (fields?: Record<string, unknown>) => {
|
||||||
|
if (!fields || Object.keys(fields).length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(fields, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringifyDebugValue = (value: unknown) => {
|
||||||
|
if (
|
||||||
|
value === null ||
|
||||||
|
value === undefined ||
|
||||||
|
(typeof value === "string" && value.trim() === "")
|
||||||
|
) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 navigate = useNavigate();
|
||||||
|
const [settingsState, setSettingsState] = createSignal(
|
||||||
|
settingsStore.getState(),
|
||||||
|
);
|
||||||
|
const [debugEntries, setDebugEntries] = createSignal<DebugLogEntry[]>([]);
|
||||||
|
const [debugSocketState, setDebugSocketState] =
|
||||||
|
createSignal<DebugSocketState>("connecting");
|
||||||
|
const [debugError, setDebugError] = createSignal("");
|
||||||
|
const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal(0);
|
||||||
|
const [selectedDetailTab, setSelectedDetailTab] =
|
||||||
|
createSignal<DebugDetailTab>("overview");
|
||||||
|
let debugLogContainerRef: HTMLDivElement | undefined;
|
||||||
|
let debugSocket: WebSocket | null = null;
|
||||||
|
let reconnectTimer: number | undefined;
|
||||||
|
let manualClose = false;
|
||||||
|
const debugEntryKeySet = new Set<number>();
|
||||||
|
|
||||||
|
const isDebugMode = createMemo(() => {
|
||||||
|
return settingsState().debugEnabled;
|
||||||
|
});
|
||||||
|
const latestDebugEntry = createMemo(() => {
|
||||||
|
const rows = debugEntries();
|
||||||
|
return rows.length > 0 ? rows[rows.length - 1] : null;
|
||||||
|
});
|
||||||
|
const selectedDebugEntry = createMemo(() => {
|
||||||
|
const currentId = selectedDebugEntryId();
|
||||||
|
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(() => {
|
||||||
|
return new Set(debugEntries().map((item) => item.source)).size;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetDebugEntries = () => {
|
||||||
|
debugEntryKeySet.clear();
|
||||||
|
setDebugEntries([]);
|
||||||
|
setSelectedDebugEntryId(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceDebugEntries = (entries: DebugLogEntry[]) => {
|
||||||
|
const nextEntries = entries.slice(-MAX_DEBUG_ENTRIES);
|
||||||
|
debugEntryKeySet.clear();
|
||||||
|
for (const entry of nextEntries) {
|
||||||
|
debugEntryKeySet.add(entry.id);
|
||||||
|
}
|
||||||
|
setDebugEntries(nextEntries);
|
||||||
|
setSelectedDebugEntryId((prev) => {
|
||||||
|
if (prev && nextEntries.some((entry) => entry.id === prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return nextEntries[nextEntries.length - 1]?.id ?? 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendDebugEntry = (entry: DebugLogEntry) => {
|
||||||
|
if (debugEntryKeySet.has(entry.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugEntryKeySet.add(entry.id);
|
||||||
|
setDebugEntries((prev) => {
|
||||||
|
const next = [...prev, entry];
|
||||||
|
while (next.length > MAX_DEBUG_ENTRIES) {
|
||||||
|
const removed = next.shift();
|
||||||
|
if (removed) {
|
||||||
|
debugEntryKeySet.delete(removed.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectedDebugEntryId()) {
|
||||||
|
setSelectedDebugEntryId(entry.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDebugSnapshot = async () => {
|
||||||
|
try {
|
||||||
|
const entries = await fetchDebugLogSnapshot();
|
||||||
|
replaceDebugEntries(entries);
|
||||||
|
setDebugError("");
|
||||||
|
} catch (error) {
|
||||||
|
setDebugError(
|
||||||
|
error instanceof Error ? error.message : "获取调试日志快照失败",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (manualClose || reconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
reconnectTimer = undefined;
|
||||||
|
connectDebugSocket();
|
||||||
|
}, 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectDebugSocket = () => {
|
||||||
|
manualClose = true;
|
||||||
|
if (reconnectTimer) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = undefined;
|
||||||
|
}
|
||||||
|
if (debugSocket) {
|
||||||
|
debugSocket.close();
|
||||||
|
debugSocket = null;
|
||||||
|
}
|
||||||
|
setDebugSocketState("closed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectDebugSocket = () => {
|
||||||
|
if (!isDebugMode() || debugSocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manualClose = false;
|
||||||
|
setDebugSocketState("connecting");
|
||||||
|
setDebugError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = new WebSocket(resolveDebugLogWsUrl());
|
||||||
|
debugSocket = socket;
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
setDebugSocketState("open");
|
||||||
|
setDebugError("");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data) as DebugLogEntry;
|
||||||
|
if (!payload || typeof payload.id !== "number") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendDebugEntry(payload);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed payloads.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
setDebugSocketState("error");
|
||||||
|
setDebugError("调试日志流连接失败,请确认设置页中的调试开关已开启。");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
debugSocket = null;
|
||||||
|
if (manualClose) {
|
||||||
|
setDebugSocketState("closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDebugSocketState("closed");
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debugSocket = null;
|
||||||
|
setDebugSocketState("error");
|
||||||
|
setDebugError(error instanceof Error ? error.message : "连接失败");
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
||||||
|
setSettingsState(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
unsubscribeSettings();
|
||||||
|
disconnectDebugSocket();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!isDebugMode()) {
|
||||||
|
disconnectDebugSocket();
|
||||||
|
resetDebugEntries();
|
||||||
|
void navigate("/logs", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadDebugSnapshot();
|
||||||
|
connectDebugSocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
debugEntries().length;
|
||||||
|
|
||||||
|
if (!settingsState().autoScrollLogs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const element = debugLogContainerRef;
|
||||||
|
if (element) {
|
||||||
|
element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (debugLogContainerRef) {
|
||||||
|
debugLogContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
selectedDebugEntryId();
|
||||||
|
setSelectedDetailTab("overview");
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearDebugLogs = () => {
|
||||||
|
resetDebugEntries();
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadDebugLogs = () => {
|
||||||
|
window.open(resolveDebugLogDownloadUrl(), "_blank", "noopener,noreferrer");
|
||||||
|
};
|
||||||
|
|
||||||
|
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(236,254,255,0.95),rgba(255,255,255,0.94))] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(8,145,178,0.22)]">
|
||||||
|
<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-cyan-700/80 uppercase">
|
||||||
|
Backend Observer
|
||||||
|
</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">
|
||||||
|
<span
|
||||||
|
class={`rounded-full border px-3 py-1 text-xs ${debugStatusClassMap[debugSocketState()]}`}
|
||||||
|
>
|
||||||
|
{debugStatusLabelMap[debugSocketState()]}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-xs text-zinc-700">
|
||||||
|
TOTAL {debugEntries().length}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-xs text-zinc-700">
|
||||||
|
SOURCE {debugSourceCount()}
|
||||||
|
</span>
|
||||||
|
<Show when={latestDebugEntry()}>
|
||||||
|
<span class="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-xs text-zinc-600">
|
||||||
|
最新: {latestDebugEntry()?.time} / {latestDebugEntry()?.source}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={isDebugMode()}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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(8,15,30,0.98),rgba(15,23,42,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-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-[10px] font-medium tracking-[0.28em] text-cyan-300/80 uppercase">
|
||||||
|
Debug Stream
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-slate-400 sm:text-sm">
|
||||||
|
先读取快照,再通过 WebSocket 持续接收增量日志。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||||
|
onClick={connectDebugSocket}
|
||||||
|
disabled={
|
||||||
|
debugSocketState() === "open" ||
|
||||||
|
debugSocketState() === "connecting"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{debugSocketState() === "connecting" ? "连接中..." : "重新连接"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10"
|
||||||
|
onClick={() => void loadDebugSnapshot()}
|
||||||
|
>
|
||||||
|
刷新快照
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||||
|
onClick={downloadDebugLogs}
|
||||||
|
>
|
||||||
|
下载日志
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10"
|
||||||
|
onClick={disconnectDebugSocket}
|
||||||
|
disabled={debugSocketState() !== "open"}
|
||||||
|
>
|
||||||
|
断开连接
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1.5 text-xs font-medium text-rose-100 transition hover:bg-rose-400/20"
|
||||||
|
onClick={clearDebugLogs}
|
||||||
|
>
|
||||||
|
清空视图
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={debugError()}>
|
||||||
|
<p class="mt-2 text-xs text-rose-300">{debugError()}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-h-0 flex-1 gap-3 p-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.95fr)]">
|
||||||
|
<div class="flex min-h-0 flex-col overflow-hidden rounded-[22px] border border-white/10 bg-white/4">
|
||||||
|
<div class="grid grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] gap-3 border-b border-white/10 bg-black/20 px-3 py-2 text-[11px] tracking-wide text-slate-300 uppercase">
|
||||||
|
<span>时间</span>
|
||||||
|
<span>来源</span>
|
||||||
|
<span>等级</span>
|
||||||
|
<span>消息</span>
|
||||||
|
<span>URL / Path</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={debugLogContainerRef}
|
||||||
|
class="min-h-0 flex-1 overflow-auto px-2 py-2 font-mono text-slate-100"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={debugEntries().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="flex h-full items-center justify-center rounded-[18px] border border-dashed border-white/10 bg-white/3 px-6 text-slate-500">
|
||||||
|
暂无后端调试日志
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="min-w-[980px] space-y-1">
|
||||||
|
<For each={debugEntries()}>
|
||||||
|
{(entry) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`grid w-full grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] items-start gap-3 rounded-[16px] border px-3 py-2 text-left transition ${
|
||||||
|
selectedDebugEntryId() === entry.id
|
||||||
|
? "border-cyan-300/35 bg-cyan-400/10"
|
||||||
|
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDebugEntryId(entry.id)}
|
||||||
|
>
|
||||||
|
<span class="truncate text-cyan-100">{entry.time}</span>
|
||||||
|
<span class="truncate text-amber-100">{entry.source}</span>
|
||||||
|
<span class="truncate text-emerald-200">
|
||||||
|
{entry.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-slate-100" title={entry.message}>
|
||||||
|
{entry.message}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="truncate text-slate-400"
|
||||||
|
title={resolveEntrySummary(entry)}
|
||||||
|
>
|
||||||
|
{resolveEntrySummary(entry)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-col overflow-hidden rounded-[22px] border border-white/10 bg-black/20">
|
||||||
|
<div class="border-b border-white/10 px-4 py-3">
|
||||||
|
<p class="text-sm font-semibold text-white">日志详情</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-400">
|
||||||
|
按请求和响应拆分查看当前日志的完整上下文。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-sm text-slate-200">
|
||||||
|
<Show
|
||||||
|
when={selectedDebugEntry()}
|
||||||
|
fallback={<p class="text-slate-500">请选择左侧一条日志查看详情。</p>}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<p>ID: {selectedDebugEntry()?.id}</p>
|
||||||
|
<p>时间: {selectedDebugEntry()?.time}</p>
|
||||||
|
<p>来源: {selectedDebugEntry()?.source}</p>
|
||||||
|
<p>等级: {selectedDebugEntry()?.level}</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}>
|
||||||
|
<p>调用位置: {selectedDebugEntry()?.caller}</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={selectedDebugEntry()?.logger}>
|
||||||
|
<p>Logger: {selectedDebugEntry()?.logger}</p>
|
||||||
|
</Show>
|
||||||
|
</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>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DebugLogs;
|
||||||
@@ -7,10 +7,6 @@ import {
|
|||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import {
|
|
||||||
resolveDebugLogWsUrl,
|
|
||||||
type DebugLogEntry,
|
|
||||||
} from "~/service/debugLog";
|
|
||||||
import { accountStore } from "~/store/account";
|
import { accountStore } from "~/store/account";
|
||||||
import { settingsStore } from "~/store/settings";
|
import { settingsStore } from "~/store/settings";
|
||||||
|
|
||||||
@@ -25,10 +21,6 @@ type LogRow = {
|
|||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DebugSocketState = "connecting" | "open" | "closed" | "error";
|
|
||||||
|
|
||||||
const MAX_DEBUG_ENTRIES = 1000;
|
|
||||||
|
|
||||||
const extractTimestamp = (message: string) => {
|
const extractTimestamp = (message: string) => {
|
||||||
const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/);
|
const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/);
|
||||||
return match?.[1] ?? null;
|
return match?.[1] ?? null;
|
||||||
@@ -56,159 +48,12 @@ const normalizeContent = (message: string) => {
|
|||||||
return value || "-";
|
return value || "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
const debugStatusLabelMap: Record<DebugSocketState, string> = {
|
|
||||||
connecting: "连接中",
|
|
||||||
open: "已连接",
|
|
||||||
closed: "已断开",
|
|
||||||
error: "连接异常",
|
|
||||||
};
|
|
||||||
|
|
||||||
const debugStatusClassMap: Record<DebugSocketState, string> = {
|
|
||||||
connecting:
|
|
||||||
"border-amber-400/20 bg-amber-400/10 text-amber-100",
|
|
||||||
open: "border-emerald-400/20 bg-emerald-400/10 text-emerald-100",
|
|
||||||
closed: "border-white/10 bg-white/5 text-slate-300",
|
|
||||||
error: "border-rose-400/20 bg-rose-400/10 text-rose-100",
|
|
||||||
};
|
|
||||||
|
|
||||||
const stringifyDebugFields = (fields?: Record<string, unknown>) => {
|
|
||||||
if (!fields || Object.keys(fields).length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(fields, null, 2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const summarizeDebugFields = (fields?: Record<string, unknown>) => {
|
|
||||||
const value = stringifyDebugFields(fields);
|
|
||||||
if (!value) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.replace(/\s+/g, " ").slice(0, 180);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Logs = () => {
|
const Logs = () => {
|
||||||
const [accountState, setAccountState] = createSignal(accountStore.getState());
|
const [accountState, setAccountState] = createSignal(accountStore.getState());
|
||||||
const [settingsState, setSettingsState] = createSignal(
|
const [settingsState, setSettingsState] = createSignal(
|
||||||
settingsStore.getState(),
|
settingsStore.getState(),
|
||||||
);
|
);
|
||||||
const [debugEntries, setDebugEntries] = createSignal<DebugLogEntry[]>([]);
|
|
||||||
const [debugSocketState, setDebugSocketState] =
|
|
||||||
createSignal<DebugSocketState>("connecting");
|
|
||||||
const [debugError, setDebugError] = createSignal("");
|
|
||||||
const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal<number>(0);
|
|
||||||
let logContainerRef: HTMLDivElement | undefined;
|
let logContainerRef: HTMLDivElement | undefined;
|
||||||
let debugLogContainerRef: HTMLDivElement | undefined;
|
|
||||||
let debugSocket: WebSocket | null = null;
|
|
||||||
let reconnectTimer: number | undefined;
|
|
||||||
let manualClose = false;
|
|
||||||
const debugEntryKeySet = new Set<number>();
|
|
||||||
|
|
||||||
const resetDebugEntries = () => {
|
|
||||||
debugEntryKeySet.clear();
|
|
||||||
setDebugEntries([]);
|
|
||||||
setSelectedDebugEntryId(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendDebugEntry = (entry: DebugLogEntry) => {
|
|
||||||
if (debugEntryKeySet.has(entry.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugEntryKeySet.add(entry.id);
|
|
||||||
setDebugEntries((prev) => {
|
|
||||||
const next = [...prev, entry];
|
|
||||||
while (next.length > MAX_DEBUG_ENTRIES) {
|
|
||||||
const removed = next.shift();
|
|
||||||
if (removed) {
|
|
||||||
debugEntryKeySet.delete(removed.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selectedDebugEntryId()) {
|
|
||||||
setSelectedDebugEntryId(entry.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
|
||||||
if (manualClose || reconnectTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnectTimer = window.setTimeout(() => {
|
|
||||||
reconnectTimer = undefined;
|
|
||||||
connectDebugSocket();
|
|
||||||
}, 1800);
|
|
||||||
};
|
|
||||||
|
|
||||||
const disconnectDebugSocket = () => {
|
|
||||||
manualClose = true;
|
|
||||||
if (reconnectTimer) {
|
|
||||||
window.clearTimeout(reconnectTimer);
|
|
||||||
reconnectTimer = undefined;
|
|
||||||
}
|
|
||||||
if (debugSocket) {
|
|
||||||
debugSocket.close();
|
|
||||||
debugSocket = null;
|
|
||||||
}
|
|
||||||
setDebugSocketState("closed");
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectDebugSocket = () => {
|
|
||||||
if (debugSocket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
manualClose = false;
|
|
||||||
setDebugSocketState("connecting");
|
|
||||||
setDebugError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = new WebSocket(resolveDebugLogWsUrl());
|
|
||||||
debugSocket = socket;
|
|
||||||
|
|
||||||
socket.addEventListener("open", () => {
|
|
||||||
resetDebugEntries();
|
|
||||||
setDebugSocketState("open");
|
|
||||||
setDebugError("");
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addEventListener("message", (event) => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data) as DebugLogEntry;
|
|
||||||
if (!payload || typeof payload.id !== "number") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
appendDebugEntry(payload);
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed WS payloads to keep the stream alive.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addEventListener("error", () => {
|
|
||||||
setDebugSocketState("error");
|
|
||||||
setDebugError("调试日志流连接失败,请确认后端处于 debug 模式。");
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addEventListener("close", () => {
|
|
||||||
debugSocket = null;
|
|
||||||
if (manualClose) {
|
|
||||||
setDebugSocketState("closed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDebugSocketState("closed");
|
|
||||||
scheduleReconnect();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
debugSocket = null;
|
|
||||||
setDebugSocketState("error");
|
|
||||||
setDebugError(error instanceof Error ? error.message : "连接失败");
|
|
||||||
scheduleReconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const unsubscribeAccount = accountStore.subscribe((state) => {
|
const unsubscribeAccount = accountStore.subscribe((state) => {
|
||||||
@@ -217,12 +62,10 @@ const Logs = () => {
|
|||||||
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
||||||
setSettingsState(state);
|
setSettingsState(state);
|
||||||
});
|
});
|
||||||
connectDebugSocket();
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
unsubscribeAccount();
|
unsubscribeAccount();
|
||||||
unsubscribeSettings();
|
unsubscribeSettings();
|
||||||
disconnectDebugSocket();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,20 +133,6 @@ const Logs = () => {
|
|||||||
return rows.length > 0 ? rows[rows.length - 1] : null;
|
return rows.length > 0 ? rows[rows.length - 1] : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const latestDebugEntry = createMemo(() => {
|
|
||||||
const rows = debugEntries();
|
|
||||||
return rows.length > 0 ? rows[rows.length - 1] : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedDebugEntry = createMemo(() => {
|
|
||||||
const currentId = selectedDebugEntryId();
|
|
||||||
return debugEntries().find((item) => item.id === currentId) ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const debugSourceCount = createMemo(() => {
|
|
||||||
return new Set(debugEntries().map((item) => item.source)).size;
|
|
||||||
});
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
logRows().length;
|
logRows().length;
|
||||||
|
|
||||||
@@ -319,54 +148,29 @@ const Logs = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
debugEntries().length;
|
|
||||||
|
|
||||||
if (!settingsState().autoScrollLogs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const element = debugLogContainerRef;
|
|
||||||
if (element) {
|
|
||||||
element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (logContainerRef) {
|
if (logContainerRef) {
|
||||||
logContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
logContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
||||||
}
|
}
|
||||||
if (debugLogContainerRef) {
|
|
||||||
debugLogContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const clearAllLogs = () => {
|
const clearAllLogs = () => {
|
||||||
accountStore.getState().clearAllStudyLogs();
|
accountStore.getState().clearAllStudyLogs();
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearDebugLogs = () => {
|
|
||||||
resetDebugEntries();
|
|
||||||
if (debugSocketState() !== "open") {
|
|
||||||
connectDebugSocket();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
<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="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="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<div class="max-w-xl">
|
<div class="max-w-xl">
|
||||||
<p class="text-[10px] font-medium tracking-[0.28em] text-amber-700/80 uppercase">
|
<p class="text-[10px] font-medium tracking-[0.28em] text-amber-700/80 uppercase">
|
||||||
Unified Stream
|
Study Stream
|
||||||
</p>
|
</p>
|
||||||
<h1 class="mt-1.5 text-2xl font-semibold tracking-tight text-zinc-950">
|
<h1 class="mt-1.5 text-2xl font-semibold tracking-tight text-zinc-950">
|
||||||
日志中心
|
学习日志
|
||||||
</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">
|
||||||
单行并列展示时间、账号、姓名与日志内容。
|
单行并列展示时间、账号、姓名与学习日志内容。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -392,14 +196,6 @@ const Logs = () => {
|
|||||||
最新: {latestLog()?.timestamp} / {latestLog()?.accountName}
|
最新: {latestLog()?.timestamp} / {latestLog()?.accountName}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<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">
|
|
||||||
DEBUG STREAM
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-semibold text-zinc-950">
|
|
||||||
{debugEntries().length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
||||||
@@ -454,10 +250,7 @@ const Logs = () => {
|
|||||||
? row.timestamp
|
? row.timestamp
|
||||||
: "--:--:--"}
|
: "--:--:--"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span class="truncate text-amber-200" title={row.accountName}>
|
||||||
class="truncate text-amber-200"
|
|
||||||
title={row.accountName}
|
|
||||||
>
|
|
||||||
{row.accountName}
|
{row.accountName}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate text-slate-300" title={row.accountId}>
|
<span class="truncate text-slate-300" title={row.accountId}>
|
||||||
@@ -478,158 +271,6 @@ const Logs = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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(8,15,30,0.98),rgba(15,23,42,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-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-[10px] font-medium tracking-[0.28em] text-cyan-300/80 uppercase">
|
|
||||||
Backend Debug Stream
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-1 text-xl font-semibold tracking-tight text-white">
|
|
||||||
后端实时日志
|
|
||||||
</h2>
|
|
||||||
<p class="mt-1 text-xs leading-5 text-slate-400 sm:text-sm">
|
|
||||||
通过 WebSocket 读取后端 debug 模式下的请求、响应和运行日志。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
|
|
||||||
<span
|
|
||||||
class={`rounded-full border px-3 py-1 text-xs ${debugStatusClassMap[debugSocketState()]}`}
|
|
||||||
>
|
|
||||||
{debugStatusLabelMap[debugSocketState()]}
|
|
||||||
</span>
|
|
||||||
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
|
|
||||||
Source {debugSourceCount()}
|
|
||||||
</span>
|
|
||||||
<Show when={latestDebugEntry()}>
|
|
||||||
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
|
|
||||||
最新: {latestDebugEntry()?.time} / {latestDebugEntry()?.source}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
|
||||||
onClick={connectDebugSocket}
|
|
||||||
disabled={debugSocketState() === "open" || debugSocketState() === "connecting"}
|
|
||||||
>
|
|
||||||
{debugSocketState() === "connecting" ? "连接中..." : "重新连接"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10"
|
|
||||||
onClick={disconnectDebugSocket}
|
|
||||||
disabled={debugSocketState() !== "open"}
|
|
||||||
>
|
|
||||||
断开连接
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1.5 text-xs font-medium text-rose-100 transition hover:bg-rose-400/20"
|
|
||||||
onClick={clearDebugLogs}
|
|
||||||
>
|
|
||||||
清空视图
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={debugError()}>
|
|
||||||
<p class="mt-2 text-xs text-rose-300">{debugError()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid min-h-0 flex-1 gap-3 p-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.95fr)]">
|
|
||||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-[22px] border border-white/10 bg-white/4">
|
|
||||||
<div class="grid grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] gap-3 border-b border-white/10 bg-black/20 px-3 py-2 text-[11px] tracking-wide text-slate-300 uppercase">
|
|
||||||
<span>时间</span>
|
|
||||||
<span>来源</span>
|
|
||||||
<span>等级</span>
|
|
||||||
<span>消息</span>
|
|
||||||
<span>摘要</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={debugLogContainerRef}
|
|
||||||
class="min-h-0 flex-1 overflow-auto px-2 py-2 font-mono text-slate-100"
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={debugEntries().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="flex h-full items-center justify-center rounded-[18px] border border-dashed border-white/10 bg-white/3 px-6 text-slate-500">
|
|
||||||
暂无后端调试日志
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="min-w-[980px] space-y-1">
|
|
||||||
<For each={debugEntries()}>
|
|
||||||
{(entry) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`grid w-full grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] items-start gap-3 rounded-[16px] border px-3 py-2 text-left transition ${
|
|
||||||
selectedDebugEntryId() === entry.id
|
|
||||||
? "border-cyan-300/35 bg-cyan-400/10"
|
|
||||||
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedDebugEntryId(entry.id)}
|
|
||||||
>
|
|
||||||
<span class="truncate text-cyan-100">{entry.time}</span>
|
|
||||||
<span class="truncate text-amber-100">{entry.source}</span>
|
|
||||||
<span class="truncate text-emerald-200">
|
|
||||||
{entry.level.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span class="truncate text-slate-100" title={entry.message}>
|
|
||||||
{entry.message}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="truncate text-slate-400"
|
|
||||||
title={stringifyDebugFields(entry.fields)}
|
|
||||||
>
|
|
||||||
{summarizeDebugFields(entry.fields)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-[22px] border border-white/10 bg-black/20">
|
|
||||||
<div class="border-b border-white/10 px-4 py-3">
|
|
||||||
<p class="text-sm font-semibold text-white">日志详情</p>
|
|
||||||
<p class="mt-1 text-xs text-slate-400">
|
|
||||||
查看当前选中日志的完整字段与上下文。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-sm text-slate-200">
|
|
||||||
<Show
|
|
||||||
when={selectedDebugEntry()}
|
|
||||||
fallback={<p class="text-slate-500">请选择左侧一条日志查看详情。</p>}
|
|
||||||
>
|
|
||||||
<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>ID: {selectedDebugEntry()?.id}</p>
|
|
||||||
<p>时间: {selectedDebugEntry()?.time}</p>
|
|
||||||
<p>来源: {selectedDebugEntry()?.source}</p>
|
|
||||||
<p>等级: {selectedDebugEntry()?.level}</p>
|
|
||||||
<p>消息: {selectedDebugEntry()?.message}</p>
|
|
||||||
<Show when={selectedDebugEntry()?.caller}>
|
|
||||||
<p>调用位置: {selectedDebugEntry()?.caller}</p>
|
|
||||||
</Show>
|
|
||||||
<Show when={selectedDebugEntry()?.logger}>
|
|
||||||
<p>Logger: {selectedDebugEntry()?.logger}</p>
|
|
||||||
</Show>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import http from "~/service/http";
|
||||||
|
|
||||||
export type DebugLogEntry = {
|
export type DebugLogEntry = {
|
||||||
id: number;
|
id: number;
|
||||||
time: string;
|
time: string;
|
||||||
@@ -9,23 +11,77 @@ export type DebugLogEntry = {
|
|||||||
fields?: Record<string, unknown>;
|
fields?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DebugLogListResponse = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
list: DebugLogEntry[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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:";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveDebugLogWsUrl = () => {
|
const resolveDebugLogUrl = (pathname: string) => {
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL as string | undefined;
|
const baseUrl = import.meta.env.VITE_BASE_URL as string | undefined;
|
||||||
|
|
||||||
if (baseUrl && /^https?:\/\//.test(baseUrl)) {
|
if (baseUrl && /^https?:\/\//.test(baseUrl)) {
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
url.protocol = toWsProtocol(url.protocol);
|
url.pathname = pathname;
|
||||||
url.pathname = "/api/debug/ws/logs";
|
|
||||||
url.search = "";
|
url.search = "";
|
||||||
url.hash = "";
|
url.hash = "";
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL("/api/debug/ws/logs", window.location.origin);
|
const url = new URL(pathname, window.location.origin);
|
||||||
url.protocol = toWsProtocol(window.location.protocol);
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveDebugLogWsUrl = () => {
|
||||||
|
const url = new URL(resolveDebugLogUrl("/api/debug/logs/ws"));
|
||||||
|
url.protocol = toWsProtocol(url.protocol);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveDebugLogDownloadUrl = () => {
|
||||||
|
return resolveDebugLogUrl("/api/debug/logs/download");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDebugLogSnapshot = async () => {
|
||||||
|
const response = await fetch(resolveDebugLogUrl("/api/debug/logs"), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取调试日志失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as DebugLogListResponse;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -108,6 +110,7 @@ export type VersionData = {
|
|||||||
GitAuthor: string;
|
GitAuthor: string;
|
||||||
GitCommit: string;
|
GitCommit: string;
|
||||||
GitEmail: string;
|
GitEmail: string;
|
||||||
|
Mode: string;
|
||||||
Version: string;
|
Version: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,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;
|
||||||
@@ -157,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);
|
||||||
@@ -189,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) => {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user