feat/optimization-and-audio #1

Merged
zhilv merged 4 commits from feat/optimization-and-audio into main 2026-04-26 20:51:23 +08:00
16 changed files with 961 additions and 621 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
doc doc
# Claude Code
.claude/

View File

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

View File

@@ -137,9 +137,6 @@ const renderInlineLinks = (text: string): JSX.Element[] => {
const App: ParentComponent = (props) => { const App: ParentComponent = (props) => {
const location = useLocation(); const location = useLocation();
const [version] = createResource(versionApi); const [version] = createResource(versionApi);
const [copyState, setCopyState] = createSignal<"idle" | "done" | "error">(
"idle",
);
const [updateDialogOpen, setUpdateDialogOpen] = createSignal(false); const [updateDialogOpen, setUpdateDialogOpen] = createSignal(false);
const [updateCheckState, setUpdateCheckState] = const [updateCheckState, setUpdateCheckState] =
createSignal<UpdateCheckState>("idle"); createSignal<UpdateCheckState>("idle");
@@ -197,31 +194,6 @@ const App: ParentComponent = (props) => {
return error instanceof Error ? error.message : "版本信息获取失败"; return error instanceof Error ? error.message : "版本信息获取失败";
}); });
const versionPayloadText = createMemo(() =>
[
`Version: ${versionText()}`,
`Mode: ${modeText()}`,
`Commit: ${commitText()}`,
`Build: ${buildText()}`,
`Author: ${authorText()}`,
`Email: ${emailText()}`,
].join("\n"),
);
const updateSummaryText = createMemo(() => {
if (updateCheckState() === "checking") {
return "更新检查中...";
}
if (updateCheckState() === "available") {
return `发现新版本:${latestRelease()?.tag_name ?? "-"}`;
}
if (updateCheckState() === "latest") {
return "已是最新版本";
}
if (updateCheckState() === "error") {
return updateCheckError() || "更新检查失败";
}
return "未检查更新";
});
const releaseNotesBlocks = createMemo(() => const releaseNotesBlocks = createMemo(() =>
parseMarkdownBlocks(latestRelease()?.body ?? ""), parseMarkdownBlocks(latestRelease()?.body ?? ""),
); );
@@ -231,6 +203,14 @@ const App: ParentComponent = (props) => {
const releaseLink = createMemo( const releaseLink = createMemo(
() => latestRelease()?.html_url || RELEASES_PAGE_URL, () => latestRelease()?.html_url || RELEASES_PAGE_URL,
); );
const safeValue = (value: string) => (value === "unknown" ? "-" : value);
const hasUpdateBadge = createMemo(() => updateCheckState() === "available");
const updateDialogTitle = createMemo(() => {
if (updateCheckState() === "available") {
return `发现更新 ${latestRelease()?.tag_name ?? ""}`;
}
return "更新信息";
});
onMount(() => { onMount(() => {
const unsubscribe = settingsStore.subscribe((state) => { const unsubscribe = settingsStore.subscribe((state) => {
@@ -242,16 +222,6 @@ const App: ParentComponent = (props) => {
}); });
}); });
const handleCopyVersion = async () => {
try {
await navigator.clipboard.writeText(versionPayloadText());
setCopyState("done");
} catch {
setCopyState("error");
}
window.setTimeout(() => setCopyState("idle"), 1800);
};
const performUpdateCheck = async (manual = false) => { const performUpdateCheck = async (manual = false) => {
if (updateCheckState() === "checking") { if (updateCheckState() === "checking") {
return; return;
@@ -276,17 +246,17 @@ const App: ParentComponent = (props) => {
setRuntimeTarget(target); setRuntimeTarget(target);
const hasNewVersion = isRemoteVersionNewer(versionText(), release.tag_name); const hasNewVersion = isRemoteVersionNewer(versionText(), release.tag_name);
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
if (!hasNewVersion) { if (!hasNewVersion) {
setUpdateCheckState("latest"); setUpdateCheckState("latest");
if (manual) { if (manual) {
setLatestRelease(release); setUpdateDialogOpen(true);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
} }
return; return;
} }
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
setUpdateCheckState("available"); setUpdateCheckState("available");
setUpdateDialogOpen(true); setUpdateDialogOpen(true);
} catch (error) { } catch (error) {
@@ -400,7 +370,7 @@ const App: ParentComponent = (props) => {
class={ class={
active active
? "min-w-fit whitespace-nowrap rounded-2xl border border-cyan-200 bg-[linear-gradient(135deg,_rgba(34,211,238,0.18),_rgba(34,197,94,0.18))] px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm" ? "min-w-fit whitespace-nowrap rounded-2xl border border-cyan-200 bg-[linear-gradient(135deg,_rgba(34,211,238,0.18),_rgba(34,197,94,0.18))] px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm"
: "min-w-fit whitespace-nowrap rounded-2xl border border-transparent px-4 py-3 text-sm font-medium text-zinc-600 transition hover:border-zinc-200 hover:bg-zinc-50/80 hover:text-zinc-900" : "min-w-fit whitespace-nowrap rounded-2xl border border-transparent px-4 py-3 text-sm font-medium text-zinc-600 transition active:scale-[0.98] hover:border-zinc-200 hover:bg-zinc-50/80 hover:text-zinc-900"
} }
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -425,48 +395,48 @@ const App: ParentComponent = (props) => {
<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>
<p class="mt-1 text-xs text-zinc-500"> <div class="mt-3 border-t border-zinc-200/80 pt-3">
: {modeText()}
</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">
Runtime
</p>
<div class="mt-2 grid gap-1 text-xs text-zinc-500 xl:block">
<p>Version: {versionText()}</p>
<p>Commit: {commitText()}</p>
<p>Build: {buildText()}</p>
<p>Author: {authorText()}</p>
<p>Email: {emailText()}</p>
</div>
<p
class={`mt-2 text-xs ${updateCheckState() === "error" ? "text-rose-500" : "text-zinc-500"}`}
>
: {updateSummaryText()}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button <button
type="button" type="button"
class="rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100" class="flex w-full min-w-0 items-center gap-2 rounded-lg border border-zinc-200 bg-white/80 px-2.5 py-1.5 text-left text-xs text-zinc-500 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleCopyVersion()}
>
{copyState() === "done"
? "已复制"
: copyState() === "error"
? "复制失败"
: "复制版本信息"}
</button>
<button
type="button"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-3 py-1.5 text-xs text-cyan-700 transition hover:bg-cyan-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={updateCheckState() === "checking"} disabled={updateCheckState() === "checking"}
onClick={() => void performUpdateCheck(true)} onClick={() => void performUpdateCheck(true)}
title="点击查看更新内容"
> >
{updateCheckState() === "checking" ? "检查中..." : "检查更新"} <span class="text-zinc-400"></span>
<span class="shrink-0 rounded-full border border-zinc-200 bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{safeValue(versionText())}
</span>
{hasUpdateBadge() ? (
<span
class="h-2 w-2 shrink-0 rounded-full bg-rose-500"
title={`新版本:${latestRelease()?.tag_name ?? "-"}`}
/>
) : null}
<span class="ml-auto text-[11px] text-zinc-400">
{updateCheckState() === "checking" ? "检查中..." : "查看更新"}
</span>
</button> </button>
<details class="mt-2 rounded-lg border border-zinc-200/80 bg-white/70 px-2.5 py-2">
<summary class="cursor-pointer select-none text-[11px] text-zinc-500">
</summary>
<div class="mt-2 space-y-1 text-xs">
<p class={isDebugMode() ? "text-amber-600" : "text-zinc-600"}>
Mode: {safeValue(modeText())}
</p>
<p class="truncate text-zinc-600">Commit: {safeValue(commitText())}</p>
<p class="truncate text-zinc-600">Build: {safeValue(buildText())}</p>
<p class="truncate text-zinc-600">Author: {safeValue(authorText())}</p>
<p class="truncate text-zinc-600">Email: {safeValue(emailText())}</p>
</div>
</details>
</div> </div>
{updateCheckState() === "error" ? (
<p class="mt-2 text-xs text-rose-500">
{updateCheckError() || "更新检查失败"}
</p>
) : null}
{versionErrorText() ? ( {versionErrorText() ? (
<p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p> <p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p>
) : null} ) : null}
@@ -488,14 +458,14 @@ const App: ParentComponent = (props) => {
} }
setUpdateDialogOpen(false); setUpdateDialogOpen(false);
}} }}
title={`发现更新 ${latestRelease()?.tag_name ?? ""}`} title={updateDialogTitle()}
widthClass="max-w-3xl" widthClass="max-w-3xl"
closeOnOverlay={downloadState() !== "downloading"} closeOnOverlay={downloadState() !== "downloading"}
footer={ footer={
<> <>
<button <button
type="button" type="button"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition active:bg-zinc-200 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={downloadState() === "downloading"} disabled={downloadState() === "downloading"}
onClick={() => setUpdateDialogOpen(false)} onClick={() => setUpdateDialogOpen(false)}
> >
@@ -503,14 +473,14 @@ const App: ParentComponent = (props) => {
</button> </button>
<button <button
type="button" type="button"
class="rounded-xl border border-cyan-200 bg-cyan-50 px-3 py-2 text-sm text-cyan-700 transition hover:bg-cyan-100" class="rounded-xl border border-cyan-200 bg-cyan-50 px-3 py-2 text-sm text-cyan-700 transition active:bg-cyan-200 hover:bg-cyan-100"
onClick={openReleasePage} onClick={openReleasePage}
> >
Release Release
</button> </button>
<button <button
type="button" type="button"
class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3 py-2 text-sm text-white transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3 py-2 text-sm text-white transition active:scale-[0.97] hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
disabled={downloadState() === "downloading"} disabled={downloadState() === "downloading"}
onClick={() => void handleDownloadUpdate()} onClick={() => void handleDownloadUpdate()}
> >

View File

@@ -89,7 +89,7 @@ const AccountSidebar = (props: AccountSidebarProps) => {
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<button <button
type="button" type="button"
class="min-w-0 flex-1 text-left" class="min-w-0 flex-1 text-left transition active:scale-[0.98]"
onClick={() => props.onSelectAccount(account.id)} onClick={() => props.onSelectAccount(account.id)}
> >
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
@@ -119,7 +119,7 @@ const AccountSidebar = (props: AccountSidebarProps) => {
</button> </button>
<button <button
type="button" type="button"
class="shrink-0 text-sm text-zinc-400 transition hover:text-zinc-600" class="shrink-0 text-sm text-zinc-400 transition hover:text-zinc-600 active:text-zinc-800"
onClick={() => props.onToggleExpand(account.id)} onClick={() => props.onToggleExpand(account.id)}
> >
{expanded() ? "收起" : "展开"} {expanded() ? "收起" : "展开"}
@@ -139,7 +139,7 @@ const AccountSidebar = (props: AccountSidebarProps) => {
<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 <button
type="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" class="rounded-xl border border-emerald-200 bg-white px-2.5 py-1 text-xs text-emerald-700 transition hover:bg-emerald-50 active:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isRefreshing()} disabled={isRefreshing()}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -150,14 +150,14 @@ const AccountSidebar = (props: AccountSidebarProps) => {
</button> </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 active:bg-cyan-100"
onClick={() => props.onToggleExpand(account.id)} onClick={() => props.onToggleExpand(account.id)}
> >
</button> </button>
<button <button
type="button" type="button"
class="rounded-xl border border-rose-200 bg-white px-2.5 py-1 text-xs text-rose-500 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-xl border border-rose-200 bg-white px-2.5 py-1 text-xs text-rose-500 transition hover:bg-rose-50 active:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.loggingOutId === account.id} disabled={props.loggingOutId === account.id}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();

View File

@@ -43,14 +43,14 @@ const AddAccountDialog = (props: AddAccountDialogProps) => {
<> <>
<button <button
type="button" type="button"
class="rounded-xl border border-zinc-200 px-4 py-2 text-zinc-700 transition hover:bg-zinc-100" class="rounded-xl border border-zinc-200 px-4 py-2 text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={props.onClose} onClick={props.onClose}
> >
</button> </button>
<button <button
type="button" type="button"
class="rounded-xl bg-cyan-500 px-4 py-2 text-white transition hover:bg-cyan-600 disabled:cursor-not-allowed disabled:bg-cyan-300" class="rounded-xl bg-cyan-500 px-4 py-2 text-white transition hover:bg-cyan-600 active:bg-cyan-700 disabled:cursor-not-allowed disabled:bg-cyan-300"
disabled={props.isSubmitting} disabled={props.isSubmitting}
onClick={props.onSubmit} onClick={props.onSubmit}
> >

View File

@@ -6,11 +6,13 @@ import {
createSignal, createSignal,
type JSX, type JSX,
} from "solid-js"; } from "solid-js";
import type { CourseKind, RecordType } from "~/service/wk"; import type { CourseKind, RecordType, WorkListItem, ExamListItem } from "~/service/wk";
import type { AccountItem } from "~/store/account"; import type { AccountItem } from "~/store/account";
import type { CourseType } from "~/types/Course"; import type { CourseType } from "~/types/Course";
import type { RecordItem } from "~/service/wk"; import type { RecordItem } from "~/service/wk";
const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim();
type RecordTypeOption = { type RecordTypeOption = {
label: string; label: string;
value: RecordType; value: RecordType;
@@ -33,6 +35,8 @@ interface CourseWorkspaceProps {
recordTypeOptions: RecordTypeOption[]; recordTypeOptions: RecordTypeOption[];
courseRecordTypeOptions: CourseRecordTypeOption[]; courseRecordTypeOptions: CourseRecordTypeOption[];
records: RecordItem[]; records: RecordItem[];
workList: WorkListItem[];
examList: ExamListItem[];
studyLogs: string[]; studyLogs: string[];
recordsLoading: boolean; recordsLoading: boolean;
recordError: string; recordError: string;
@@ -179,7 +183,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</select> </select>
<button <button
type="button" type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingCourseRecords} disabled={props.isRefreshingCourseRecords}
onClick={props.onRefreshCourseRecords} onClick={props.onRefreshCourseRecords}
> >
@@ -232,8 +236,8 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div <div
class={ class={
compact() compact()
? "grid min-h-0 gap-1.5 grid-rows-[minmax(0,1fr)_176px] xl:grid-rows-[minmax(0,1fr)_188px]" ? "grid min-h-0 gap-1.5 grid-rows-[minmax(0,1fr)_minmax(140px,30vh)]"
: "grid min-h-0 gap-2 grid-rows-[minmax(0,1fr)_188px] xl:grid-rows-[minmax(0,1fr)_208px]" : "grid min-h-0 gap-2 grid-rows-[minmax(0,1fr)_minmax(140px,30vh)]"
} }
> >
<div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]"> <div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
@@ -259,7 +263,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<button <button
type="button" type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={ disabled={
!props.selectedCourse || props.isRefreshingRecords !props.selectedCourse || props.isRefreshingRecords
} }
@@ -279,7 +283,13 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
> >
<For each={props.recordTypeOptions}> <For each={props.recordTypeOptions}>
{(item) => ( {(item) => (
<option value={item.value}>{item.label}</option> <option
value={item.value}
disabled={item.value === "/discuss"}
class={item.value === "/discuss" ? "text-zinc-400" : ""}
>
{item.label}
</option>
)} )}
</For> </For>
</select> </select>
@@ -287,7 +297,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<Show when={props.recordType === ""}> <Show when={props.recordType === ""}>
<button <button
type="button" type="button"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 transition hover:bg-cyan-100" class="rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 transition hover:bg-cyan-100 active:bg-cyan-200"
onClick={ onClick={
props.isRunningStudy props.isRunningStudy
? props.onStopStudy ? props.onStopStudy
@@ -299,25 +309,26 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</Show> </Show>
</div> </div>
</div> </div>
<Show when={props.recordType === ""}>
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5"> <div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5">
<div class="flex flex-wrap items-center gap-1.5 text-xs"> <div class="flex flex-wrap items-center gap-1.5 text-xs">
<button <button
type="button" type="button"
class={recordFilter() === "all" ? "rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-0.5 font-medium text-zinc-700" : "rounded-full border border-zinc-200 bg-white px-2.5 py-0.5 text-zinc-600 transition hover:bg-zinc-100"} class={recordFilter() === "all" ? "rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-0.5 font-medium text-zinc-700 active:bg-zinc-200" : "rounded-full border border-zinc-200 bg-white px-2.5 py-0.5 text-zinc-600 transition hover:bg-zinc-100 active:bg-zinc-200"}
onClick={() => setRecordFilter("all")} onClick={() => setRecordFilter("all")}
> >
{recordStats().total} {recordStats().total}
</button> </button>
<button <button
type="button" type="button"
class={recordFilter() === "unlearned" ? "rounded-full border border-amber-200 bg-amber-100 px-2.5 py-0.5 font-medium text-amber-700" : "rounded-full border border-amber-200 bg-white px-2.5 py-0.5 text-amber-700 transition hover:bg-amber-50"} class={recordFilter() === "unlearned" ? "rounded-full border border-amber-200 bg-amber-100 px-2.5 py-0.5 font-medium text-amber-700 active:bg-amber-200" : "rounded-full border border-amber-200 bg-white px-2.5 py-0.5 text-amber-700 transition hover:bg-amber-50 active:bg-amber-100"}
onClick={() => setRecordFilter("unlearned")} onClick={() => setRecordFilter("unlearned")}
> >
{recordStats().unlearned} {recordStats().unlearned}
</button> </button>
<button <button
type="button" type="button"
class={recordFilter() === "learned" ? "rounded-full border border-emerald-200 bg-emerald-100 px-2.5 py-0.5 font-medium text-emerald-700" : "rounded-full border border-emerald-200 bg-white px-2.5 py-0.5 text-emerald-700 transition hover:bg-emerald-50"} class={recordFilter() === "learned" ? "rounded-full border border-emerald-200 bg-emerald-100 px-2.5 py-0.5 font-medium text-emerald-700 active:bg-emerald-200" : "rounded-full border border-emerald-200 bg-white px-2.5 py-0.5 text-emerald-700 transition hover:bg-emerald-50 active:bg-emerald-100"}
onClick={() => setRecordFilter("learned")} onClick={() => setRecordFilter("learned")}
> >
{recordStats().learned} {recordStats().learned}
@@ -327,6 +338,17 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
{recordFilter() === "all" ? "全部" : recordFilter() === "unlearned" ? "未学" : "已学"} {recordFilter() === "all" ? "全部" : recordFilter() === "unlearned" ? "未学" : "已学"}
</p> </p>
</div> </div>
</Show>
<Show when={props.recordType === "/work" && props.workList.length > 0}>
<div class="flex items-center gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5 text-xs text-zinc-500">
{props.workList.length}
</div>
</Show>
<Show when={props.recordType === "/exam" && props.examList.length > 0}>
<div class="flex items-center gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5 text-xs text-zinc-500">
{props.examList.length}
</div>
</Show>
<div <div
class={ class={
@@ -360,6 +382,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
!props.recordsLoading && !props.recordsLoading &&
!props.recordError && !props.recordError &&
props.selectedCourse && props.selectedCourse &&
props.recordType === "" &&
filteredRecords().length === 0 filteredRecords().length === 0
} }
> >
@@ -370,59 +393,195 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</EmptyState> </EmptyState>
</Show> </Show>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}> <Show
<For each={filteredRecords()}> when={
{(record) => { !props.recordsLoading &&
const stateText = !props.recordError &&
props.renderRecordState(record.state) || "未知状态"; props.selectedCourse &&
const learned = props.recordType === "/work" &&
stateText.includes("已学") || record.progress === "1.00"; props.workList.length === 0
}
>
<EmptyState></EmptyState>
</Show>
return ( <Show
<div when={
class={ !props.recordsLoading &&
learned !props.recordError &&
? compact() props.selectedCourse &&
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm" props.recordType === "/exam" &&
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm" props.examList.length === 0
: compact() }
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm" >
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm" <EmptyState></EmptyState>
} </Show>
>
<div class="flex flex-wrap items-start justify-between gap-2"> <Show when={props.recordType === ""}>
<div class="min-w-0 flex-1"> <div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<p class="truncate text-sm font-semibold text-zinc-900"> <For each={filteredRecords()}>
{record.name} {(record) => {
</p> const stateText =
<p class="mt-1 text-xs text-zinc-500"> props.renderRecordState(record.state) || "未知状态";
ID{record.id} | {record.chapterId} const learned =
</p> stateText.includes("已学") || record.progress === "1.00";
return (
<div
class={
learned
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{record.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{record.id} | {record.chapterId}
</p>
</div>
<span
class={
learned
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div> </div>
<span
class={
learned
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3"> <div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{record.videoDuration}</p> <p>{record.videoDuration}</p>
<p>{record.duration}</p> <p>{record.duration}</p>
<p>{record.progress}</p> <p>{record.progress}</p>
<p>{record.beginTime || "-"}</p> <p>{record.beginTime || "-"}</p>
<p>{record.finalTime || "-"}</p> <p>{record.finalTime || "-"}</p>
<p>{record.viewCount}</p> <p>{record.viewCount}</p>
</div>
</div> </div>
</div> );
); }}
}} </For>
</For> </div>
</div> </Show>
<Show when={props.recordType === "/work"}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={props.workList}>
{(work) => {
const stateRaw = stripHtml(work.state);
const stateText = stateRaw || "未做";
const done = stateRaw.includes("已阅") || stateRaw.includes("已提交") || stateRaw.includes("已完成");
return (
<div
class={
done
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{work.title || work.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{work.id} | {work.chapterId}
</p>
</div>
<span
class={
done
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{work.typeName || work.type || "-"}</p>
<p>{work.score || "-"}</p>
<p>{stripHtml(work.finalScore) || "-"}</p>
<p>{work.topicNumber || "-"}</p>
<p>{work.addTime || "-"}</p>
<p>{work.finishTime !== "-" ? work.finishTime : "-"}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
<Show when={props.recordType === "/exam"}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={props.examList}>
{(exam) => {
const stateRaw = stripHtml(exam.state);
const stateText = stateRaw || "未做";
const done = stateRaw.includes("已阅") || stateRaw.includes("已提交") || stateRaw.includes("已完成");
return (
<div
class={
done
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{exam.title || exam.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{exam.id} | {exam.chapterId}
</p>
</div>
<span
class={
done
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{exam.limitedTime ? `${exam.limitedTime}分钟` : "-"}</p>
<p>{exam.score || "-"}</p>
<p>{stripHtml(exam.finalScore) || "-"}</p>
<p>{exam.topicNumber || "-"}</p>
<p>{exam.addTime || "-"}</p>
<p>{exam.finishTime !== "-" ? exam.finishTime : "-"}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
</div> </div>
</div> </div>
@@ -440,7 +599,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingLogs} disabled={props.isRefreshingLogs}
onClick={props.onRefreshLogs} onClick={props.onRefreshLogs}
> >
@@ -448,7 +607,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</button> </button>
<button <button
type="button" type="button"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100" class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={props.onClearLogs} onClick={props.onClearLogs}
> >

View File

@@ -1,39 +0,0 @@
import type { CourseType } from "~/types/Course";
import type { Accessor } from "solid-js";
const courseLabel: [string, keyof CourseType][] = [
["课程", "name"],
["课程号", "id"],
["老师", "teacher"],
["进度", "progress"],
["开始时间", "start_time"],
["结束时间", "stop_time"],
["学分", "credit"],
["类型", "type"],
];
interface CourseListProps {
courseList: Accessor<CourseType[]>;
}
const CourseList = (props: CourseListProps) => {
return (
<div class="m4-2 flex min-h-0 flex-1 flex-col gap-y-4 overflow-y-auto p-5">
{props.courseList().map((course) => {
return (
<div class="grid grid-cols-3 rounded-xl border bg-zinc-200 p-4 py-2 shadow-sm">
{courseLabel.map(([labelText, field]) => {
return (
<p class="text-base leading-7">
{labelText}: <span>{course[field]}</span>
</p>
);
})}
</div>
);
})}
</div>
);
};
export default CourseList;

View File

@@ -60,7 +60,7 @@ const Dialog: ParentComponent<DialogProps> = (props) => {
</h2> </h2>
<button <button
type="button" type="button"
class="rounded-lg px-3 py-1 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900" class="rounded-lg px-3 py-1 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900 active:bg-zinc-200"
onClick={props.onClose} onClick={props.onClose}
> >

View File

@@ -20,8 +20,11 @@ import {
type CourseKind, type CourseKind,
type RecordItem, type RecordItem,
type RecordType, type RecordType,
type WorkListItem,
type ExamListItem,
} from "~/service/wk"; } from "~/service/wk";
import { setUnauthorizedHandler } from "~/service/http"; import { setUnauthorizedHandler } from "~/service/http";
import { startSilentAudio, stopSilentAudio } from "~/service/silentAudio";
import { accountStore, type AccountItem } from "~/store/account"; import { accountStore, type AccountItem } from "~/store/account";
import { getMergedHosts, settingsStore } from "~/store/settings"; import { getMergedHosts, settingsStore } from "~/store/settings";
import type { CourseType } from "~/types/Course"; import type { CourseType } from "~/types/Course";
@@ -196,7 +199,10 @@ const Account = () => {
const courseKind = createMemo(() => storeState().courseKind); const courseKind = createMemo(() => storeState().courseKind);
const recordType = createMemo(() => storeState().recordType); const recordType = createMemo(() => storeState().recordType);
const records = createMemo(() => storeState().records); const records = createMemo(() => storeState().records);
const workList = createMemo(() => storeState().workList);
const examList = createMemo(() => storeState().examList);
const recordCacheMap = createMemo(() => storeState().recordCacheMap); const recordCacheMap = createMemo(() => storeState().recordCacheMap);
const workExamCacheMap = createMemo(() => storeState().workExamCacheMap);
const studyLogsMap = createMemo(() => storeState().studyLogsMap); const studyLogsMap = createMemo(() => storeState().studyLogsMap);
const runningStudyMap = createMemo(() => storeState().runningStudyMap); const runningStudyMap = createMemo(() => storeState().runningStudyMap);
const mergedHostOptions = createMemo(() => const mergedHostOptions = createMemo(() =>
@@ -567,33 +573,83 @@ const Account = () => {
setRecordError(""); setRecordError("");
try { try {
const res = await getAccountClient(accountId).recordApi({ const cacheKey = createRecordCacheKey(accountId, courseId, nextRecordType);
course_id: String(courseId), const client = getAccountClient(accountId);
page: 0,
record_type: nextRecordType,
});
if (requestToken !== recordRequestToken) {
return;
}
const rawList = (res as { data?: { list?: unknown } })?.data?.list; if (nextRecordType === "/work") {
const list = (Array.isArray(rawList) ? rawList : []) as RecordItem[]; const res = await client.workListApi({
const snapshot = accountStore.getState(); course_id: String(courseId),
if ( page: 0,
snapshot.selectedAccountId !== accountId || record_type: "/work",
snapshot.selectedCourseId !== courseId || });
snapshot.recordType !== nextRecordType if (requestToken !== recordRequestToken) return;
) {
return;
}
accountStore.getState().setRecords(list); const rawList = (res as { data?: { list?: unknown } })?.data?.list;
accountStore const list = (Array.isArray(rawList) ? rawList : []) as WorkListItem[];
.getState() const snapshot = accountStore.getState();
.setRecordCache( if (
createRecordCacheKey(accountId, courseId, nextRecordType), snapshot.selectedAccountId !== accountId ||
list, snapshot.selectedCourseId !== courseId ||
); snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setWorkList(list);
accountStore.getState().setRecords([]);
accountStore.getState().setExamList([]);
accountStore.getState().setWorkExamCache(cacheKey, list);
} else if (nextRecordType === "/exam") {
const res = await client.examListApi({
course_id: String(courseId),
page: 0,
record_type: "/exam",
});
if (requestToken !== recordRequestToken) return;
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as ExamListItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setExamList(list);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setWorkExamCache(cacheKey, list);
} else {
const res = await client.recordApi({
course_id: String(courseId),
page: 0,
record_type: nextRecordType,
});
if (requestToken !== recordRequestToken) {
return;
}
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as RecordItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setRecords(list);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
accountStore
.getState()
.setRecordCache(cacheKey, list);
}
} catch (error) { } catch (error) {
if (requestToken !== recordRequestToken) { if (requestToken !== recordRequestToken) {
return; return;
@@ -602,6 +658,8 @@ const Account = () => {
error instanceof Error ? error.message : "获取记录失败,请稍后重试。"; error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
setRecordError(message); setRecordError(message);
accountStore.getState().setRecords([]); accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
} finally { } finally {
if (requestToken === recordRequestToken) { if (requestToken === recordRequestToken) {
setRecordsLoading(false); setRecordsLoading(false);
@@ -665,6 +723,7 @@ const Account = () => {
try { try {
accountStore.getState().setAccountRunningStudy(account.id, true); accountStore.getState().setAccountRunningStudy(account.id, true);
touchStudyHeartbeat(account.id); touchStudyHeartbeat(account.id);
startSilentAudio();
appendStudyLog(`开始刷课:${course.name}`, account.id); appendStudyLog(`开始刷课:${course.name}`, account.id);
await runStudyQueue({ await runStudyQueue({
accountId: account.id, accountId: account.id,
@@ -677,6 +736,7 @@ const Account = () => {
setIsRunningStudy: () => { setIsRunningStudy: () => {
accountStore.getState().setAccountRunningStudy(account.id, false); accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id); clearStudyHeartbeat(account.id);
stopSilentAudio();
}, },
onLog: (message: string, accoundID: string) => { onLog: (message: string, accoundID: string) => {
touchStudyHeartbeat(accoundID); touchStudyHeartbeat(accoundID);
@@ -694,6 +754,7 @@ const Account = () => {
} finally { } finally {
accountStore.getState().setAccountRunningStudy(account.id, false); accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id); clearStudyHeartbeat(account.id);
stopSilentAudio();
} }
}; };
@@ -705,6 +766,7 @@ const Account = () => {
accountStore.getState().setAccountRunningStudy(account.id, false); accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id); clearStudyHeartbeat(account.id);
stopSilentAudio();
appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id); appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
}; };
@@ -729,10 +791,23 @@ const Account = () => {
return; return;
} }
const cachedRecords = const cacheKey = createRecordCacheKey(accountId, courseId, type);
recordCacheMap()[createRecordCacheKey(accountId, courseId, type)] ?? if (type === "/work" || type === "/exam") {
[]; const cachedData = workExamCacheMap()[cacheKey] ?? [];
accountStore.getState().setRecords(cachedRecords); if (type === "/work") {
accountStore.getState().setWorkList(cachedData as WorkListItem[]);
accountStore.getState().setExamList([]);
} else {
accountStore.getState().setExamList(cachedData as ExamListItem[]);
accountStore.getState().setWorkList([]);
}
accountStore.getState().setRecords([]);
} else {
const cachedRecords = recordCacheMap()[cacheKey] ?? [];
accountStore.getState().setRecords(cachedRecords);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
}
}, },
), ),
); );
@@ -744,12 +819,19 @@ const Account = () => {
const cacheKey = account const cacheKey = account
? createRecordCacheKey(account.id, courseId ?? 0, type) ? createRecordCacheKey(account.id, courseId ?? 0, type)
: ""; : "";
const hasCachedRecords = cacheKey ? cacheKey in recordCacheMap() : false; const hasCachedRecords = cacheKey
? cacheKey in recordCacheMap() || cacheKey in workExamCacheMap()
: false;
if (!hasRestoredRecords()) { if (!hasRestoredRecords()) {
setHasRestoredRecords(true); setHasRestoredRecords(true);
if (courseId && account && (records().length > 0 || hasCachedRecords)) { const hasAnyData =
records().length > 0 ||
workList().length > 0 ||
examList().length > 0 ||
hasCachedRecords;
if (courseId && account && hasAnyData) {
return; return;
} }
} }
@@ -818,6 +900,8 @@ const Account = () => {
recordTypeOptions={recordTypeOptions} recordTypeOptions={recordTypeOptions}
courseRecordTypeOptions={statusOptions} courseRecordTypeOptions={statusOptions}
records={records()} records={records()}
workList={workList()}
examList={examList()}
studyLogs={studyLogs()} studyLogs={studyLogs()}
recordsLoading={recordsLoading()} recordsLoading={recordsLoading()}
recordError={recordError()} recordError={recordError()}
@@ -840,6 +924,8 @@ const Account = () => {
accountStore.getState().setCourseKind(value); accountStore.getState().setCourseKind(value);
accountStore.getState().setSelectedCourseId(null); accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]); accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
}} }}
onStartStudy={() => void handleStartStudy()} onStartStudy={() => void handleStartStudy()}
onStopStudy={handleStopStudy} onStopStudy={handleStopStudy}

View File

@@ -469,7 +469,7 @@ const DebugLogs = () => {
<div class="flex flex-wrap items-center gap-2 xl:justify-end"> <div class="flex flex-wrap items-center gap-2 xl:justify-end">
<button <button
type="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" 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 active:bg-cyan-400/30"
onClick={connectDebugSocket} onClick={connectDebugSocket}
disabled={ disabled={
debugSocketState() === "open" || debugSocketState() === "open" ||
@@ -480,21 +480,21 @@ const DebugLogs = () => {
</button> </button>
<button <button
type="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" 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 active:bg-white/20"
onClick={() => void loadDebugSnapshot()} onClick={() => void loadDebugSnapshot()}
> >
</button> </button>
<button <button
type="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" 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 active:bg-cyan-400/30"
onClick={downloadDebugLogs} onClick={downloadDebugLogs}
> >
</button> </button>
<button <button
type="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" 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 active:bg-white/20"
onClick={disconnectDebugSocket} onClick={disconnectDebugSocket}
disabled={debugSocketState() !== "open"} disabled={debugSocketState() !== "open"}
> >
@@ -502,7 +502,7 @@ const DebugLogs = () => {
</button> </button>
<button <button
type="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" 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 active:bg-rose-400/30"
onClick={clearDebugLogs} onClick={clearDebugLogs}
> >
@@ -543,7 +543,7 @@ const DebugLogs = () => {
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 ${ 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 selectedDebugEntryId() === entry.id
? "border-cyan-300/35 bg-cyan-400/10" ? "border-cyan-300/35 bg-cyan-400/10"
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6" : "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6 active:bg-white/10"
}`} }`}
onClick={() => setSelectedDebugEntryId(entry.id)} onClick={() => setSelectedDebugEntryId(entry.id)}
> >
@@ -590,7 +590,7 @@ const DebugLogs = () => {
class={`rounded-full border px-3 py-1.5 text-xs transition ${ class={`rounded-full border px-3 py-1.5 text-xs transition ${
selectedDetailTab() === tab.id selectedDetailTab() === tab.id
? "border-cyan-300/35 bg-cyan-400/12 text-cyan-100" ? "border-cyan-300/35 bg-cyan-400/12 text-cyan-100"
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10" : "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 active:bg-white/20"
}`} }`}
onClick={() => setSelectedDetailTab(tab.id)} onClick={() => setSelectedDetailTab(tab.id)}
> >

View File

@@ -198,7 +198,7 @@ const Logs = () => {
</Show> </Show>
<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 active:bg-rose-100 active:scale-[0.97]"
onClick={clearAllLogs} onClick={clearAllLogs}
> >

View File

@@ -1,4 +1,4 @@
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 { 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";
@@ -117,414 +117,367 @@ const Setting = () => {
void refreshDebugConfig(); void refreshDebugConfig();
}); });
const panelClass =
"rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]";
const sectionCardClass = "rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4";
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="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(135deg,rgba(255,255,255,0.92),rgba(240,249,255,0.96))] px-5 py-4 shadow-[0_18px_45px_-28px_rgba(15,23,42,0.18)]"> <div class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(125deg,rgba(255,255,255,0.96),rgba(240,249,255,0.95)_55%,rgba(236,254,255,0.9))] px-6 py-5 shadow-[0_22px_50px_-32px_rgba(15,23,42,0.22)]">
<div> <div>
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase"> <p class="text-xs font-semibold tracking-[0.28em] text-cyan-700/80 uppercase">
Settings Center Preference Center
</p> </p>
<h1 class="mt-2 text-2xl font-semibold text-zinc-900"></h1> <h1 class="mt-2 text-2xl font-semibold text-zinc-900"></h1>
<p class="mt-1 text-sm text-zinc-500"> <p class="mt-1 text-sm text-zinc-500">
Host Host
</p> </p>
</div> </div>
<div class="rounded-2xl border border-zinc-200 bg-white/90 px-4 py-3 text-right shadow-sm"> <div class="rounded-2xl border border-zinc-200/80 bg-white/90 px-4 py-3 text-right shadow-sm">
<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-xs text-zinc-500"> <p class="mt-1 text-xs leading-5 text-zinc-500">
Host Host
</p> </p>
</div> </div>
</div> </div>
<div class="mt-4 grid min-h-0 flex-1 gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]"> <div class="mt-5 min-h-0 flex-1 overflow-hidden">
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1"> <div class="grid h-full min-h-full w-full gap-5 lg:grid-cols-2">
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]"> <div class="flex min-h-0 h-full flex-col gap-5 overflow-y-auto pr-1">
<div class="flex items-center justify-between gap-4 border-b border-zinc-200 pb-4"> <section class={panelClass}>
<div> <div class="flex items-center justify-between gap-4">
<p class="text-lg font-semibold text-zinc-900"></p> <div>
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-600 transition hover:bg-rose-100 active:bg-rose-200"
onClick={() => settingsStore.getState().clearAllPersistedData()}
>
</button>
</div>
<div class="mt-4 grid gap-3">
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("accounts")
}
>
</button>
</div>
</div>
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("records")
}
>
</button>
</div>
</div>
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("logs")
}
>
</button>
</div>
</div>
</div>
</section>
<section class={panelClass}>
<div class="border-b border-zinc-200/70 pb-4">
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"> <p class="mt-1 text-sm text-zinc-500">
使
</p> </p>
</div> </div>
<button
type="button"
class="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-600 transition hover:bg-rose-100"
onClick={() => settingsStore.getState().clearAllPersistedData()}
>
</button>
</div>
<div class="mt-4 grid gap-4"> <div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4"> <div class={`${sectionCardClass} md:col-span-2`}>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div> <div>
<p class="font-medium text-zinc-900"></p> <p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"> <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> </p>
</div> </div>
<input {debugError() ? (
type="checkbox" <div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
checked={state().persistAccounts} {debugError()}
onChange={(event) => </div>
settingsStore ) : null}
.getState()
.setPersistSection(
"accounts",
event.currentTarget.checked,
)
}
/>
</div> </div>
<button
type="button"
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
onClick={() =>
settingsStore.getState().clearPersistedSection("accounts")
}
>
</button>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4"> <div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div> <div>
<p class="font-medium text-zinc-900"></p> <p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"> <p class="mt-1 text-sm text-zinc-500"></p>
</div>
</p> <input
type="checkbox"
checked={state().autoScrollLogs}
onChange={(event) =>
settingsStore
.getState()
.setAutoScrollLogs(event.currentTarget.checked)
}
/>
</div> </div>
<input
type="checkbox"
checked={state().persistRecords}
onChange={(event) =>
settingsStore
.getState()
.setPersistSection(
"records",
event.currentTarget.checked,
)
}
/>
</div> </div>
<button
type="button"
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
onClick={() =>
settingsStore.getState().clearPersistedSection("records")
}
>
</button>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4"> <div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div> <div>
<p class="font-medium text-zinc-900"></p> <p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"> <p class="mt-1 text-sm text-zinc-500"></p>
</div>
</p> <input
type="checkbox"
checked={state().showLogTimestamps}
onChange={(event) =>
settingsStore
.getState()
.setShowLogTimestamps(event.currentTarget.checked)
}
/>
</div> </div>
<input
type="checkbox"
checked={state().persistLogs}
onChange={(event) =>
settingsStore
.getState()
.setPersistSection("logs", event.currentTarget.checked)
}
/>
</div> </div>
<button
type="button" <div class={sectionCardClass}>
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100" <label class="block">
onClick={() => <p class="font-medium text-zinc-900"></p>
settingsStore.getState().clearPersistedSection("logs") <p class="mt-1 text-sm text-zinc-500"></p>
} <select
> class="mt-3 w-full rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={state().densityMode}
</button> onChange={(event) =>
settingsStore
.getState()
.setDensityMode(
event.currentTarget.value as
| "comfortable"
| "compact",
)
}
>
<option value="comfortable"></option>
<option value="compact"></option>
</select>
</label>
</div>
<div class={sectionCardClass}>
<label class="block">
<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">{state().logFontSize}px</p>
</div>
</div>
<input
class="mt-4 w-full"
type="range"
min="11"
max="16"
value={state().logFontSize}
onInput={(event) =>
settingsStore
.getState()
.setLogFontSize(Number(event.currentTarget.value))
}
/>
</label>
</div>
<div class={`${sectionCardClass} md:col-span-2`}>
<label class="block">
<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">{state().sidebarWidth}px</p>
</div>
</div>
<input
class="mt-4 w-full"
type="range"
min="280"
max="380"
step="10"
value={state().sidebarWidth}
onInput={(event) =>
settingsStore
.getState()
.setSidebarWidth(Number(event.currentTarget.value))
}
/>
</label>
</div>
</div> </div>
</div> </section>
</div> </div>
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]"> <div class="flex min-h-0 h-full flex-col gap-5 overflow-y-auto pr-1">
<div class="border-b border-zinc-200 pb-4"> <section class={panelClass}>
<p class="text-lg font-semibold text-zinc-900"></p> <div class="border-b border-zinc-200 pb-4">
<p class="mt-1 text-sm text-zinc-500"> <p class="text-lg font-semibold text-zinc-900">Host </p>
使 <p class="mt-1 text-sm text-zinc-500">
</p> Host Host
</div> </p>
</div>
<div class="mt-4 grid gap-4"> <div class={`${sectionCardClass} mt-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> <p class="font-medium text-zinc-900"> Host</p>
<p class="font-medium text-zinc-900"></p> <button
<p class="mt-1 text-sm text-zinc-500"> type="button"
/ SSL class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
</p> disabled={isLoadingRemoteHosts()}
</div> onClick={() => void loadRemoteHosts()}
>
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
</button>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<input <input
type="checkbox" class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
checked={state().debugEnabled} value={hostLabel()}
disabled={debugSyncing()} onInput={(event) => setHostLabel(event.currentTarget.value)}
onChange={(event) => placeholder="名称,如:校内测试"
void handleDebugToggle(event.currentTarget.checked) />
} <input
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={hostValue()}
onInput={(event) => setHostValue(event.currentTarget.value)}
placeholder="Hostexample.com"
/> />
</div> </div>
<div class="mt-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600"> <button
<p>{backendDebugState()?.enabled ? "已开启" : "已关闭"}</p> type="button"
<p class="mt-1"> class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600 active:bg-cyan-700"
{backendDebugState()?.buildMode ?? "-"} onClick={addLocalHost}
</p> >
<p class="mt-1"> Host
</button>
{backendDebugState()?.proxyConfigured
? backendDebugState()?.proxy {hostError() ? (
: "未配置"}
</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"> <div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{debugError()} {hostError()}
</div> </div>
) : null} ) : null}
</div> </div>
</section>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4"> <section class={panelClass}>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between border-b border-zinc-200 pb-4">
<div> <p class="text-lg font-semibold text-zinc-900">Host </p>
<p class="font-medium text-zinc-900"></p> <p class="text-xs text-zinc-500"></p>
<p class="mt-1 text-sm text-zinc-500">
</p>
</div>
<input
type="checkbox"
checked={state().autoScrollLogs}
onChange={(event) =>
settingsStore
.getState()
.setAutoScrollLogs(event.currentTarget.checked)
}
/>
</div>
</div> </div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4"> <div class="mt-4 grid gap-4 xl:grid-cols-2">
<div class="flex items-center justify-between gap-3"> <div class={sectionCardClass}>
<div> <p class="font-medium text-zinc-900"> Host</p>
<p class="font-medium text-zinc-900"></p> <div class="mt-3 flex flex-col gap-3">
<p class="mt-1 text-sm text-zinc-500"> <For each={state().localHosts}>
{(item) => (
</p> <div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
</div> <div class="flex items-start justify-between gap-3">
<input <div>
type="checkbox" <p class="font-medium text-zinc-900">{item.label}</p>
checked={state().showLogTimestamps} <p class="mt-1 text-sm text-zinc-500">{item.host}</p>
onChange={(event) => </div>
settingsStore <button
.getState() type="button"
.setShowLogTimestamps(event.currentTarget.checked) class="rounded-lg border border-rose-200 px-3 py-1 text-xs text-rose-600 transition hover:bg-rose-50 active:bg-rose-100"
} onClick={() =>
/> settingsStore.getState().removeLocalHost(item.host)
</div> }
</div> >
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4"> </button>
<label class="block">
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
</p>
<select
class="mt-3 w-full rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={state().densityMode}
onChange={(event) =>
settingsStore
.getState()
.setDensityMode(
event.currentTarget.value as
| "comfortable"
| "compact",
)
}
>
<option value="comfortable"></option>
<option value="compact"></option>
</select>
</label>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<label class="block">
<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">
{state().logFontSize}px
</p>
</div>
</div>
<input
class="mt-4 w-full"
type="range"
min="11"
max="16"
value={state().logFontSize}
onInput={(event) =>
settingsStore
.getState()
.setLogFontSize(Number(event.currentTarget.value))
}
/>
</label>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<label class="block">
<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">
{state().sidebarWidth}px
</p>
</div>
</div>
<input
class="mt-4 w-full"
type="range"
min="280"
max="380"
step="10"
value={state().sidebarWidth}
onInput={(event) =>
settingsStore
.getState()
.setSidebarWidth(Number(event.currentTarget.value))
}
/>
</label>
</div>
</div>
</div>
</section>
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
<div class="border-b border-zinc-200 pb-4">
<p class="text-lg font-semibold text-zinc-900">Host </p>
<p class="mt-1 text-sm text-zinc-500">
Host Host
</p>
</div>
<div class="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3">
<p class="font-medium text-zinc-900"> Host</p>
<button
type="button"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoadingRemoteHosts()}
onClick={() => void loadRemoteHosts()}
>
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
</button>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<input
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={hostLabel()}
onInput={(event) => setHostLabel(event.currentTarget.value)}
placeholder="名称,如:校内测试"
/>
<input
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={hostValue()}
onInput={(event) => setHostValue(event.currentTarget.value)}
placeholder="Hostexample.com"
/>
</div>
<button
type="button"
class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600"
onClick={addLocalHost}
>
Host
</button>
{hostError() ? (
<div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{hostError()}
</div>
) : null}
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-2">
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<p class="font-medium text-zinc-900"> Host</p>
<div class="mt-3 flex flex-col gap-3">
<For each={state().localHosts}>
{(item) => (
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium text-zinc-900">
{item.label}
</p>
<p class="mt-1 text-sm text-zinc-500">
{item.host}
</p>
</div> </div>
<button
type="button"
class="rounded-lg border border-rose-200 px-3 py-1 text-xs text-rose-600 transition hover:bg-rose-50"
onClick={() =>
settingsStore
.getState()
.removeLocalHost(item.host)
}
>
</button>
</div> </div>
</div> )}
)} </For>
</For> </div>
</div> </div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4"> <div class={sectionCardClass}>
<p class="font-medium text-zinc-900"></p> <p class="font-medium text-zinc-900"></p>
<div class="mt-3 flex flex-col gap-3"> <div class="mt-3 flex flex-col gap-3">
<For each={mergedHosts()}> <For each={mergedHosts()}>
{(item) => ( {(item) => (
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3"> <div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
<p class="font-medium text-zinc-900">{item.label}</p> <p class="font-medium text-zinc-900">{item.label}</p>
<p class="mt-1 text-sm text-zinc-500">{item.host}</p> <p class="mt-1 text-sm text-zinc-500">{item.host}</p>
<p class="mt-2 text-xs text-cyan-700"> <p class="mt-2 text-xs text-cyan-700">
{item.source === "local" ? "本地优先" : "远端"} {item.source === "local" ? "本地优先" : "远端"}
</p> </p>
</div> </div>
)} )}
</For> </For>
</div>
</div> </div>
</div> </div>
</div> </section>
</div> </div>
</section> </div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,57 @@
/**
* Silent audio playback to prevent browser tab throttling during long-running tasks.
*
* Uses the Web Audio API to produce a nearly inaudible signal that keeps the
* browser from suspending the tab's timers and network requests.
*/
let audioContext: AudioContext | null = null;
let oscillatorNode: OscillatorNode | null = null;
let gainNode: GainNode | null = null;
/**
* Start playing silent audio. Safe to call multiple times — duplicate calls
* are ignored if audio is already playing.
*/
export const startSilentAudio = () => {
if (oscillatorNode) {
return;
}
try {
audioContext = new AudioContext();
gainNode = audioContext.createGain();
gainNode.gain.value = 0.001; // Nearly silent
oscillatorNode = audioContext.createOscillator();
oscillatorNode.type = "sine";
oscillatorNode.frequency.value = 1; // Sub-bass, inaudible
oscillatorNode.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillatorNode.start();
} catch {
// AudioContext may be unavailable in some environments; degrade silently.
oscillatorNode = null;
gainNode = null;
audioContext = null;
}
};
/**
* Stop playing silent audio and release resources.
*/
export const stopSilentAudio = () => {
try {
oscillatorNode?.stop();
} catch {
// Already stopped or never started
}
oscillatorNode?.disconnect();
gainNode?.disconnect();
audioContext?.close();
oscillatorNode = null;
gainNode = null;
audioContext = null;
};

View File

@@ -1,6 +1,5 @@
import type { Accessor } from "solid-js"; import type { Accessor } from "solid-js";
import http, { import http, {
DEFAULT_HTTP_TIMEOUT_MS,
createHttpClient, createHttpClient,
type HttpClient, type HttpClient,
} from "~/service/http"; } from "~/service/http";
@@ -13,6 +12,80 @@ export type RecordType = "" | "/work" | "/exam" | "/discuss";
export type StudyStatus = 1 | 2 | 3; export type StudyStatus = 1 | 2 | 3;
export type WorkListItem = {
id: string;
userId: string | number | null;
title: string;
topicNumber: string;
score: string;
type: string;
remarks: string;
addTime: string;
sequence: string;
nodeId: string;
courseId: string;
startTime: string;
endTime: string;
paperId: string;
createUserId: string;
isPrivate: string;
classList: string;
teacherType: string;
allow: string;
frequency: string;
scoringRules: string;
hasCollect: string;
lock: string | number | null;
schoolId: string;
parsing: string;
addDate: string;
name: string;
chapterId: string;
state: string;
submitTime: string;
finalScore: string;
typeName: string;
finishTime: string;
url: string;
};
export type ExamListItem = {
id: string;
userId: string | number | null;
title: string;
topicNumber: string;
score: string;
addTime: string;
nodeId: string;
courseId: string;
limitedTime: string;
sequence: string;
remarks: string;
paperId: string;
startTime: string;
endTime: string;
createUserId: string;
classList: string;
isPrivate: string;
teacherType: string;
allow: string;
frequency: string;
hasCollect: string;
schoolId: string;
parsing: string;
addDate: string;
random: string;
randData: unknown;
randNumber: string;
name: string;
chapterId: string;
state: string;
submitTime: string;
finalScore: string;
finishTime: string;
url: string;
};
type ApiResponse<T> = { type ApiResponse<T> = {
code: number; code: number;
message: string; message: string;
@@ -165,16 +238,33 @@ export type StudyRunnerPayload = {
onLog?: (message: string, accoundID: string) => void; onLog?: (message: string, accoundID: string) => void;
}; };
export type WorkListData = {
list: WorkListItem[];
page_info: PageInfo;
};
export type WorkListRes = ApiResponse<WorkListData>;
export type ExamListData = {
list: ExamListItem[];
page_info: PageInfo;
};
export type ExamListRes = ApiResponse<ExamListData>;
export type WkClient = { export type WkClient = {
userInfoApi: () => Promise<UserInfoRes>; userInfoApi: () => Promise<UserInfoRes>;
courseApi: (payload: CourseReq) => Promise<CourseRes>; courseApi: (payload: CourseReq) => Promise<CourseRes>;
recordApi: (payload: RecordReq) => Promise<RecordRes>; recordApi: (payload: RecordReq) => Promise<RecordRes>;
workListApi: (payload: RecordReq) => Promise<WorkListRes>;
examListApi: (payload: RecordReq) => Promise<ExamListRes>;
studyApi: (payload: StudyReq) => Promise<StudyRes>; studyApi: (payload: StudyReq) => Promise<StudyRes>;
logoutApi: () => Promise<LogoutRes>; logoutApi: () => Promise<LogoutRes>;
}; };
const RECORD_API_TIMEOUT_MS = 60000; const RECORD_API_TIMEOUT_MS = 60000;
const COURSE_API_TIMEOUT_MS = Math.max(DEFAULT_HTTP_TIMEOUT_MS, 30000); // Course list can be slow on large accounts, use a longer timeout than default
const COURSE_API_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);
@@ -195,6 +285,22 @@ const createWkClientFromHttp = (client: HttpClient): WkClient => ({
timeout: RECORD_API_TIMEOUT_MS, timeout: RECORD_API_TIMEOUT_MS,
}); });
}, },
workListApi(payload) {
return client.post<WorkListRes>("/api/v2/record", {
...payload,
record_type: "/work",
}, {
timeout: RECORD_API_TIMEOUT_MS,
});
},
examListApi(payload) {
return client.post<ExamListRes>("/api/v2/record", {
...payload,
record_type: "/exam",
}, {
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);
}, },
@@ -313,7 +419,7 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
continue; continue;
} }
if (resp.data.state != 0) { if (resp.data.state !== 0) {
_payload.onLog?.(`⛔ 自动停止: ${resp.data.msg}`, _payload.accountId); _payload.onLog?.(`⛔ 自动停止: ${resp.data.msg}`, _payload.accountId);
_payload.setIsRunningStudy(); _payload.setIsRunningStudy();
return; return;

View File

@@ -1,6 +1,6 @@
import { createStore } from "zustand/vanilla"; import { createStore } from "zustand/vanilla";
import { persist, createJSONStorage } from "zustand/middleware"; import { persist, createJSONStorage } from "zustand/middleware";
import type { CourseKind, RecordItem, RecordType } from "~/service/wk"; import type { CourseKind, RecordItem, RecordType, WorkListItem, ExamListItem } from "~/service/wk";
import type { CourseType } from "~/types/Course"; import type { CourseType } from "~/types/Course";
import type { userInfoType } from "~/types/Userinfo"; import type { userInfoType } from "~/types/Userinfo";
@@ -39,6 +39,8 @@ export type AccountItem = {
export type RecordCacheMap = Record<string, RecordItem[]>; export type RecordCacheMap = Record<string, RecordItem[]>;
export type WorkExamCacheMap = Record<string, WorkListItem[] | ExamListItem[]>;
type PersistPreferences = { type PersistPreferences = {
persistAccounts: boolean; persistAccounts: boolean;
persistRecords: boolean; persistRecords: boolean;
@@ -87,6 +89,9 @@ type AccountState = {
recordType: RecordType; recordType: RecordType;
records: RecordItem[]; records: RecordItem[];
recordCacheMap: RecordCacheMap; recordCacheMap: RecordCacheMap;
workList: WorkListItem[];
examList: ExamListItem[];
workExamCacheMap: WorkExamCacheMap;
studyLogsMap: Record<string, string[]>; studyLogsMap: Record<string, string[]>;
runningStudyMap: Record<string, boolean>; runningStudyMap: Record<string, boolean>;
studyHeartbeatMap: Record<string, number>; studyHeartbeatMap: Record<string, number>;
@@ -97,6 +102,9 @@ type AccountState = {
setRecordType: (recordType: RecordType) => void; setRecordType: (recordType: RecordType) => void;
setRecords: (records: RecordItem[]) => void; setRecords: (records: RecordItem[]) => void;
setRecordCache: (cacheKey: string, records: RecordItem[]) => void; setRecordCache: (cacheKey: string, records: RecordItem[]) => void;
setWorkList: (list: WorkListItem[]) => void;
setExamList: (list: ExamListItem[]) => void;
setWorkExamCache: (cacheKey: string, data: WorkListItem[] | ExamListItem[]) => void;
setAccountRunningStudy: (accountId: string, value: boolean) => void; setAccountRunningStudy: (accountId: string, value: boolean) => void;
touchStudyHeartbeat: (accountId: string, timestamp?: number) => void; touchStudyHeartbeat: (accountId: string, timestamp?: number) => void;
clearStudyHeartbeat: (accountId: string) => void; clearStudyHeartbeat: (accountId: string) => void;
@@ -107,6 +115,9 @@ type AccountState = {
upsertAccount: (account: AccountItem) => void; upsertAccount: (account: AccountItem) => void;
setAccountCourses: (accountId: string, courses: CourseType[]) => void; setAccountCourses: (accountId: string, courses: CourseType[]) => void;
removeAccount: (accountId: string) => void; removeAccount: (accountId: string) => void;
clearAllData: () => void;
clearRecordsData: () => void;
clearAccountsData: () => void;
}; };
export const accountStore = createStore<AccountState>()( export const accountStore = createStore<AccountState>()(
@@ -120,6 +131,9 @@ export const accountStore = createStore<AccountState>()(
recordType: "", recordType: "",
records: [], records: [],
recordCacheMap: {}, recordCacheMap: {},
workList: [],
examList: [],
workExamCacheMap: {},
studyLogsMap: {}, studyLogsMap: {},
runningStudyMap: {}, runningStudyMap: {},
studyHeartbeatMap: {}, studyHeartbeatMap: {},
@@ -138,6 +152,15 @@ export const accountStore = createStore<AccountState>()(
[cacheKey]: records, [cacheKey]: records,
}, },
})), })),
setWorkList: (list) => set({ workList: list }),
setExamList: (list) => set({ examList: list }),
setWorkExamCache: (cacheKey, data) =>
set((state) => ({
workExamCacheMap: {
...state.workExamCacheMap,
[cacheKey]: data,
},
})),
setAccountRunningStudy: (accountId, value) => setAccountRunningStudy: (accountId, value) =>
set((state) => ({ set((state) => ({
runningStudyMap: { runningStudyMap: {
@@ -189,6 +212,39 @@ export const accountStore = createStore<AccountState>()(
set({ set({
studyLogsMap: {}, studyLogsMap: {},
}), }),
clearAllData: () =>
set({
accounts: [],
selectedAccountId: "",
expandedAccountId: "",
selectedCourseId: null,
courseKind: "run" as CourseKind,
recordType: "" as RecordType,
records: [],
recordCacheMap: {},
workList: [],
examList: [],
workExamCacheMap: {},
studyLogsMap: {},
runningStudyMap: {},
studyHeartbeatMap: {},
}),
clearRecordsData: () =>
set({
records: [],
recordCacheMap: {},
workList: [],
examList: [],
workExamCacheMap: {},
selectedCourseId: null,
}),
clearAccountsData: () =>
set({
accounts: [],
selectedAccountId: "",
expandedAccountId: "",
selectedCourseId: null,
}),
upsertAccount: (account) => upsertAccount: (account) =>
set((state) => ({ set((state) => ({
accounts: [ accounts: [

View File

@@ -1,5 +1,6 @@
import { createStore } from "zustand/vanilla"; import { createStore } from "zustand/vanilla";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import { accountStore } from "~/store/account";
export type HostOption = { export type HostOption = {
label: string; label: string;
@@ -37,6 +38,7 @@ type SettingsState = {
}; };
const accountStorageKey = "account-storage"; const accountStorageKey = "account-storage";
const settingsStorageKey = "settings-storage";
type PersistedStorage = { type PersistedStorage = {
state?: Record<string, unknown>; state?: Record<string, unknown>;
version?: number; version?: number;
@@ -115,53 +117,40 @@ export const settingsStore = createStore<SettingsState>()(
} }
}, },
clearPersistedSection: (section) => { clearPersistedSection: (section) => {
const store = accountStore.getState();
if (section === "accounts") { if (section === "accounts") {
patchAccountStorage((state) => { store.clearAccountsData();
const { localStorage.removeItem(accountStorageKey);
accounts,
selectedAccountId,
expandedAccountId,
selectedCourseId,
records,
recordCacheMap,
studyLogsMap,
runningStudyMap,
...rest
} = state;
void accounts;
void selectedAccountId;
void expandedAccountId;
void selectedCourseId;
void records;
void recordCacheMap;
void studyLogsMap;
void runningStudyMap;
return rest;
});
} }
if (section === "records") { if (section === "records") {
store.clearRecordsData();
patchAccountStorage((state) => { patchAccountStorage((state) => {
const { records, recordCacheMap, selectedCourseId, ...rest } = const { records, recordCacheMap, workList, examList, workExamCacheMap, ...rest } = state;
state;
void records; void records;
void recordCacheMap; void recordCacheMap;
void selectedCourseId; void workList;
void examList;
void workExamCacheMap;
return rest; return rest;
}); });
} }
if (section === "logs") { if (section === "logs") {
store.clearAllStudyLogs();
patchAccountStorage((state) => { patchAccountStorage((state) => {
const { studyLogsMap, runningStudyMap, ...rest } = state; const { studyLogsMap, ...rest } = state;
void studyLogsMap; void studyLogsMap;
void runningStudyMap;
return rest; return rest;
}); });
} }
}, },
clearAllPersistedData: () => { clearAllPersistedData: () => {
accountStore.getState().clearAllData();
localStorage.removeItem(accountStorageKey); localStorage.removeItem(accountStorageKey);
localStorage.removeItem(settingsStorageKey);
window.location.reload();
}, },
setDebugEnabled: (value) => set({ debugEnabled: value }), setDebugEnabled: (value) => set({ debugEnabled: value }),
setAutoScrollLogs: (value) => set({ autoScrollLogs: value }), setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),