Compare commits
4 Commits
13f0be162b
...
a182c64f82
| Author | SHA1 | Date | |
|---|---|---|---|
| a182c64f82 | |||
| 0c0d2a0292 | |||
| 5d4e0f493c | |||
| a1911573d1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
doc
|
doc
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
134
src/App.tsx
134
src/App.tsx
@@ -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>
|
</div>
|
||||||
|
</details>
|
||||||
|
</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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,6 +393,31 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
</EmptyState>
|
</EmptyState>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!props.recordsLoading &&
|
||||||
|
!props.recordError &&
|
||||||
|
props.selectedCourse &&
|
||||||
|
props.recordType === "/work" &&
|
||||||
|
props.workList.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmptyState>当前课程下没有作业记录。</EmptyState>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!props.recordsLoading &&
|
||||||
|
!props.recordError &&
|
||||||
|
props.selectedCourse &&
|
||||||
|
props.recordType === "/exam" &&
|
||||||
|
props.examList.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmptyState>当前课程下没有考试记录。</EmptyState>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.recordType === ""}>
|
||||||
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
|
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
|
||||||
<For each={filteredRecords()}>
|
<For each={filteredRecords()}>
|
||||||
{(record) => {
|
{(record) => {
|
||||||
@@ -423,6 +471,117 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
}}
|
}}
|
||||||
</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}
|
||||||
>
|
>
|
||||||
清空日志
|
清空日志
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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}
|
||||||
>
|
>
|
||||||
关闭
|
关闭
|
||||||
|
|||||||
@@ -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,7 +573,57 @@ const Account = () => {
|
|||||||
setRecordError("");
|
setRecordError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getAccountClient(accountId).recordApi({
|
const cacheKey = createRecordCacheKey(accountId, courseId, nextRecordType);
|
||||||
|
const client = getAccountClient(accountId);
|
||||||
|
|
||||||
|
if (nextRecordType === "/work") {
|
||||||
|
const res = await client.workListApi({
|
||||||
|
course_id: String(courseId),
|
||||||
|
page: 0,
|
||||||
|
record_type: "/work",
|
||||||
|
});
|
||||||
|
if (requestToken !== recordRequestToken) return;
|
||||||
|
|
||||||
|
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
|
||||||
|
const list = (Array.isArray(rawList) ? rawList : []) as WorkListItem[];
|
||||||
|
const snapshot = accountStore.getState();
|
||||||
|
if (
|
||||||
|
snapshot.selectedAccountId !== accountId ||
|
||||||
|
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),
|
course_id: String(courseId),
|
||||||
page: 0,
|
page: 0,
|
||||||
record_type: nextRecordType,
|
record_type: nextRecordType,
|
||||||
@@ -588,12 +644,12 @@ const Account = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
accountStore.getState().setRecords(list);
|
accountStore.getState().setRecords(list);
|
||||||
|
accountStore.getState().setWorkList([]);
|
||||||
|
accountStore.getState().setExamList([]);
|
||||||
accountStore
|
accountStore
|
||||||
.getState()
|
.getState()
|
||||||
.setRecordCache(
|
.setRecordCache(cacheKey, list);
|
||||||
createRecordCacheKey(accountId, courseId, nextRecordType),
|
}
|
||||||
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] ?? [];
|
||||||
|
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().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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
清空全部日志
|
清空全部日志
|
||||||
|
|||||||
@@ -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,152 +117,116 @@ 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 class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg font-semibold text-zinc-900">本地数据</p>
|
<p class="text-lg font-semibold text-zinc-900">本地数据</p>
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
<p class="mt-1 text-xs text-zinc-500">管理本地缓存数据</p>
|
||||||
控制哪些数据会保存在本地,以及如何清理缓存
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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()}
|
onClick={() => settingsStore.getState().clearAllPersistedData()}
|
||||||
>
|
>
|
||||||
清空全部缓存
|
清空全部
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 grid gap-4">
|
<div class="mt-4 grid gap-3">
|
||||||
<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="text-sm font-medium text-zinc-900">账号缓存</p>
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
<p class="mt-1 text-xs text-zinc-500">账号、课程和登录信息</p>
|
||||||
保留账号、课程和登录相关信息
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={state().persistAccounts}
|
|
||||||
onChange={(event) =>
|
|
||||||
settingsStore
|
|
||||||
.getState()
|
|
||||||
.setPersistSection(
|
|
||||||
"accounts",
|
|
||||||
event.currentTarget.checked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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={() =>
|
onClick={() =>
|
||||||
settingsStore.getState().clearPersistedSection("accounts")
|
settingsStore.getState().clearPersistedSection("accounts")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
清空账号缓存
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</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="text-sm font-medium text-zinc-900">记录缓存</p>
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
<p class="mt-1 text-xs text-zinc-500">课程记录和筛选类型</p>
|
||||||
保留当前课程记录和筛选类型
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={state().persistRecords}
|
|
||||||
onChange={(event) =>
|
|
||||||
settingsStore
|
|
||||||
.getState()
|
|
||||||
.setPersistSection(
|
|
||||||
"records",
|
|
||||||
event.currentTarget.checked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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={() =>
|
onClick={() =>
|
||||||
settingsStore.getState().clearPersistedSection("records")
|
settingsStore.getState().clearPersistedSection("records")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
清空记录缓存
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</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="text-sm font-medium text-zinc-900">日志缓存</p>
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
<p class="mt-1 text-xs text-zinc-500">任务日志历史</p>
|
||||||
保留任务日志历史,刷新后继续查看
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={state().persistLogs}
|
|
||||||
onChange={(event) =>
|
|
||||||
settingsStore
|
|
||||||
.getState()
|
|
||||||
.setPersistSection("logs", event.currentTarget.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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={() =>
|
onClick={() =>
|
||||||
settingsStore.getState().clearPersistedSection("logs")
|
settingsStore.getState().clearPersistedSection("logs")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
清空日志缓存
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
|
<section class={panelClass}>
|
||||||
<div class="border-b border-zinc-200 pb-4">
|
<div class="border-b border-zinc-200/70 pb-4">
|
||||||
<p class="text-lg font-semibold text-zinc-900">界面偏好</p>
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -281,9 +245,7 @@ const Setting = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600">
|
<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>后端状态:{backendDebugState()?.enabled ? "已开启" : "已关闭"}</p>
|
||||||
<p class="mt-1">
|
<p class="mt-1">编译模式:{backendDebugState()?.buildMode ?? "-"}</p>
|
||||||
编译模式:{backendDebugState()?.buildMode ?? "-"}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1">
|
<p class="mt-1">
|
||||||
本地代理:
|
本地代理:
|
||||||
{backendDebugState()?.proxyConfigured
|
{backendDebugState()?.proxyConfigured
|
||||||
@@ -302,13 +264,11 @@ const Setting = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
新日志出现时自动滚动到最底部
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -322,13 +282,11 @@ const Setting = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
后续日志输出可以追加格式化时间
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -342,12 +300,10 @@ const Setting = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
<div class={sectionCardClass}>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<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>
|
||||||
选择更舒适或更紧凑的展示方式
|
|
||||||
</p>
|
|
||||||
<select
|
<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"
|
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}
|
value={state().densityMode}
|
||||||
@@ -367,14 +323,12 @@ const Setting = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
<div class={sectionCardClass}>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<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">当前:{state().logFontSize}px</p>
|
||||||
当前:{state().logFontSize}px
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -392,14 +346,12 @@ const Setting = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
<div class={`${sectionCardClass} md:col-span-2`}>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<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">当前:{state().sidebarWidth}px</p>
|
||||||
当前:{state().sidebarWidth}px
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -418,25 +370,24 @@ const Setting = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
<div class="flex min-h-0 h-full flex-col gap-5 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)]">
|
<section class={panelClass}>
|
||||||
<div class="border-b border-zinc-200 pb-4">
|
<div class="border-b border-zinc-200 pb-4">
|
||||||
<p class="text-lg font-semibold text-zinc-900">Host 配置策略</p>
|
<p class="text-lg font-semibold text-zinc-900">Host 配置策略</p>
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
远端 Host 后续通过接口请求,本地 Host
|
远端 Host 后续通过接口请求,本地 Host 手动添加,最终合并并去重,优先保留本地配置。
|
||||||
手动添加,最终合并并去重,优先保留本地配置。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
<div class={`${sectionCardClass} mt-4`}>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<p class="font-medium text-zinc-900">添加本地 Host</p>
|
<p class="font-medium text-zinc-900">添加本地 Host</p>
|
||||||
<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 hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
disabled={isLoadingRemoteHosts()}
|
disabled={isLoadingRemoteHosts()}
|
||||||
onClick={() => void loadRemoteHosts()}
|
onClick={() => void loadRemoteHosts()}
|
||||||
>
|
>
|
||||||
@@ -459,7 +410,7 @@ const Setting = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600"
|
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"
|
||||||
onClick={addLocalHost}
|
onClick={addLocalHost}
|
||||||
>
|
>
|
||||||
添加本地 Host
|
添加本地 Host
|
||||||
@@ -471,9 +422,16 @@ const Setting = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class={panelClass}>
|
||||||
|
<div class="flex items-center justify-between border-b border-zinc-200 pb-4">
|
||||||
|
<p class="text-lg font-semibold text-zinc-900">Host 列表</p>
|
||||||
|
<p class="text-xs text-zinc-500">本地配置优先覆盖远端</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 grid gap-4 xl:grid-cols-2">
|
<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">
|
<div class={sectionCardClass}>
|
||||||
<p class="font-medium text-zinc-900">本地 Host</p>
|
<p class="font-medium text-zinc-900">本地 Host</p>
|
||||||
<div class="mt-3 flex flex-col gap-3">
|
<div class="mt-3 flex flex-col gap-3">
|
||||||
<For each={state().localHosts}>
|
<For each={state().localHosts}>
|
||||||
@@ -481,20 +439,14 @@ const Setting = () => {
|
|||||||
<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">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-zinc-900">
|
<p class="font-medium text-zinc-900">{item.label}</p>
|
||||||
{item.label}
|
<p class="mt-1 text-sm text-zinc-500">{item.host}</p>
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
|
||||||
{item.host}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border border-rose-200 px-3 py-1 text-xs text-rose-600 transition hover:bg-rose-50"
|
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={() =>
|
onClick={() =>
|
||||||
settingsStore
|
settingsStore.getState().removeLocalHost(item.host)
|
||||||
.getState()
|
|
||||||
.removeLocalHost(item.host)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -506,7 +458,7 @@ const Setting = () => {
|
|||||||
</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()}>
|
||||||
@@ -523,10 +475,11 @@ const Setting = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
57
src/service/silentAudio.ts
Normal file
57
src/service/silentAudio.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user