diff --git a/package.json b/package.json index ddb674e..dff997c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.0.0", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index 9318fdd..da3edc0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,27 @@ -import type { ParentComponent } from "solid-js"; -import { createMemo, createResource, createSignal } from "solid-js"; +import type { JSX, ParentComponent } from "solid-js"; +import { + For, + Show, + createEffect, + createMemo, + createResource, + createSignal, + onCleanup, +} from "solid-js"; import { A, useLocation } from "@solidjs/router"; +import Dialog from "~/components/dialog/Dialog"; +import { + RELEASES_PAGE_URL, + type LatestRelease, + type ReleaseAsset, + type RuntimeTarget, + detectRuntimeTarget, + downloadReleaseAsset, + fetchLatestRelease, + isRemoteVersionNewer, + resolveAssetForRuntime, + resolveReleaseLink, +} from "~/service/update"; import { versionApi } from "~/service/wk"; const asideList = [ @@ -9,12 +30,138 @@ const asideList = [ { label: "设置", url: "/setting" }, ]; +type DownloadState = "idle" | "downloading" | "done" | "error"; +type UpdateCheckState = + | "idle" + | "checking" + | "latest" + | "available" + | "error"; +type MarkdownBlock = + | { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; text: string } + | { type: "paragraph"; text: string } + | { type: "list"; items: string[] }; + +const normalizeMarkdown = (value: string) => value.replace(/\r\n?/g, "\n"); + +const parseMarkdownBlocks = (markdown: string): MarkdownBlock[] => { + const lines = normalizeMarkdown(markdown).split("\n"); + const blocks: MarkdownBlock[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index].trim(); + if (!line) { + index += 1; + continue; + } + + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + const level = Math.min( + 6, + Math.max(1, headingMatch[1].length), + ) as 1 | 2 | 3 | 4 | 5 | 6; + blocks.push({ + type: "heading", + level, + text: headingMatch[2].trim(), + }); + index += 1; + continue; + } + + if (line.startsWith("- ")) { + const items: string[] = []; + while (index < lines.length) { + const nextLine = lines[index].trim(); + if (!nextLine.startsWith("- ")) { + break; + } + items.push(nextLine.slice(2).trim()); + index += 1; + } + blocks.push({ type: "list", items }); + continue; + } + + const paragraphLines: string[] = [line]; + index += 1; + while (index < lines.length) { + const nextLine = lines[index].trim(); + if (!nextLine || nextLine.startsWith("- ") || /^#{1,6}\s+/.test(nextLine)) { + break; + } + paragraphLines.push(nextLine); + index += 1; + } + blocks.push({ + type: "paragraph", + text: paragraphLines.join(" ").trim(), + }); + } + + return blocks; +}; + +const renderInlineLinks = (text: string): JSX.Element[] => { + const parts: JSX.Element[] = []; + const regex = /\[([^\]]+)\]\(([^)]+)\)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + const label = match[1] || "链接"; + const href = resolveReleaseLink(match[2] || ""); + parts.push( + + {label} + , + ); + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? parts : [text]; +}; + const App: ParentComponent = (props) => { const location = useLocation(); const [version] = createResource(versionApi); const [copyState, setCopyState] = createSignal<"idle" | "done" | "error">( "idle", ); + const [updateDialogOpen, setUpdateDialogOpen] = createSignal(false); + const [updateCheckState, setUpdateCheckState] = + createSignal("idle"); + const [hasAutoCheckedUpdate, setHasAutoCheckedUpdate] = createSignal(false); + const [latestRelease, setLatestRelease] = createSignal( + null, + ); + const [runtimeTarget, setRuntimeTarget] = createSignal({ + os: "unknown", + arch: "unknown", + }); + const [matchedAsset, setMatchedAsset] = createSignal( + null, + ); + const [updateCheckError, setUpdateCheckError] = createSignal(""); + const [downloadState, setDownloadState] = createSignal("idle"); + const [downloadProgress, setDownloadProgress] = createSignal(0); + const [downloadError, setDownloadError] = createSignal(""); + let updateAbortController: AbortController | null = null; const isActive = (url: string) => location.pathname === url || @@ -45,6 +192,30 @@ const App: ParentComponent = (props) => { `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(() => + parseMarkdownBlocks(latestRelease()?.body ?? ""), + ); + const runtimeTargetText = createMemo( + () => `${runtimeTarget().os} / ${runtimeTarget().arch}`, + ); + const releaseLink = createMemo( + () => latestRelease()?.html_url || RELEASES_PAGE_URL, + ); const handleCopyVersion = async () => { try { @@ -56,109 +227,349 @@ const App: ParentComponent = (props) => { window.setTimeout(() => setCopyState("idle"), 1800); }; + const performUpdateCheck = async (manual = false) => { + if (updateCheckState() === "checking") { + return; + } + + setUpdateCheckState("checking"); + setUpdateCheckError(""); + setDownloadState("idle"); + setDownloadProgress(0); + setDownloadError(""); + + if (updateAbortController) { + updateAbortController.abort(); + } + updateAbortController = new AbortController(); + + try { + const [release, target] = await Promise.all([ + fetchLatestRelease(updateAbortController.signal), + detectRuntimeTarget(), + ]); + setRuntimeTarget(target); + + const hasNewVersion = isRemoteVersionNewer(versionText(), release.tag_name); + if (!hasNewVersion) { + setUpdateCheckState("latest"); + if (manual) { + setLatestRelease(release); + setMatchedAsset(resolveAssetForRuntime(release.assets, target)); + } + return; + } + + setLatestRelease(release); + setMatchedAsset(resolveAssetForRuntime(release.assets, target)); + setUpdateCheckState("available"); + setUpdateDialogOpen(true); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + + const message = error instanceof Error ? error.message : "更新检查失败"; + setUpdateCheckError(message); + setUpdateCheckState("error"); + } + }; + const openReleasePage = () => { + window.open(releaseLink(), "_blank", "noopener,noreferrer"); + }; + const handleDownloadUpdate = async () => { + const asset = matchedAsset(); + if (!asset) { + openReleasePage(); + return; + } + + setDownloadState("downloading"); + setDownloadProgress(0); + setDownloadError(""); + try { + await downloadReleaseAsset(asset, (progress) => { + setDownloadProgress(progress); + }); + setDownloadState("done"); + } catch (error) { + const message = error instanceof Error ? error.message : "下载失败"; + setDownloadError(message); + setDownloadState("error"); + openReleasePage(); + } + }; + + createEffect(() => { + version.loading; + if (hasAutoCheckedUpdate() || version.loading) { + return; + } + + setHasAutoCheckedUpdate(true); + void performUpdateCheck(false); + }); + + onCleanup(() => { + if (updateAbortController) { + updateAbortController.abort(); + updateAbortController = null; + } + }); return ( - - - - - - WK - - + <> + + + + - 网课控制台 + WK - - 管理账号、课程记录与任务日志 - + + + 网课控制台 + + + 管理账号、课程记录与任务日志 + + - - + - - - + + { + if (downloadState() === "downloading") { + return; + } + setUpdateDialogOpen(false); + }} + title={`发现更新 ${latestRelease()?.tag_name ?? ""}`} + widthClass="max-w-3xl" + closeOnOverlay={downloadState() !== "downloading"} + footer={ + <> + setUpdateDialogOpen(false)} + > + 稍后更新 + + + 打开 Release + + void handleDownloadUpdate()} + > + {matchedAsset() + ? downloadState() === "downloading" + ? `下载中 ${downloadProgress()}%` + : "在线下载" + : "前往 Release 下载"} + + > + } + > + + + + 当前版本:{versionText()} + + + 最新版本: + + {latestRelease()?.tag_name ?? "-"} + + + + 发布名称: + + {latestRelease()?.name ?? "-"} + + + + 运行环境: + {runtimeTargetText()} + + + 匹配资源: + + {matchedAsset()?.name ?? "未识别当前系统架构,请点击“打开 Release”手动下载"} + + + + + + + + {downloadState() === "downloading" + ? "下载进度" + : downloadState() === "done" + ? "下载完成" + : "下载失败"} + + + + + + {downloadState() === "downloading" + ? `已下载 ${downloadProgress()}%` + : downloadState() === "done" + ? "安装包已下载到浏览器默认下载目录,请替换本地程序后重启。" + : downloadError() || "下载失败,请改为 Release 页面手动下载。"} + + + + + + 更新日志 + + + {(block) => { + if (block.type === "heading") { + return ( + + {renderInlineLinks(block.text)} + + ); + } + + if (block.type === "list") { + return ( + + + {(item) => {renderInlineLinks(item)}} + + + ); + } + + return {renderInlineLinks(block.text)}; + }} + + + + + + > ); }; diff --git a/src/components/account/AccountSidebar.tsx b/src/components/account/AccountSidebar.tsx index 9be109a..7cd66b1 100644 --- a/src/components/account/AccountSidebar.tsx +++ b/src/components/account/AccountSidebar.tsx @@ -29,21 +29,23 @@ const AccountSidebar = (props: AccountSidebarProps) => { return ( - + - 账号信息 - 选择账号后查看课程与记录 + 账号信息 + + 选择账号后查看课程与记录 + @@ -54,8 +56,8 @@ const AccountSidebar = (props: AccountSidebarProps) => { @@ -77,17 +79,19 @@ const AccountSidebar = (props: AccountSidebarProps) => { const courseTypeLabel = selected() ? `当前筛选:${currentCourseLabel}` : `登录类型:${statusLabel}`; + const badgeTypeLabel = selected() ? currentCourseLabel : statusLabel; + const badgeCountLabel = `${account.courses.length} 门`; return ( @@ -101,15 +105,23 @@ const AccountSidebar = (props: AccountSidebarProps) => { {account.user.name} + {platformLabel} - + 学号:{account.user.id} + + + {badgeTypeLabel} + + + {badgeCountLabel} + + @@ -123,26 +135,26 @@ const AccountSidebar = (props: AccountSidebarProps) => { - + 学院:{account.user.dept} 班级:{account.user.class} 性别:{account.user.gender} 站点:{account.host} 账号:{account.username || "-"} {courseCountLabel} - {courseTypeLabel} + {courseTypeLabel} - + props.onToggleExpand(account.id)} > 收起信息 { event.stopPropagation(); diff --git a/src/components/account/CourseWorkspace.tsx b/src/components/account/CourseWorkspace.tsx index 5b53ba8..3890780 100644 --- a/src/components/account/CourseWorkspace.tsx +++ b/src/components/account/CourseWorkspace.tsx @@ -1,4 +1,11 @@ -import { For, Show, createEffect, type JSX } from "solid-js"; +import { + For, + Show, + createEffect, + createMemo, + createSignal, + type JSX, +} from "solid-js"; import type { CourseKind, RecordType } from "~/service/wk"; import type { AccountItem } from "~/store/account"; import type { CourseType } from "~/types/Course"; @@ -12,6 +19,7 @@ type CourseRecordTypeOption = { label: string; value: CourseKind; }; +type RecordFilter = "all" | "unlearned" | "learned"; interface CourseWorkspaceProps { selectedAccount: AccountItem | null; @@ -65,6 +73,7 @@ const stripTimestamp = (message: string) => { const CourseWorkspace = (props: CourseWorkspaceProps) => { let logContainerRef: HTMLDivElement | undefined; + const [recordFilter, setRecordFilter] = createSignal("all"); createEffect(() => { props.studyLogs.length; @@ -88,16 +97,44 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { }); const compact = () => props.densityMode === "compact"; + const recordStats = createMemo(() => { + const learned = props.records.filter((record) => { + const stateText = props.renderRecordState(record.state) || "未知状态"; + return stateText.includes("已学") || record.progress === "1.00"; + }).length; + const total = props.records.length; + + return { + total, + learned, + unlearned: Math.max(0, total - learned), + }; + }); + const filteredRecords = createMemo(() => { + if (recordFilter() === "all") { + return props.records; + } + + return props.records.filter((record) => { + const stateText = props.renderRecordState(record.state) || "未知状态"; + const learned = stateText.includes("已学") || record.progress === "1.00"; + return recordFilter() === "learned" ? learned : !learned; + }); + }); return ( - - + + - 课程工作台 - 课程、记录与日志统一查看 + + 课程工作台 + + + 课程、记录与日志统一查看 + - + {props.selectedAccount?.user.name} @@ -114,19 +151,21 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { - - - - 课程列表 + + + + + 课程列表 + 点击课程查看对应记录 props.onChangeCourseRecordType( @@ -140,7 +179,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { @@ -149,13 +188,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { - + 当前“{props.currentCourseKindLabel} @@ -173,18 +206,18 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { class={ selected() ? compact() - ? "rounded-[20px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-3 py-3 text-left shadow-sm" - : "rounded-[22px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-4 py-4 text-left shadow-sm" + ? "rounded-[16px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-2 py-2 text-left shadow-sm" + : "rounded-[18px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-2.5 py-2.5 text-left shadow-sm" : compact() - ? "rounded-[20px] border border-zinc-200 bg-white px-3 py-3 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30" - : "rounded-[22px] border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30" + ? "rounded-[16px] border border-zinc-200 bg-white px-2 py-2 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30" + : "rounded-[18px] border border-zinc-200 bg-white px-2.5 py-2.5 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30" } onClick={() => props.onSelectCourse(course.id)} > - + {course.name} - + 课程号:{course.id} 老师:{course.teacher} 进度:{course.progress} @@ -199,14 +232,16 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { - - + + - 记录列表 + + 记录列表 + {props.selectedCourse ? props.selectedCourse.name @@ -221,10 +256,10 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { - + { props.onChangeRecordType( @@ -252,7 +287,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { { + + + setRecordFilter("all")} + > + 全部 {recordStats().total} + + setRecordFilter("unlearned")} + > + 未学 {recordStats().unlearned} + + setRecordFilter("learned")} + > + 已学 {recordStats().learned} + + + + 当前筛选:{recordFilter() === "all" ? "全部" : recordFilter() === "unlearned" ? "未学" : "已学"} + + @@ -297,56 +360,78 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { !props.recordsLoading && !props.recordError && props.selectedCourse && - props.records.length === 0 + filteredRecords().length === 0 } > - 当前分类下没有记录。 + + {props.records.length === 0 + ? "当前分类下没有记录。" + : "当前筛选下没有记录。"} + - - - {(record) => ( - - - - - {record.name} - - - 记录 ID:{record.id} | 章节:{record.chapterId} - - - - {props.renderRecordState(record.state) || - "未知状态"} - - + + + {(record) => { + const stateText = + props.renderRecordState(record.state) || "未知状态"; + const learned = + stateText.includes("已学") || record.progress === "1.00"; - - 视频时长:{record.videoDuration} - 学习秒数:{record.duration} - 学习进度:{record.progress} - 开始时间:{record.beginTime || "-"} - 完成时间:{record.finalTime || "-"} - 查看次数:{record.viewCount} + return ( + + + + + {record.name} + + + 记录 ID:{record.id} | 章节:{record.chapterId} + + + + {stateText} + + + + + 视频时长:{record.videoDuration} + 学习秒数:{record.duration} + 学习进度:{record.progress} + 开始时间:{record.beginTime || "-"} + 完成时间:{record.finalTime || "-"} + 查看次数:{record.viewCount} + - - )} + ); + }} - - + + - 运行日志 + + 运行日志 + 输出会自动滚动到最新位置 @@ -355,7 +440,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { @@ -363,7 +448,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { 清空日志 @@ -373,13 +458,13 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => { 0} fallback={暂无日志输出。} > - + {(log, index) => ( diff --git a/src/pages/accounts/Account.tsx b/src/pages/accounts/Account.tsx index c7a3264..5b2308a 100644 --- a/src/pages/accounts/Account.tsx +++ b/src/pages/accounts/Account.tsx @@ -13,9 +13,11 @@ import AddAccountDialog, { import CourseWorkspace from "~/components/account/CourseWorkspace"; import { createWkClient, + hostApi, loginApi, runStudyQueue, type CourseKind, + type RecordItem, type RecordType, } from "~/service/wk"; import { setUnauthorizedHandler } from "~/service/http"; @@ -47,13 +49,32 @@ const createDefaultForm = (host: string): LoginForm => ({ const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim(); const parseDurationToSeconds = (value: string) => { - const parts = value.split(":").map(Number); - if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) { + const input = value.trim(); + if (!input) { return 0; } - const [hours, minutes, seconds] = parts; - return hours * 3600 + minutes * 60 + seconds; + const numeric = Number(input); + if (!Number.isNaN(numeric)) { + return Math.max(0, Math.floor(numeric)); + } + + const parts = input.split(":").map(Number); + if (parts.some((part) => Number.isNaN(part))) { + return 0; + } + + if (parts.length === 3) { + const [hours, minutes, seconds] = parts; + return hours * 3600 + minutes * 60 + seconds; + } + + if (parts.length === 2) { + const [minutes, seconds] = parts; + return minutes * 60 + seconds; + } + + return 0; }; const createRecordCacheKey = ( @@ -61,6 +82,8 @@ const createRecordCacheKey = ( courseId: number, recordType: RecordType, ) => `${accountId}::${courseId}::${recordType || "course"}`; +const STUDY_HEARTBEAT_CHECK_INTERVAL_MS = 5000; +const STUDY_HEARTBEAT_TIMEOUT_MS = 45000; const Account = () => { const [storeState, setStoreState] = createSignal(accountStore.getState()); @@ -80,8 +103,67 @@ const Account = () => { const [isRefreshingRecords, setIsRefreshingRecords] = createSignal(false); const [isRefreshingCourses, setIsRefreshingCourses] = createSignal(false); const [isRefreshingLogs, setIsRefreshingLogs] = createSignal(false); + const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false); const [hasRestoredRecords, setHasRestoredRecords] = createSignal(false); const accountClients = new Map>(); + let recordRequestToken = 0; + + const touchStudyHeartbeat = (accountId: string) => { + accountStore.getState().touchStudyHeartbeat(accountId); + }; + const clearStudyHeartbeat = (accountId: string) => { + accountStore.getState().clearStudyHeartbeat(accountId); + }; + const runStudyHeartbeatWatchdog = () => { + const now = Date.now(); + const snapshot = accountStore.getState(); + + for (const [accountId, running] of Object.entries(snapshot.runningStudyMap)) { + if (!running) { + continue; + } + + const lastHeartbeat = snapshot.studyHeartbeatMap[accountId] ?? 0; + if (now - lastHeartbeat <= STUDY_HEARTBEAT_TIMEOUT_MS) { + continue; + } + + snapshot.setAccountRunningStudy(accountId, false); + snapshot.clearStudyHeartbeat(accountId); + snapshot.appendStudyLog( + accountId, + `检测到刷课任务超时(超过 ${Math.floor( + STUDY_HEARTBEAT_TIMEOUT_MS / 1000, + )} 秒无活动),已自动重置状态`, + ); + } + }; + + const loadRemoteHostsIfNeeded = async () => { + if (isLoadingRemoteHosts()) { + return; + } + + if (settingsStore.getState().remoteHosts.length > 0) { + return; + } + + setIsLoadingRemoteHosts(true); + + try { + const res = await hostApi(); + settingsStore.getState().setRemoteHosts( + res.data.list.map((item) => ({ + label: item.name, + host: item.host, + })), + ); + } catch { + // Ignore host bootstrap errors in account page to avoid blocking main flow. + } finally { + setIsLoadingRemoteHosts(false); + } + }; onMount(() => { const unsubscribeAccount = accountStore.subscribe((state) => { @@ -90,12 +172,19 @@ const Account = () => { const unsubscribeSettings = settingsStore.subscribe((state) => { setSettingsState(state); }); + void loadRemoteHostsIfNeeded(); setUnauthorizedHandler(reloginBySession); + runStudyHeartbeatWatchdog(); + const heartbeatTimer = window.setInterval( + runStudyHeartbeatWatchdog, + STUDY_HEARTBEAT_CHECK_INTERVAL_MS, + ); onCleanup(() => { unsubscribeAccount(); unsubscribeSettings(); setUnauthorizedHandler(null); + window.clearInterval(heartbeatTimer); }); }); @@ -189,6 +278,12 @@ const Account = () => { accountStore.getState().appendStudyLog(targetAccountId, message); }; + const cancelPendingRecordRequest = () => { + recordRequestToken += 1; + setRecordsLoading(false); + setIsRefreshingRecords(false); + }; + const getAccountClient = (accountId: string) => { const existingClient = accountClients.get(accountId); if (existingClient) { @@ -313,6 +408,7 @@ const Account = () => { }; const handleSelectAccount = (accountId: string) => { + cancelPendingRecordRequest(); accountStore.getState().setSelectedAccountId(accountId); accountStore.getState().setSelectedCourseId(null); accountStore.getState().setRecords([]); @@ -360,6 +456,7 @@ const Account = () => { setErrorMessage(message); accountStore.getState().setAccountCourses(accountId, []); if (accountStore.getState().selectedAccountId === accountId) { + cancelPendingRecordRequest(); accountStore.getState().setSelectedCourseId(null); accountStore.getState().setRecords([]); } @@ -437,36 +534,59 @@ const Account = () => { if (!account) { return; } + const requestToken = ++recordRequestToken; + const accountId = account.id; setRecordsLoading(true); setIsRefreshingRecords(true); setRecordError(""); try { - const res = await getAccountClient(account.id).recordApi({ + const res = await getAccountClient(accountId).recordApi({ course_id: String(courseId), page: 0, record_type: nextRecordType, }); - accountStore.getState().setRecords(res.data.list); + 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() .setRecordCache( - createRecordCacheKey(account.id, courseId, nextRecordType), - res.data.list, + createRecordCacheKey(accountId, courseId, nextRecordType), + list, ); } catch (error) { + if (requestToken !== recordRequestToken) { + return; + } const message = error instanceof Error ? error.message : "获取记录失败,请稍后重试。"; setRecordError(message); accountStore.getState().setRecords([]); } finally { - setRecordsLoading(false); - setIsRefreshingRecords(false); + if (requestToken === recordRequestToken) { + setRecordsLoading(false); + setIsRefreshingRecords(false); + } } }; const handleSelectCourse = (courseId: number) => { + cancelPendingRecordRequest(); accountStore.getState().setSelectedCourseId(courseId); }; @@ -507,18 +627,19 @@ const Account = () => { } const queueItems = records() - .filter((item) => item.progress !== "1.00") .map((item) => ({ nodeId: item.id, name: item.name, - currentTime: Number(item.duration || 0), + currentTime: parseDurationToSeconds(item.duration), totalTime: parseDurationToSeconds(item.videoDuration), progress: item.progress, completed: stripHtml(item.state) === "已学", - })); + })) + .filter((item) => item.progress !== "1.00" && !item.completed); try { accountStore.getState().setAccountRunningStudy(account.id, true); + touchStudyHeartbeat(account.id); appendStudyLog(`开始刷课:${course.name}`, account.id); await runStudyQueue({ accountId: account.id, @@ -528,10 +649,14 @@ const Account = () => { client: getAccountClient(account.id), isRunningStudy: () => !!accountStore.getState().runningStudyMap[account.id], - setIsRunningStudy: () => - accountStore.getState().setAccountRunningStudy(account.id, false), - onLog: (message: string, accoundID: string) => - appendStudyLog(message, accoundID), + setIsRunningStudy: () => { + accountStore.getState().setAccountRunningStudy(account.id, false); + clearStudyHeartbeat(account.id); + }, + onLog: (message: string, accoundID: string) => { + touchStudyHeartbeat(accoundID); + appendStudyLog(message, accoundID); + }, }); if (accountStore.getState().runningStudyMap[account.id]) { appendStudyLog(`刷课完成:${course.name}`, account.id); @@ -543,6 +668,7 @@ const Account = () => { setRecordError(message); } finally { accountStore.getState().setAccountRunningStudy(account.id, false); + clearStudyHeartbeat(account.id); } }; @@ -553,12 +679,14 @@ const Account = () => { } accountStore.getState().setAccountRunningStudy(account.id, false); + clearStudyHeartbeat(account.id); appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id); }; createEffect( on([selectedAccountId, courseKind], ([accountId, kind]) => { if (!accountId) { + cancelPendingRecordRequest(); return; } @@ -570,6 +698,7 @@ const Account = () => { on( [selectedAccountId, selectedCourseId, recordType], ([accountId, courseId, type]) => { + cancelPendingRecordRequest(); if (!accountId || !courseId) { accountStore.getState().setRecords([]); return; @@ -613,12 +742,12 @@ const Account = () => { return ( - + Account Center - + 账号管理 @@ -627,14 +756,14 @@ const Account = () => { 添加账号 - + { accountStore.getState().setRecordType(value) } onChangeCourseRecordType={(value) => { + cancelPendingRecordRequest(); accountStore.getState().setCourseKind(value); accountStore.getState().setSelectedCourseId(null); accountStore.getState().setRecords([]); diff --git a/src/pages/logs/Logs.tsx b/src/pages/logs/Logs.tsx index 740fac0..f974b65 100644 --- a/src/pages/logs/Logs.tsx +++ b/src/pages/logs/Logs.tsx @@ -10,6 +10,17 @@ import { import { accountStore } from "~/store/account"; import { settingsStore } from "~/store/settings"; +type LogRow = { + id: string; + accountId: string; + accountName: string; + host: string; + seq: number; + timestamp: string; + timestampValue: number; + content: string; +}; + const extractTimestamp = (message: string) => { const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/); return match?.[1] ?? null; @@ -25,7 +36,6 @@ const parseTimestampValue = (timestamp: string | null) => { } const [hours, minutes, seconds] = timestamp.split(":").map(Number); - if ([hours, minutes, seconds].some((item) => Number.isNaN(item))) { return -1; } @@ -33,6 +43,11 @@ const parseTimestampValue = (timestamp: string | null) => { return hours * 3600 + minutes * 60 + seconds; }; +const normalizeContent = (message: string) => { + const value = stripTimestamp(message).replace(/\s+/g, " ").trim(); + return value || "-"; +}; + const Logs = () => { const [accountState, setAccountState] = createSignal(accountStore.getState()); const [settingsState, setSettingsState] = createSignal( @@ -66,75 +81,60 @@ const Logs = () => { ) as Record; }); - const allLogs = createMemo(() => { - return Object.entries(accountState().studyLogsMap) - .flatMap(([accountId, messages]) => { + const logRows = createMemo(() => { + const rows = Object.entries(accountState().studyLogsMap).flatMap( + ([accountId, messages]) => { const accountInfo = accountInfoMap()[accountId]; - const accountName = accountInfo?.name ?? accountId; - return messages.map((message, index) => { const timestamp = extractTimestamp(message); - return { id: `${accountId}-${index}`, accountId, - accountName, - index: index + 1, - timestamp, + accountName: accountInfo?.name ?? accountId, + host: accountInfo?.host ?? "-", + seq: index + 1, + timestamp: timestamp ?? "--:--:--", timestampValue: parseTimestampValue(timestamp), - content: stripTimestamp(message), - raw: message, + content: normalizeContent(message), }; }); - }) - .sort((left, right) => { - if (left.timestampValue !== right.timestampValue) { - return left.timestampValue - right.timestampValue; - } - - if (left.index !== right.index) { - return left.index - right.index; - } - - return left.accountId.localeCompare(right.accountId); - }); - }); - - const accountSummaries = createMemo(() => { - const accountIds = Array.from( - new Set([ - ...accountState().accounts.map((account) => account.id), - ...Object.keys(accountState().studyLogsMap), - ]), + }, ); - return accountIds.map((accountId) => { - const accountInfo = accountInfoMap()[accountId]; - const logs = accountState().studyLogsMap[accountId] ?? []; - const latestMessage = logs[logs.length - 1] ?? "暂无日志"; + return rows.sort((left, right) => { + const leftTs = + left.timestampValue >= 0 ? left.timestampValue : Number.MAX_SAFE_INTEGER; + const rightTs = + right.timestampValue >= 0 + ? right.timestampValue + : Number.MAX_SAFE_INTEGER; - return { - id: accountId, - name: accountInfo?.name ?? accountId, - host: accountInfo?.host ?? "未知账号来源", - total: logs.length, - latestMessage: stripTimestamp(latestMessage), - latestTime: extractTimestamp(latestMessage), - }; + if (leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.accountName !== right.accountName) { + return left.accountName.localeCompare(right.accountName, "zh-CN"); + } + if (left.seq !== right.seq) { + return left.seq - right.seq; + } + return left.accountId.localeCompare(right.accountId); }); }); - const latestLog = createMemo(() => { - const logs = allLogs(); - return logs.length > 0 ? logs[logs.length - 1] : null; + const totalAccountsWithLogs = createMemo(() => { + return Object.values(accountState().studyLogsMap).filter( + (messages) => messages.length > 0, + ).length; }); - const totalAccountsWithLogs = createMemo(() => { - return accountSummaries().filter((item) => item.total > 0).length; + const latestLog = createMemo(() => { + const rows = logRows(); + return rows.length > 0 ? rows[rows.length - 1] : null; }); createEffect(() => { - allLogs().length; + logRows().length; if (!settingsState().autoScrollLogs) { return; @@ -170,7 +170,7 @@ const Logs = () => { 日志中心 - 聚合全部账号日志,优先把空间留给正文。 + 单行并列展示时间、账号、姓名与日志内容。 @@ -180,7 +180,7 @@ const Logs = () => { TOTAL LOGS - {allLogs().length} + {logRows().length} @@ -191,14 +191,11 @@ const Logs = () => { {totalAccountsWithLogs()} - - - AUTO SCROLL - - - {settingsState().autoScrollLogs ? "跟随最新" : "手动查看"} - - + + + 最新: {latestLog()?.timestamp} / {latestLog()?.accountName} + + { - - - - 账号概览 - 最新输出与日志数量 + + + + + {settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"} + + + {settingsState().autoScrollLogs ? "自动滚动中" : "自动滚动关闭"} + + - - - {(account) => ( - - {/* 顶部 */} - - - - {account.name} - - {account.host} - - - - {account.total} - - - - {/* 最新日志 */} - - - Latest - {account.latestTime ?? "--:--"} - - - - {account.latestMessage} - - - - )} - - - - - - - - - 实时日志流 - - 保留关键信息,把更多高度让给正文 - + + 0} + fallback={ + + 暂无日志输出 - - - - {settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"} - - - {settingsState().autoScrollLogs - ? "自动滚动中" - : "自动滚动关闭"} - - - - 最近来源 {latestLog()?.accountName} - - - - - - - - 0} - fallback={ - - 暂无日志输出 - - } - > - - - {(log) => ( - - {/* 头部 */} - - - {log.accountName} - #{log.index} + + + 时间 + 姓名 + 账号 + 主机 + 序号 + 内容 + - - - {log.timestamp} - - - - - {log.accountId} - - - {/* 内容 */} - - {log.content} - + + + {(row) => ( + + + {settingsState().showLogTimestamps + ? row.timestamp + : "--:--:--"} + + + {row.accountName} + + + {row.accountId} + + + {row.host} + + #{row.seq} + + {row.content} + )} - - - - + + + + ); }; diff --git a/src/service/http.ts b/src/service/http.ts index b7f2ee2..63c5978 100644 --- a/src/service/http.ts +++ b/src/service/http.ts @@ -13,16 +13,40 @@ export type HttpClient = { }; let unauthorizedHandler: UnauthorizedHandler | null = null; +const reloginTaskMap = new Map>(); export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => { unauthorizedHandler = handler; }; +const runUnauthorizedHandler = (sessionId: string) => { + const pendingTask = reloginTaskMap.get(sessionId); + if (pendingTask) { + return pendingTask; + } + + const task = (async () => { + if (!unauthorizedHandler) { + return false; + } + + try { + return await unauthorizedHandler(sessionId); + } finally { + reloginTaskMap.delete(sessionId); + } + })(); + + reloginTaskMap.set(sessionId, task); + return task; +}; + export const createHttpClient = ( resolveSessionId?: SessionResolver, ): HttpClient => { const instance = axios.create({ baseURL: import.meta.env.VITE_BASE_URL, + timeout: 15000, }); instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { @@ -52,7 +76,7 @@ export const createHttpClient = ( !url.includes("/api/login") ) { config._retry = true; - const ok = await unauthorizedHandler(sessionId); + const ok = await runUnauthorizedHandler(sessionId); if (ok) { return instance.request(config); diff --git a/src/service/update.ts b/src/service/update.ts new file mode 100644 index 0000000..6839552 --- /dev/null +++ b/src/service/update.ts @@ -0,0 +1,302 @@ +export type ReleaseAsset = { + id: number; + name: string; + size: number; + download_count: number; + browser_download_url: string; +}; + +export type LatestRelease = { + id: number; + tag_name: string; + name: string; + body: string; + html_url: string; + published_at: string; + assets: ReleaseAsset[]; +}; + +export type RuntimeOS = "windows" | "linux" | "darwin" | "unknown"; +export type RuntimeArch = "amd64" | "arm64" | "unknown"; + +export type RuntimeTarget = { + os: RuntimeOS; + arch: RuntimeArch; +}; + +export const UPDATE_API_URL = + "https://gitea.kmux.cn/api/v1/repos/cqcst/wk-backend/releases/latest"; +export const RELEASES_PAGE_URL = + "https://gitea.kmux.cn/cqcst/wk-backend/releases"; +const RELEASE_HOST_ORIGIN = "https://gitea.kmux.cn"; + +type UADataHighEntropy = { + architecture?: string; + bitness?: string; + platform?: string; +}; + +type UAData = { + platform?: string; + getHighEntropyValues?: ( + hints: string[], + ) => Promise; +}; + +const normalizeSemver = (value: string) => { + const match = value.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?/); + if (!match) { + return null; + } + + return [1, 2, 3, 4].map((index) => Number(match[index] ?? 0)); +}; + +export const isRemoteVersionNewer = (currentVersion: string, tagName: string) => { + const current = normalizeSemver(currentVersion); + const remote = normalizeSemver(tagName); + + if (!remote) { + return false; + } + if (!current) { + return true; + } + + for (let i = 0; i < Math.max(current.length, remote.length); i += 1) { + const left = current[i] ?? 0; + const right = remote[i] ?? 0; + if (right > left) { + return true; + } + if (right < left) { + return false; + } + } + + return false; +}; + +const detectOSFromString = (value: string) => { + const lower = value.toLowerCase(); + if (lower.includes("win")) { + return "windows" as const; + } + if (lower.includes("mac") || lower.includes("darwin")) { + return "darwin" as const; + } + if (lower.includes("linux") || lower.includes("x11")) { + return "linux" as const; + } + return "unknown" as const; +}; + +const detectArchFromString = (value: string) => { + const lower = value.toLowerCase(); + if ( + lower.includes("arm64") || + lower.includes("aarch64") || + lower.includes("armv8") + ) { + return "arm64" as const; + } + if ( + lower.includes("amd64") || + lower.includes("x86_64") || + lower.includes("x64") || + lower.includes("win64") || + lower.includes("x86-64") + ) { + return "amd64" as const; + } + return "unknown" as const; +}; + +export const detectRuntimeTarget = async (): Promise => { + const userAgent = navigator.userAgent || ""; + const platform = navigator.platform || ""; + const uaData = (navigator as Navigator & { userAgentData?: UAData }) + .userAgentData; + + let os = detectOSFromString(platform || userAgent); + let arch = detectArchFromString(userAgent); + + if (uaData?.platform) { + const uaDataOs = detectOSFromString(uaData.platform); + if (uaDataOs !== "unknown") { + os = uaDataOs; + } + } + + if (uaData?.getHighEntropyValues) { + try { + const high = await uaData.getHighEntropyValues([ + "architecture", + "bitness", + "platform", + ]); + const highArch = detectArchFromString( + `${high.architecture ?? ""} ${high.bitness ?? ""}`, + ); + if (highArch !== "unknown") { + arch = highArch; + } else if (high.architecture === "x86" && high.bitness === "64") { + arch = "amd64"; + } + + if (high.platform) { + const highOs = detectOSFromString(high.platform); + if (highOs !== "unknown") { + os = highOs; + } + } + } catch { + // Ignore UA high-entropy detection errors and keep fallbacks. + } + } + + return { os, arch }; +}; + +const assetMatchesOS = (name: string, os: RuntimeOS) => { + if (os === "windows") { + return /\bwindows\b|\bwin\b|\.exe$/.test(name); + } + if (os === "linux") { + return /\blinux\b/.test(name); + } + if (os === "darwin") { + return /\bdarwin\b|\bmac\b|\bmacos\b|\bosx\b/.test(name); + } + return false; +}; + +const assetMatchesArch = (name: string, arch: RuntimeArch) => { + if (arch === "amd64") { + return /\bamd64\b|\bx86_64\b|\bx64\b/.test(name); + } + if (arch === "arm64") { + return /\barm64\b|\baarch64\b/.test(name); + } + return false; +}; + +export const resolveAssetForRuntime = ( + assets: ReleaseAsset[], + target: RuntimeTarget, +) => { + if (target.os === "unknown") { + return null; + } + + const mapped = assets.map((asset) => { + const lower = asset.name.toLowerCase(); + const osScore = assetMatchesOS(lower, target.os) ? 2 : 0; + const archScore = + target.arch === "unknown" + ? 0 + : assetMatchesArch(lower, target.arch) + ? 2 + : 0; + const exactPenalty = + target.arch !== "unknown" && archScore === 0 ? -2 : 0; + return { + asset, + score: osScore + archScore + exactPenalty, + }; + }); + + const matched = mapped + .filter((item) => item.score > 0) + .sort((left, right) => right.score - left.score); + + return matched[0]?.asset ?? null; +}; + +const triggerBrowserDownload = (blob: Blob, filename: string) => { + const link = document.createElement("a"); + const blobUrl = URL.createObjectURL(blob); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); +}; + +export const fetchLatestRelease = async (signal?: AbortSignal) => { + const response = await fetch(UPDATE_API_URL, { + method: "GET", + headers: { + accept: "application/json", + }, + signal, + }); + + if (!response.ok) { + throw new Error(`检查更新失败: HTTP ${response.status}`); + } + + return (await response.json()) as LatestRelease; +}; + +export const downloadReleaseAsset = async ( + asset: ReleaseAsset, + onProgress?: (progress: number) => void, +) => { + const response = await fetch(asset.browser_download_url, { + method: "GET", + }); + + if (!response.ok) { + throw new Error(`下载失败: HTTP ${response.status}`); + } + + const totalSize = Number(response.headers.get("content-length") ?? 0); + if (!response.body || !Number.isFinite(totalSize) || totalSize <= 0) { + const blob = await response.blob(); + triggerBrowserDownload(blob, asset.name); + onProgress?.(100); + return; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + + chunks.push(value); + received += value.length; + const progress = Math.min(99, Math.round((received / totalSize) * 100)); + onProgress?.(progress); + } + + const merged = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + + const blob = new Blob([merged.buffer]); + triggerBrowserDownload(blob, asset.name); + onProgress?.(100); +}; + +export const resolveReleaseLink = (url: string) => { + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + if (url.startsWith("/")) { + return `${RELEASE_HOST_ORIGIN}${url}`; + } + return `${RELEASE_HOST_ORIGIN}/${url}`; +}; diff --git a/src/service/wk.ts b/src/service/wk.ts index a467b17..8c84cc4 100644 --- a/src/service/wk.ts +++ b/src/service/wk.ts @@ -193,18 +193,32 @@ const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); export const runStudyQueue = async (_payload: StudyRunnerPayload) => { const stopFlag = _payload.isRunningStudy; + const stepSeconds = Math.max(1, Math.floor(_payload.intervalSeconds || 5)); + const sleepMs = stepSeconds * 1000; + const maxRetryCount = 3; + const maxStudySubmitRetry = 3; + const studySubmitRetrySleepMs = 5000; + for (const item of _payload.items) { + const learnedTime = Math.max(0, Math.floor(item.currentTime || 0)); + const rawTotalTime = Math.max(0, Math.floor(item.totalTime || 0)); + const totalTime = Math.max(0, rawTotalTime - learnedTime); let currentTime = 0; let count = 0; let study_id = 0; - let total = item.totalTime - item.currentTime; + let retryCount = 0; - while (currentTime <= total) { + if (totalTime <= 0) { + _payload.onLog?.(`跳过已完成节点: ${item.name}`, _payload.accountId); + continue; + } + + while (currentTime <= totalTime) { if (!stopFlag()) { _payload.onLog?.("⛔ 已手动停止", _payload.accountId); return; } - const message = `[${item.name}]: ${currentTime}/${total}`; + const message = `[${item.name}]: ${currentTime}/${totalTime}`; _payload.onLog?.(message, _payload.accountId); try { @@ -212,8 +226,69 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => { node_id: item.nodeId, study_id: String(study_id), study_time: String(currentTime), - status: count === 0 ? 1 : currentTime >= total ? 3 : 2, + status: count === 0 ? 1 : currentTime >= totalTime ? 3 : 2, }); + const submitMessage = String(resp.data.msg ?? ""); + const isSubmitFailed = + submitMessage.includes("提交学时失败") || resp.data.status === false; + + if (isSubmitFailed) { + let submitRetry = 0; + let fixedResp = resp; + + while (submitRetry < maxStudySubmitRetry) { + submitRetry += 1; + _payload.onLog?.( + `⚠️ 提交学时失败,${submitRetry}/${maxStudySubmitRetry} 次重试后再提交: ${item.name}`, + _payload.accountId, + ); + await sleep(studySubmitRetrySleepMs); + + if (!stopFlag()) { + _payload.onLog?.("⛔ 已手动停止", _payload.accountId); + return; + } + + fixedResp = await _payload.client.studyApi({ + node_id: item.nodeId, + study_id: String(study_id), + study_time: String(currentTime), + status: count === 0 ? 1 : currentTime >= totalTime ? 3 : 2, + }); + + const nextMsg = String(fixedResp.data.msg ?? ""); + const nextFailed = + nextMsg.includes("提交学时失败") || fixedResp.data.status === false; + if (!nextFailed) { + break; + } + } + + const finalMsg = String(fixedResp.data.msg ?? ""); + const stillFailed = + finalMsg.includes("提交学时失败") || fixedResp.data.status === false; + if (stillFailed) { + _payload.onLog?.( + `⛔ 提交学时连续重试 ${maxStudySubmitRetry} 次仍失败,停止刷课: ${item.name}`, + _payload.accountId, + ); + _payload.setIsRunningStudy(); + return; + } + + study_id = fixedResp.data.studyId; + retryCount = 0; + + if (currentTime === totalTime) { + break; + } + + currentTime = Math.min(currentTime + stepSeconds, totalTime); + count++; + await sleep(sleepMs); + continue; + } + if (resp.data.state != 0) { _payload.onLog?.(`⛔ 自动停止: ${resp.data.msg}`, _payload.accountId); _payload.setIsRunningStudy(); @@ -221,17 +296,29 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => { } study_id = resp.data.studyId; + retryCount = 0; - if (currentTime === total) break; + if (currentTime === totalTime) { + break; + } - currentTime = Math.min(currentTime + 5, total); + currentTime = Math.min(currentTime + stepSeconds, totalTime); count++; } catch (error) { + retryCount += 1; const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`; _payload.onLog?.(errorMessage, _payload.accountId); + + if (retryCount >= maxRetryCount) { + _payload.onLog?.( + `⚠️ 连续失败 ${maxRetryCount} 次,跳过节点: ${item.name}`, + _payload.accountId, + ); + break; + } } - await sleep(5000); + await sleep(sleepMs); } } }; diff --git a/src/store/account.ts b/src/store/account.ts index d00bdb6..b24f540 100644 --- a/src/store/account.ts +++ b/src/store/account.ts @@ -19,6 +19,7 @@ const withLogTimestamp = (message: string) => { return `[${timestamp}] ${message}`; }; +const MAX_STUDY_LOGS_PER_ACCOUNT = 1000; export type AccountAuth = { password: string; @@ -38,6 +39,45 @@ export type AccountItem = { export type RecordCacheMap = Record; +type PersistPreferences = { + persistAccounts: boolean; + persistRecords: boolean; + persistLogs: boolean; +}; + +const settingsStorageKey = "settings-storage"; +const defaultPersistPreferences: PersistPreferences = { + persistAccounts: true, + persistRecords: true, + persistLogs: true, +}; + +const resolvePersistPreferences = (): PersistPreferences => { + try { + const rawValue = localStorage.getItem(settingsStorageKey); + if (!rawValue) { + return defaultPersistPreferences; + } + + const parsedValue = JSON.parse(rawValue) as { + state?: Partial; + }; + const persistedState = parsedValue?.state; + + return { + persistAccounts: + persistedState?.persistAccounts ?? + defaultPersistPreferences.persistAccounts, + persistRecords: + persistedState?.persistRecords ?? defaultPersistPreferences.persistRecords, + persistLogs: + persistedState?.persistLogs ?? defaultPersistPreferences.persistLogs, + }; + } catch { + return defaultPersistPreferences; + } +}; + type AccountState = { accounts: AccountItem[]; selectedAccountId: string; @@ -49,6 +89,7 @@ type AccountState = { recordCacheMap: RecordCacheMap; studyLogsMap: Record; runningStudyMap: Record; + studyHeartbeatMap: Record; setSelectedAccountId: (accountId: string) => void; setExpandedAccountId: (accountId: string) => void; setSelectedCourseId: (courseId: number | null) => void; @@ -57,6 +98,9 @@ type AccountState = { setRecords: (records: RecordItem[]) => void; setRecordCache: (cacheKey: string, records: RecordItem[]) => void; setAccountRunningStudy: (accountId: string, value: boolean) => void; + touchStudyHeartbeat: (accountId: string, timestamp?: number) => void; + clearStudyHeartbeat: (accountId: string) => void; + clearAllStudyHeartbeat: () => void; appendStudyLog: (accountId: string, message: string) => void; clearStudyLogs: (accountId: string) => void; clearAllStudyLogs: () => void; @@ -78,6 +122,7 @@ export const accountStore = createStore()( recordCacheMap: {}, studyLogsMap: {}, runningStudyMap: {}, + studyHeartbeatMap: {}, setSelectedAccountId: (accountId) => set({ selectedAccountId: accountId }), setExpandedAccountId: (accountId) => @@ -100,16 +145,39 @@ export const accountStore = createStore()( [accountId]: value, }, })), - appendStudyLog: (accountId, message) => + touchStudyHeartbeat: (accountId, timestamp) => set((state) => ({ - studyLogsMap: { - ...state.studyLogsMap, - [accountId]: [ - ...(state.studyLogsMap[accountId] ?? []), - withLogTimestamp(message), - ], + studyHeartbeatMap: { + ...state.studyHeartbeatMap, + [accountId]: timestamp ?? Date.now(), }, })), + clearStudyHeartbeat: (accountId) => + set((state) => ({ + studyHeartbeatMap: Object.fromEntries( + Object.entries(state.studyHeartbeatMap).filter( + ([key]) => key !== accountId, + ), + ), + })), + clearAllStudyHeartbeat: () => + set({ + studyHeartbeatMap: {}, + }), + appendStudyLog: (accountId, message) => + set((state) => { + const nextLogs = [ + ...(state.studyLogsMap[accountId] ?? []), + withLogTimestamp(message), + ].slice(-MAX_STUDY_LOGS_PER_ACCOUNT); + + return { + studyLogsMap: { + ...state.studyLogsMap, + [accountId]: nextLogs, + }, + }; + }), clearStudyLogs: (accountId) => set((state) => ({ studyLogsMap: { @@ -171,24 +239,42 @@ export const accountStore = createStore()( ([key]) => key !== accountId, ), ), + studyHeartbeatMap: Object.fromEntries( + Object.entries(state.studyHeartbeatMap).filter( + ([key]) => key !== accountId, + ), + ), }; }), }), { name: "account-storage", storage: createJSONStorage(() => localStorage), - partialize: (state) => ({ - accounts: state.accounts, - selectedAccountId: state.selectedAccountId, - expandedAccountId: state.expandedAccountId, - selectedCourseId: state.selectedCourseId, - courseKind: state.courseKind, - recordType: state.recordType, - records: state.records, - recordCacheMap: state.recordCacheMap, - studyLogsMap: state.studyLogsMap, - runningStudyMap: state.runningStudyMap, - }), + partialize: (state) => { + const preferences = resolvePersistPreferences(); + const persistedState: Partial = { + courseKind: state.courseKind, + recordType: state.recordType, + }; + + if (preferences.persistAccounts) { + persistedState.accounts = state.accounts; + persistedState.selectedAccountId = state.selectedAccountId; + persistedState.expandedAccountId = state.expandedAccountId; + persistedState.selectedCourseId = state.selectedCourseId; + } + + if (preferences.persistRecords) { + persistedState.records = state.records; + persistedState.recordCacheMap = state.recordCacheMap; + } + + if (preferences.persistLogs) { + persistedState.studyLogsMap = state.studyLogsMap; + } + + return persistedState; + }, }, ), ); diff --git a/src/store/settings.ts b/src/store/settings.ts index 834f804..5ea7522 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -35,6 +35,38 @@ type SettingsState = { }; const accountStorageKey = "account-storage"; +type PersistedStorage = { + state?: Record; + version?: number; +}; + +const patchAccountStorage = ( + patcher: (state: Record) => Record, +) => { + const rawValue = localStorage.getItem(accountStorageKey); + if (!rawValue) { + return; + } + + try { + const parsedValue = JSON.parse(rawValue) as PersistedStorage; + const currentState = + parsedValue.state && typeof parsedValue.state === "object" + ? parsedValue.state + : {}; + const nextState = patcher(currentState); + + localStorage.setItem( + accountStorageKey, + JSON.stringify({ + ...parsedValue, + state: nextState, + }), + ); + } catch { + localStorage.removeItem(accountStorageKey); + } +}; const uniqueHosts = (hosts: HostOption[]) => { const map = new Map(); @@ -74,26 +106,59 @@ export const settingsStore = createStore()( if (section === "accounts") set({ persistAccounts: value }); if (section === "records") set({ persistRecords: value }); if (section === "logs") set({ persistLogs: value }); + + if (!value) { + queueMicrotask(() => get().clearPersistedSection(section)); + } }, clearPersistedSection: (section) => { if (section === "accounts") { - localStorage.removeItem(accountStorageKey); + patchAccountStorage((state) => { + const { + 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") { - set({ persistRecords: false }); - queueMicrotask(() => set({ persistRecords: true })); + patchAccountStorage((state) => { + const { records, recordCacheMap, selectedCourseId, ...rest } = + state; + void records; + void recordCacheMap; + void selectedCourseId; + return rest; + }); } if (section === "logs") { - set({ persistLogs: false }); - queueMicrotask(() => set({ persistLogs: true })); + patchAccountStorage((state) => { + const { studyLogsMap, runningStudyMap, ...rest } = state; + void studyLogsMap; + void runningStudyMap; + return rest; + }); } }, clearAllPersistedData: () => { localStorage.removeItem(accountStorageKey); - get().clearPersistedSection("records"); - get().clearPersistedSection("logs"); }, setAutoScrollLogs: (value) => set({ autoScrollLogs: value }), setShowLogTimestamps: (value) => set({ showLogTimestamps: value }), diff --git a/vite.config.ts b/vite.config.ts index ba2b265..8fd6cff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,4 +10,8 @@ export default defineConfig({ "~": path.resolve(__dirname, "src"), }, }, + + server: { + host: "local.kmux.cn", + }, });
- 管理账号、课程记录与任务日志 -
+ 管理账号、课程记录与任务日志 +
+ 当前版本:{versionText()} +
+ 最新版本: + + {latestRelease()?.tag_name ?? "-"} + +
+ 发布名称: + + {latestRelease()?.name ?? "-"} + +
+ 运行环境: + {runtimeTargetText()} +
+ 匹配资源: + + {matchedAsset()?.name ?? "未识别当前系统架构,请点击“打开 Release”手动下载"} + +
+ {downloadState() === "downloading" + ? "下载进度" + : downloadState() === "done" + ? "下载完成" + : "下载失败"} +
+ {downloadState() === "downloading" + ? `已下载 ${downloadProgress()}%` + : downloadState() === "done" + ? "安装包已下载到浏览器默认下载目录,请替换本地程序后重启。" + : downloadError() || "下载失败,请改为 Release 页面手动下载。"} +
更新日志
{renderInlineLinks(block.text)}
账号信息
选择账号后查看课程与记录
+ 选择账号后查看课程与记录 +
{account.user.name} + {platformLabel}
+
学号:{account.user.id}
学院:{account.user.dept}
班级:{account.user.class}
性别:{account.user.gender}
站点:{account.host}
账号:{account.username || "-"}
{courseCountLabel}
{courseTypeLabel}
课程工作台
课程、记录与日志统一查看
+ 课程工作台 +
+ 课程、记录与日志统一查看 +
课程列表
+ 课程列表 +
点击课程查看对应记录
{course.name}
课程号:{course.id}
老师:{course.teacher}
进度:{course.progress}
记录列表
+ 记录列表 +
{props.selectedCourse ? props.selectedCourse.name @@ -221,10 +256,10 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
+ 当前筛选:{recordFilter() === "all" ? "全部" : recordFilter() === "unlearned" ? "未学" : "已学"} +
- {record.name} -
- 记录 ID:{record.id} | 章节:{record.chapterId} -
视频时长:{record.videoDuration}
学习秒数:{record.duration}
学习进度:{record.progress}
开始时间:{record.beginTime || "-"}
完成时间:{record.finalTime || "-"}
查看次数:{record.viewCount}
+ {record.name} +
+ 记录 ID:{record.id} | 章节:{record.chapterId} +
运行日志
+ 运行日志 +
输出会自动滚动到最新位置
暂无日志输出。
diff --git a/src/pages/accounts/Account.tsx b/src/pages/accounts/Account.tsx index c7a3264..5b2308a 100644 --- a/src/pages/accounts/Account.tsx +++ b/src/pages/accounts/Account.tsx @@ -13,9 +13,11 @@ import AddAccountDialog, { import CourseWorkspace from "~/components/account/CourseWorkspace"; import { createWkClient, + hostApi, loginApi, runStudyQueue, type CourseKind, + type RecordItem, type RecordType, } from "~/service/wk"; import { setUnauthorizedHandler } from "~/service/http"; @@ -47,13 +49,32 @@ const createDefaultForm = (host: string): LoginForm => ({ const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim(); const parseDurationToSeconds = (value: string) => { - const parts = value.split(":").map(Number); - if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) { + const input = value.trim(); + if (!input) { return 0; } - const [hours, minutes, seconds] = parts; - return hours * 3600 + minutes * 60 + seconds; + const numeric = Number(input); + if (!Number.isNaN(numeric)) { + return Math.max(0, Math.floor(numeric)); + } + + const parts = input.split(":").map(Number); + if (parts.some((part) => Number.isNaN(part))) { + return 0; + } + + if (parts.length === 3) { + const [hours, minutes, seconds] = parts; + return hours * 3600 + minutes * 60 + seconds; + } + + if (parts.length === 2) { + const [minutes, seconds] = parts; + return minutes * 60 + seconds; + } + + return 0; }; const createRecordCacheKey = ( @@ -61,6 +82,8 @@ const createRecordCacheKey = ( courseId: number, recordType: RecordType, ) => `${accountId}::${courseId}::${recordType || "course"}`; +const STUDY_HEARTBEAT_CHECK_INTERVAL_MS = 5000; +const STUDY_HEARTBEAT_TIMEOUT_MS = 45000; const Account = () => { const [storeState, setStoreState] = createSignal(accountStore.getState()); @@ -80,8 +103,67 @@ const Account = () => { const [isRefreshingRecords, setIsRefreshingRecords] = createSignal(false); const [isRefreshingCourses, setIsRefreshingCourses] = createSignal(false); const [isRefreshingLogs, setIsRefreshingLogs] = createSignal(false); + const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false); const [hasRestoredRecords, setHasRestoredRecords] = createSignal(false); const accountClients = new Map>(); + let recordRequestToken = 0; + + const touchStudyHeartbeat = (accountId: string) => { + accountStore.getState().touchStudyHeartbeat(accountId); + }; + const clearStudyHeartbeat = (accountId: string) => { + accountStore.getState().clearStudyHeartbeat(accountId); + }; + const runStudyHeartbeatWatchdog = () => { + const now = Date.now(); + const snapshot = accountStore.getState(); + + for (const [accountId, running] of Object.entries(snapshot.runningStudyMap)) { + if (!running) { + continue; + } + + const lastHeartbeat = snapshot.studyHeartbeatMap[accountId] ?? 0; + if (now - lastHeartbeat <= STUDY_HEARTBEAT_TIMEOUT_MS) { + continue; + } + + snapshot.setAccountRunningStudy(accountId, false); + snapshot.clearStudyHeartbeat(accountId); + snapshot.appendStudyLog( + accountId, + `检测到刷课任务超时(超过 ${Math.floor( + STUDY_HEARTBEAT_TIMEOUT_MS / 1000, + )} 秒无活动),已自动重置状态`, + ); + } + }; + + const loadRemoteHostsIfNeeded = async () => { + if (isLoadingRemoteHosts()) { + return; + } + + if (settingsStore.getState().remoteHosts.length > 0) { + return; + } + + setIsLoadingRemoteHosts(true); + + try { + const res = await hostApi(); + settingsStore.getState().setRemoteHosts( + res.data.list.map((item) => ({ + label: item.name, + host: item.host, + })), + ); + } catch { + // Ignore host bootstrap errors in account page to avoid blocking main flow. + } finally { + setIsLoadingRemoteHosts(false); + } + }; onMount(() => { const unsubscribeAccount = accountStore.subscribe((state) => { @@ -90,12 +172,19 @@ const Account = () => { const unsubscribeSettings = settingsStore.subscribe((state) => { setSettingsState(state); }); + void loadRemoteHostsIfNeeded(); setUnauthorizedHandler(reloginBySession); + runStudyHeartbeatWatchdog(); + const heartbeatTimer = window.setInterval( + runStudyHeartbeatWatchdog, + STUDY_HEARTBEAT_CHECK_INTERVAL_MS, + ); onCleanup(() => { unsubscribeAccount(); unsubscribeSettings(); setUnauthorizedHandler(null); + window.clearInterval(heartbeatTimer); }); }); @@ -189,6 +278,12 @@ const Account = () => { accountStore.getState().appendStudyLog(targetAccountId, message); }; + const cancelPendingRecordRequest = () => { + recordRequestToken += 1; + setRecordsLoading(false); + setIsRefreshingRecords(false); + }; + const getAccountClient = (accountId: string) => { const existingClient = accountClients.get(accountId); if (existingClient) { @@ -313,6 +408,7 @@ const Account = () => { }; const handleSelectAccount = (accountId: string) => { + cancelPendingRecordRequest(); accountStore.getState().setSelectedAccountId(accountId); accountStore.getState().setSelectedCourseId(null); accountStore.getState().setRecords([]); @@ -360,6 +456,7 @@ const Account = () => { setErrorMessage(message); accountStore.getState().setAccountCourses(accountId, []); if (accountStore.getState().selectedAccountId === accountId) { + cancelPendingRecordRequest(); accountStore.getState().setSelectedCourseId(null); accountStore.getState().setRecords([]); } @@ -437,36 +534,59 @@ const Account = () => { if (!account) { return; } + const requestToken = ++recordRequestToken; + const accountId = account.id; setRecordsLoading(true); setIsRefreshingRecords(true); setRecordError(""); try { - const res = await getAccountClient(account.id).recordApi({ + const res = await getAccountClient(accountId).recordApi({ course_id: String(courseId), page: 0, record_type: nextRecordType, }); - accountStore.getState().setRecords(res.data.list); + 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() .setRecordCache( - createRecordCacheKey(account.id, courseId, nextRecordType), - res.data.list, + createRecordCacheKey(accountId, courseId, nextRecordType), + list, ); } catch (error) { + if (requestToken !== recordRequestToken) { + return; + } const message = error instanceof Error ? error.message : "获取记录失败,请稍后重试。"; setRecordError(message); accountStore.getState().setRecords([]); } finally { - setRecordsLoading(false); - setIsRefreshingRecords(false); + if (requestToken === recordRequestToken) { + setRecordsLoading(false); + setIsRefreshingRecords(false); + } } }; const handleSelectCourse = (courseId: number) => { + cancelPendingRecordRequest(); accountStore.getState().setSelectedCourseId(courseId); }; @@ -507,18 +627,19 @@ const Account = () => { } const queueItems = records() - .filter((item) => item.progress !== "1.00") .map((item) => ({ nodeId: item.id, name: item.name, - currentTime: Number(item.duration || 0), + currentTime: parseDurationToSeconds(item.duration), totalTime: parseDurationToSeconds(item.videoDuration), progress: item.progress, completed: stripHtml(item.state) === "已学", - })); + })) + .filter((item) => item.progress !== "1.00" && !item.completed); try { accountStore.getState().setAccountRunningStudy(account.id, true); + touchStudyHeartbeat(account.id); appendStudyLog(`开始刷课:${course.name}`, account.id); await runStudyQueue({ accountId: account.id, @@ -528,10 +649,14 @@ const Account = () => { client: getAccountClient(account.id), isRunningStudy: () => !!accountStore.getState().runningStudyMap[account.id], - setIsRunningStudy: () => - accountStore.getState().setAccountRunningStudy(account.id, false), - onLog: (message: string, accoundID: string) => - appendStudyLog(message, accoundID), + setIsRunningStudy: () => { + accountStore.getState().setAccountRunningStudy(account.id, false); + clearStudyHeartbeat(account.id); + }, + onLog: (message: string, accoundID: string) => { + touchStudyHeartbeat(accoundID); + appendStudyLog(message, accoundID); + }, }); if (accountStore.getState().runningStudyMap[account.id]) { appendStudyLog(`刷课完成:${course.name}`, account.id); @@ -543,6 +668,7 @@ const Account = () => { setRecordError(message); } finally { accountStore.getState().setAccountRunningStudy(account.id, false); + clearStudyHeartbeat(account.id); } }; @@ -553,12 +679,14 @@ const Account = () => { } accountStore.getState().setAccountRunningStudy(account.id, false); + clearStudyHeartbeat(account.id); appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id); }; createEffect( on([selectedAccountId, courseKind], ([accountId, kind]) => { if (!accountId) { + cancelPendingRecordRequest(); return; } @@ -570,6 +698,7 @@ const Account = () => { on( [selectedAccountId, selectedCourseId, recordType], ([accountId, courseId, type]) => { + cancelPendingRecordRequest(); if (!accountId || !courseId) { accountStore.getState().setRecords([]); return; @@ -613,12 +742,12 @@ const Account = () => { return ( - + Account Center - + 账号管理 @@ -627,14 +756,14 @@ const Account = () => { 添加账号 - + { accountStore.getState().setRecordType(value) } onChangeCourseRecordType={(value) => { + cancelPendingRecordRequest(); accountStore.getState().setCourseKind(value); accountStore.getState().setSelectedCourseId(null); accountStore.getState().setRecords([]); diff --git a/src/pages/logs/Logs.tsx b/src/pages/logs/Logs.tsx index 740fac0..f974b65 100644 --- a/src/pages/logs/Logs.tsx +++ b/src/pages/logs/Logs.tsx @@ -10,6 +10,17 @@ import { import { accountStore } from "~/store/account"; import { settingsStore } from "~/store/settings"; +type LogRow = { + id: string; + accountId: string; + accountName: string; + host: string; + seq: number; + timestamp: string; + timestampValue: number; + content: string; +}; + const extractTimestamp = (message: string) => { const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/); return match?.[1] ?? null; @@ -25,7 +36,6 @@ const parseTimestampValue = (timestamp: string | null) => { } const [hours, minutes, seconds] = timestamp.split(":").map(Number); - if ([hours, minutes, seconds].some((item) => Number.isNaN(item))) { return -1; } @@ -33,6 +43,11 @@ const parseTimestampValue = (timestamp: string | null) => { return hours * 3600 + minutes * 60 + seconds; }; +const normalizeContent = (message: string) => { + const value = stripTimestamp(message).replace(/\s+/g, " ").trim(); + return value || "-"; +}; + const Logs = () => { const [accountState, setAccountState] = createSignal(accountStore.getState()); const [settingsState, setSettingsState] = createSignal( @@ -66,75 +81,60 @@ const Logs = () => { ) as Record; }); - const allLogs = createMemo(() => { - return Object.entries(accountState().studyLogsMap) - .flatMap(([accountId, messages]) => { + const logRows = createMemo(() => { + const rows = Object.entries(accountState().studyLogsMap).flatMap( + ([accountId, messages]) => { const accountInfo = accountInfoMap()[accountId]; - const accountName = accountInfo?.name ?? accountId; - return messages.map((message, index) => { const timestamp = extractTimestamp(message); - return { id: `${accountId}-${index}`, accountId, - accountName, - index: index + 1, - timestamp, + accountName: accountInfo?.name ?? accountId, + host: accountInfo?.host ?? "-", + seq: index + 1, + timestamp: timestamp ?? "--:--:--", timestampValue: parseTimestampValue(timestamp), - content: stripTimestamp(message), - raw: message, + content: normalizeContent(message), }; }); - }) - .sort((left, right) => { - if (left.timestampValue !== right.timestampValue) { - return left.timestampValue - right.timestampValue; - } - - if (left.index !== right.index) { - return left.index - right.index; - } - - return left.accountId.localeCompare(right.accountId); - }); - }); - - const accountSummaries = createMemo(() => { - const accountIds = Array.from( - new Set([ - ...accountState().accounts.map((account) => account.id), - ...Object.keys(accountState().studyLogsMap), - ]), + }, ); - return accountIds.map((accountId) => { - const accountInfo = accountInfoMap()[accountId]; - const logs = accountState().studyLogsMap[accountId] ?? []; - const latestMessage = logs[logs.length - 1] ?? "暂无日志"; + return rows.sort((left, right) => { + const leftTs = + left.timestampValue >= 0 ? left.timestampValue : Number.MAX_SAFE_INTEGER; + const rightTs = + right.timestampValue >= 0 + ? right.timestampValue + : Number.MAX_SAFE_INTEGER; - return { - id: accountId, - name: accountInfo?.name ?? accountId, - host: accountInfo?.host ?? "未知账号来源", - total: logs.length, - latestMessage: stripTimestamp(latestMessage), - latestTime: extractTimestamp(latestMessage), - }; + if (leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.accountName !== right.accountName) { + return left.accountName.localeCompare(right.accountName, "zh-CN"); + } + if (left.seq !== right.seq) { + return left.seq - right.seq; + } + return left.accountId.localeCompare(right.accountId); }); }); - const latestLog = createMemo(() => { - const logs = allLogs(); - return logs.length > 0 ? logs[logs.length - 1] : null; + const totalAccountsWithLogs = createMemo(() => { + return Object.values(accountState().studyLogsMap).filter( + (messages) => messages.length > 0, + ).length; }); - const totalAccountsWithLogs = createMemo(() => { - return accountSummaries().filter((item) => item.total > 0).length; + const latestLog = createMemo(() => { + const rows = logRows(); + return rows.length > 0 ? rows[rows.length - 1] : null; }); createEffect(() => { - allLogs().length; + logRows().length; if (!settingsState().autoScrollLogs) { return; @@ -170,7 +170,7 @@ const Logs = () => { 日志中心 - 聚合全部账号日志,优先把空间留给正文。 + 单行并列展示时间、账号、姓名与日志内容。 @@ -180,7 +180,7 @@ const Logs = () => { TOTAL LOGS - {allLogs().length} + {logRows().length} @@ -191,14 +191,11 @@ const Logs = () => { {totalAccountsWithLogs()} - - - AUTO SCROLL - - - {settingsState().autoScrollLogs ? "跟随最新" : "手动查看"} - - + + + 最新: {latestLog()?.timestamp} / {latestLog()?.accountName} + + { - - - - 账号概览 - 最新输出与日志数量 + + + + + {settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"} + + + {settingsState().autoScrollLogs ? "自动滚动中" : "自动滚动关闭"} + + - - - {(account) => ( - - {/* 顶部 */} - - - - {account.name} - - {account.host} - - - - {account.total} - - - - {/* 最新日志 */} - - - Latest - {account.latestTime ?? "--:--"} - - - - {account.latestMessage} - - - - )} - - - - - - - - - 实时日志流 - - 保留关键信息,把更多高度让给正文 - + + 0} + fallback={ + + 暂无日志输出 - - - - {settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"} - - - {settingsState().autoScrollLogs - ? "自动滚动中" - : "自动滚动关闭"} - - - - 最近来源 {latestLog()?.accountName} - - - - - - - - 0} - fallback={ - - 暂无日志输出 - - } - > - - - {(log) => ( - - {/* 头部 */} - - - {log.accountName} - #{log.index} + + + 时间 + 姓名 + 账号 + 主机 + 序号 + 内容 + - - - {log.timestamp} - - - - - {log.accountId} - - - {/* 内容 */} - - {log.content} - + + + {(row) => ( + + + {settingsState().showLogTimestamps + ? row.timestamp + : "--:--:--"} + + + {row.accountName} + + + {row.accountId} + + + {row.host} + + #{row.seq} + + {row.content} + )} - - - - + + + + ); }; diff --git a/src/service/http.ts b/src/service/http.ts index b7f2ee2..63c5978 100644 --- a/src/service/http.ts +++ b/src/service/http.ts @@ -13,16 +13,40 @@ export type HttpClient = { }; let unauthorizedHandler: UnauthorizedHandler | null = null; +const reloginTaskMap = new Map>(); export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => { unauthorizedHandler = handler; }; +const runUnauthorizedHandler = (sessionId: string) => { + const pendingTask = reloginTaskMap.get(sessionId); + if (pendingTask) { + return pendingTask; + } + + const task = (async () => { + if (!unauthorizedHandler) { + return false; + } + + try { + return await unauthorizedHandler(sessionId); + } finally { + reloginTaskMap.delete(sessionId); + } + })(); + + reloginTaskMap.set(sessionId, task); + return task; +}; + export const createHttpClient = ( resolveSessionId?: SessionResolver, ): HttpClient => { const instance = axios.create({ baseURL: import.meta.env.VITE_BASE_URL, + timeout: 15000, }); instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { @@ -52,7 +76,7 @@ export const createHttpClient = ( !url.includes("/api/login") ) { config._retry = true; - const ok = await unauthorizedHandler(sessionId); + const ok = await runUnauthorizedHandler(sessionId); if (ok) { return instance.request(config); diff --git a/src/service/update.ts b/src/service/update.ts new file mode 100644 index 0000000..6839552 --- /dev/null +++ b/src/service/update.ts @@ -0,0 +1,302 @@ +export type ReleaseAsset = { + id: number; + name: string; + size: number; + download_count: number; + browser_download_url: string; +}; + +export type LatestRelease = { + id: number; + tag_name: string; + name: string; + body: string; + html_url: string; + published_at: string; + assets: ReleaseAsset[]; +}; + +export type RuntimeOS = "windows" | "linux" | "darwin" | "unknown"; +export type RuntimeArch = "amd64" | "arm64" | "unknown"; + +export type RuntimeTarget = { + os: RuntimeOS; + arch: RuntimeArch; +}; + +export const UPDATE_API_URL = + "https://gitea.kmux.cn/api/v1/repos/cqcst/wk-backend/releases/latest"; +export const RELEASES_PAGE_URL = + "https://gitea.kmux.cn/cqcst/wk-backend/releases"; +const RELEASE_HOST_ORIGIN = "https://gitea.kmux.cn"; + +type UADataHighEntropy = { + architecture?: string; + bitness?: string; + platform?: string; +}; + +type UAData = { + platform?: string; + getHighEntropyValues?: ( + hints: string[], + ) => Promise; +}; + +const normalizeSemver = (value: string) => { + const match = value.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?/); + if (!match) { + return null; + } + + return [1, 2, 3, 4].map((index) => Number(match[index] ?? 0)); +}; + +export const isRemoteVersionNewer = (currentVersion: string, tagName: string) => { + const current = normalizeSemver(currentVersion); + const remote = normalizeSemver(tagName); + + if (!remote) { + return false; + } + if (!current) { + return true; + } + + for (let i = 0; i < Math.max(current.length, remote.length); i += 1) { + const left = current[i] ?? 0; + const right = remote[i] ?? 0; + if (right > left) { + return true; + } + if (right < left) { + return false; + } + } + + return false; +}; + +const detectOSFromString = (value: string) => { + const lower = value.toLowerCase(); + if (lower.includes("win")) { + return "windows" as const; + } + if (lower.includes("mac") || lower.includes("darwin")) { + return "darwin" as const; + } + if (lower.includes("linux") || lower.includes("x11")) { + return "linux" as const; + } + return "unknown" as const; +}; + +const detectArchFromString = (value: string) => { + const lower = value.toLowerCase(); + if ( + lower.includes("arm64") || + lower.includes("aarch64") || + lower.includes("armv8") + ) { + return "arm64" as const; + } + if ( + lower.includes("amd64") || + lower.includes("x86_64") || + lower.includes("x64") || + lower.includes("win64") || + lower.includes("x86-64") + ) { + return "amd64" as const; + } + return "unknown" as const; +}; + +export const detectRuntimeTarget = async (): Promise => { + const userAgent = navigator.userAgent || ""; + const platform = navigator.platform || ""; + const uaData = (navigator as Navigator & { userAgentData?: UAData }) + .userAgentData; + + let os = detectOSFromString(platform || userAgent); + let arch = detectArchFromString(userAgent); + + if (uaData?.platform) { + const uaDataOs = detectOSFromString(uaData.platform); + if (uaDataOs !== "unknown") { + os = uaDataOs; + } + } + + if (uaData?.getHighEntropyValues) { + try { + const high = await uaData.getHighEntropyValues([ + "architecture", + "bitness", + "platform", + ]); + const highArch = detectArchFromString( + `${high.architecture ?? ""} ${high.bitness ?? ""}`, + ); + if (highArch !== "unknown") { + arch = highArch; + } else if (high.architecture === "x86" && high.bitness === "64") { + arch = "amd64"; + } + + if (high.platform) { + const highOs = detectOSFromString(high.platform); + if (highOs !== "unknown") { + os = highOs; + } + } + } catch { + // Ignore UA high-entropy detection errors and keep fallbacks. + } + } + + return { os, arch }; +}; + +const assetMatchesOS = (name: string, os: RuntimeOS) => { + if (os === "windows") { + return /\bwindows\b|\bwin\b|\.exe$/.test(name); + } + if (os === "linux") { + return /\blinux\b/.test(name); + } + if (os === "darwin") { + return /\bdarwin\b|\bmac\b|\bmacos\b|\bosx\b/.test(name); + } + return false; +}; + +const assetMatchesArch = (name: string, arch: RuntimeArch) => { + if (arch === "amd64") { + return /\bamd64\b|\bx86_64\b|\bx64\b/.test(name); + } + if (arch === "arm64") { + return /\barm64\b|\baarch64\b/.test(name); + } + return false; +}; + +export const resolveAssetForRuntime = ( + assets: ReleaseAsset[], + target: RuntimeTarget, +) => { + if (target.os === "unknown") { + return null; + } + + const mapped = assets.map((asset) => { + const lower = asset.name.toLowerCase(); + const osScore = assetMatchesOS(lower, target.os) ? 2 : 0; + const archScore = + target.arch === "unknown" + ? 0 + : assetMatchesArch(lower, target.arch) + ? 2 + : 0; + const exactPenalty = + target.arch !== "unknown" && archScore === 0 ? -2 : 0; + return { + asset, + score: osScore + archScore + exactPenalty, + }; + }); + + const matched = mapped + .filter((item) => item.score > 0) + .sort((left, right) => right.score - left.score); + + return matched[0]?.asset ?? null; +}; + +const triggerBrowserDownload = (blob: Blob, filename: string) => { + const link = document.createElement("a"); + const blobUrl = URL.createObjectURL(blob); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); +}; + +export const fetchLatestRelease = async (signal?: AbortSignal) => { + const response = await fetch(UPDATE_API_URL, { + method: "GET", + headers: { + accept: "application/json", + }, + signal, + }); + + if (!response.ok) { + throw new Error(`检查更新失败: HTTP ${response.status}`); + } + + return (await response.json()) as LatestRelease; +}; + +export const downloadReleaseAsset = async ( + asset: ReleaseAsset, + onProgress?: (progress: number) => void, +) => { + const response = await fetch(asset.browser_download_url, { + method: "GET", + }); + + if (!response.ok) { + throw new Error(`下载失败: HTTP ${response.status}`); + } + + const totalSize = Number(response.headers.get("content-length") ?? 0); + if (!response.body || !Number.isFinite(totalSize) || totalSize <= 0) { + const blob = await response.blob(); + triggerBrowserDownload(blob, asset.name); + onProgress?.(100); + return; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + + chunks.push(value); + received += value.length; + const progress = Math.min(99, Math.round((received / totalSize) * 100)); + onProgress?.(progress); + } + + const merged = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + + const blob = new Blob([merged.buffer]); + triggerBrowserDownload(blob, asset.name); + onProgress?.(100); +}; + +export const resolveReleaseLink = (url: string) => { + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + if (url.startsWith("/")) { + return `${RELEASE_HOST_ORIGIN}${url}`; + } + return `${RELEASE_HOST_ORIGIN}/${url}`; +}; diff --git a/src/service/wk.ts b/src/service/wk.ts index a467b17..8c84cc4 100644 --- a/src/service/wk.ts +++ b/src/service/wk.ts @@ -193,18 +193,32 @@ const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); export const runStudyQueue = async (_payload: StudyRunnerPayload) => { const stopFlag = _payload.isRunningStudy; + const stepSeconds = Math.max(1, Math.floor(_payload.intervalSeconds || 5)); + const sleepMs = stepSeconds * 1000; + const maxRetryCount = 3; + const maxStudySubmitRetry = 3; + const studySubmitRetrySleepMs = 5000; + for (const item of _payload.items) { + const learnedTime = Math.max(0, Math.floor(item.currentTime || 0)); + const rawTotalTime = Math.max(0, Math.floor(item.totalTime || 0)); + const totalTime = Math.max(0, rawTotalTime - learnedTime); let currentTime = 0; let count = 0; let study_id = 0; - let total = item.totalTime - item.currentTime; + let retryCount = 0; - while (currentTime <= total) { + if (totalTime <= 0) { + _payload.onLog?.(`跳过已完成节点: ${item.name}`, _payload.accountId); + continue; + } + + while (currentTime <= totalTime) { if (!stopFlag()) { _payload.onLog?.("⛔ 已手动停止", _payload.accountId); return; } - const message = `[${item.name}]: ${currentTime}/${total}`; + const message = `[${item.name}]: ${currentTime}/${totalTime}`; _payload.onLog?.(message, _payload.accountId); try { @@ -212,8 +226,69 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => { node_id: item.nodeId, study_id: String(study_id), study_time: String(currentTime), - status: count === 0 ? 1 : currentTime >= total ? 3 : 2, + status: count === 0 ? 1 : currentTime >= totalTime ? 3 : 2, }); + const submitMessage = String(resp.data.msg ?? ""); + const isSubmitFailed = + submitMessage.includes("提交学时失败") || resp.data.status === false; + + if (isSubmitFailed) { + let submitRetry = 0; + let fixedResp = resp; + + while (submitRetry < maxStudySubmitRetry) { + submitRetry += 1; + _payload.onLog?.( + `⚠️ 提交学时失败,${submitRetry}/${maxStudySubmitRetry} 次重试后再提交: ${item.name}`, + _payload.accountId, + ); + await sleep(studySubmitRetrySleepMs); + + if (!stopFlag()) { + _payload.onLog?.("⛔ 已手动停止", _payload.accountId); + return; + } + + fixedResp = await _payload.client.studyApi({ + node_id: item.nodeId, + study_id: String(study_id), + study_time: String(currentTime), + status: count === 0 ? 1 : currentTime >= totalTime ? 3 : 2, + }); + + const nextMsg = String(fixedResp.data.msg ?? ""); + const nextFailed = + nextMsg.includes("提交学时失败") || fixedResp.data.status === false; + if (!nextFailed) { + break; + } + } + + const finalMsg = String(fixedResp.data.msg ?? ""); + const stillFailed = + finalMsg.includes("提交学时失败") || fixedResp.data.status === false; + if (stillFailed) { + _payload.onLog?.( + `⛔ 提交学时连续重试 ${maxStudySubmitRetry} 次仍失败,停止刷课: ${item.name}`, + _payload.accountId, + ); + _payload.setIsRunningStudy(); + return; + } + + study_id = fixedResp.data.studyId; + retryCount = 0; + + if (currentTime === totalTime) { + break; + } + + currentTime = Math.min(currentTime + stepSeconds, totalTime); + count++; + await sleep(sleepMs); + continue; + } + if (resp.data.state != 0) { _payload.onLog?.(`⛔ 自动停止: ${resp.data.msg}`, _payload.accountId); _payload.setIsRunningStudy(); @@ -221,17 +296,29 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => { } study_id = resp.data.studyId; + retryCount = 0; - if (currentTime === total) break; + if (currentTime === totalTime) { + break; + } - currentTime = Math.min(currentTime + 5, total); + currentTime = Math.min(currentTime + stepSeconds, totalTime); count++; } catch (error) { + retryCount += 1; const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`; _payload.onLog?.(errorMessage, _payload.accountId); + + if (retryCount >= maxRetryCount) { + _payload.onLog?.( + `⚠️ 连续失败 ${maxRetryCount} 次,跳过节点: ${item.name}`, + _payload.accountId, + ); + break; + } } - await sleep(5000); + await sleep(sleepMs); } } }; diff --git a/src/store/account.ts b/src/store/account.ts index d00bdb6..b24f540 100644 --- a/src/store/account.ts +++ b/src/store/account.ts @@ -19,6 +19,7 @@ const withLogTimestamp = (message: string) => { return `[${timestamp}] ${message}`; }; +const MAX_STUDY_LOGS_PER_ACCOUNT = 1000; export type AccountAuth = { password: string; @@ -38,6 +39,45 @@ export type AccountItem = { export type RecordCacheMap = Record; +type PersistPreferences = { + persistAccounts: boolean; + persistRecords: boolean; + persistLogs: boolean; +}; + +const settingsStorageKey = "settings-storage"; +const defaultPersistPreferences: PersistPreferences = { + persistAccounts: true, + persistRecords: true, + persistLogs: true, +}; + +const resolvePersistPreferences = (): PersistPreferences => { + try { + const rawValue = localStorage.getItem(settingsStorageKey); + if (!rawValue) { + return defaultPersistPreferences; + } + + const parsedValue = JSON.parse(rawValue) as { + state?: Partial; + }; + const persistedState = parsedValue?.state; + + return { + persistAccounts: + persistedState?.persistAccounts ?? + defaultPersistPreferences.persistAccounts, + persistRecords: + persistedState?.persistRecords ?? defaultPersistPreferences.persistRecords, + persistLogs: + persistedState?.persistLogs ?? defaultPersistPreferences.persistLogs, + }; + } catch { + return defaultPersistPreferences; + } +}; + type AccountState = { accounts: AccountItem[]; selectedAccountId: string; @@ -49,6 +89,7 @@ type AccountState = { recordCacheMap: RecordCacheMap; studyLogsMap: Record; runningStudyMap: Record; + studyHeartbeatMap: Record; setSelectedAccountId: (accountId: string) => void; setExpandedAccountId: (accountId: string) => void; setSelectedCourseId: (courseId: number | null) => void; @@ -57,6 +98,9 @@ type AccountState = { setRecords: (records: RecordItem[]) => void; setRecordCache: (cacheKey: string, records: RecordItem[]) => void; setAccountRunningStudy: (accountId: string, value: boolean) => void; + touchStudyHeartbeat: (accountId: string, timestamp?: number) => void; + clearStudyHeartbeat: (accountId: string) => void; + clearAllStudyHeartbeat: () => void; appendStudyLog: (accountId: string, message: string) => void; clearStudyLogs: (accountId: string) => void; clearAllStudyLogs: () => void; @@ -78,6 +122,7 @@ export const accountStore = createStore()( recordCacheMap: {}, studyLogsMap: {}, runningStudyMap: {}, + studyHeartbeatMap: {}, setSelectedAccountId: (accountId) => set({ selectedAccountId: accountId }), setExpandedAccountId: (accountId) => @@ -100,16 +145,39 @@ export const accountStore = createStore()( [accountId]: value, }, })), - appendStudyLog: (accountId, message) => + touchStudyHeartbeat: (accountId, timestamp) => set((state) => ({ - studyLogsMap: { - ...state.studyLogsMap, - [accountId]: [ - ...(state.studyLogsMap[accountId] ?? []), - withLogTimestamp(message), - ], + studyHeartbeatMap: { + ...state.studyHeartbeatMap, + [accountId]: timestamp ?? Date.now(), }, })), + clearStudyHeartbeat: (accountId) => + set((state) => ({ + studyHeartbeatMap: Object.fromEntries( + Object.entries(state.studyHeartbeatMap).filter( + ([key]) => key !== accountId, + ), + ), + })), + clearAllStudyHeartbeat: () => + set({ + studyHeartbeatMap: {}, + }), + appendStudyLog: (accountId, message) => + set((state) => { + const nextLogs = [ + ...(state.studyLogsMap[accountId] ?? []), + withLogTimestamp(message), + ].slice(-MAX_STUDY_LOGS_PER_ACCOUNT); + + return { + studyLogsMap: { + ...state.studyLogsMap, + [accountId]: nextLogs, + }, + }; + }), clearStudyLogs: (accountId) => set((state) => ({ studyLogsMap: { @@ -171,24 +239,42 @@ export const accountStore = createStore()( ([key]) => key !== accountId, ), ), + studyHeartbeatMap: Object.fromEntries( + Object.entries(state.studyHeartbeatMap).filter( + ([key]) => key !== accountId, + ), + ), }; }), }), { name: "account-storage", storage: createJSONStorage(() => localStorage), - partialize: (state) => ({ - accounts: state.accounts, - selectedAccountId: state.selectedAccountId, - expandedAccountId: state.expandedAccountId, - selectedCourseId: state.selectedCourseId, - courseKind: state.courseKind, - recordType: state.recordType, - records: state.records, - recordCacheMap: state.recordCacheMap, - studyLogsMap: state.studyLogsMap, - runningStudyMap: state.runningStudyMap, - }), + partialize: (state) => { + const preferences = resolvePersistPreferences(); + const persistedState: Partial = { + courseKind: state.courseKind, + recordType: state.recordType, + }; + + if (preferences.persistAccounts) { + persistedState.accounts = state.accounts; + persistedState.selectedAccountId = state.selectedAccountId; + persistedState.expandedAccountId = state.expandedAccountId; + persistedState.selectedCourseId = state.selectedCourseId; + } + + if (preferences.persistRecords) { + persistedState.records = state.records; + persistedState.recordCacheMap = state.recordCacheMap; + } + + if (preferences.persistLogs) { + persistedState.studyLogsMap = state.studyLogsMap; + } + + return persistedState; + }, }, ), ); diff --git a/src/store/settings.ts b/src/store/settings.ts index 834f804..5ea7522 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -35,6 +35,38 @@ type SettingsState = { }; const accountStorageKey = "account-storage"; +type PersistedStorage = { + state?: Record; + version?: number; +}; + +const patchAccountStorage = ( + patcher: (state: Record) => Record, +) => { + const rawValue = localStorage.getItem(accountStorageKey); + if (!rawValue) { + return; + } + + try { + const parsedValue = JSON.parse(rawValue) as PersistedStorage; + const currentState = + parsedValue.state && typeof parsedValue.state === "object" + ? parsedValue.state + : {}; + const nextState = patcher(currentState); + + localStorage.setItem( + accountStorageKey, + JSON.stringify({ + ...parsedValue, + state: nextState, + }), + ); + } catch { + localStorage.removeItem(accountStorageKey); + } +}; const uniqueHosts = (hosts: HostOption[]) => { const map = new Map(); @@ -74,26 +106,59 @@ export const settingsStore = createStore()( if (section === "accounts") set({ persistAccounts: value }); if (section === "records") set({ persistRecords: value }); if (section === "logs") set({ persistLogs: value }); + + if (!value) { + queueMicrotask(() => get().clearPersistedSection(section)); + } }, clearPersistedSection: (section) => { if (section === "accounts") { - localStorage.removeItem(accountStorageKey); + patchAccountStorage((state) => { + const { + 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") { - set({ persistRecords: false }); - queueMicrotask(() => set({ persistRecords: true })); + patchAccountStorage((state) => { + const { records, recordCacheMap, selectedCourseId, ...rest } = + state; + void records; + void recordCacheMap; + void selectedCourseId; + return rest; + }); } if (section === "logs") { - set({ persistLogs: false }); - queueMicrotask(() => set({ persistLogs: true })); + patchAccountStorage((state) => { + const { studyLogsMap, runningStudyMap, ...rest } = state; + void studyLogsMap; + void runningStudyMap; + return rest; + }); } }, clearAllPersistedData: () => { localStorage.removeItem(accountStorageKey); - get().clearPersistedSection("records"); - get().clearPersistedSection("logs"); }, setAutoScrollLogs: (value) => set({ autoScrollLogs: value }), setShowLogTimestamps: (value) => set({ showLogTimestamps: value }), diff --git a/vite.config.ts b/vite.config.ts index ba2b265..8fd6cff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,4 +10,8 @@ export default defineConfig({ "~": path.resolve(__dirname, "src"), }, }, + + server: { + host: "local.kmux.cn", + }, });
Account Center
@@ -627,14 +756,14 @@ const Account = () => {
- 聚合全部账号日志,优先把空间留给正文。 + 单行并列展示时间、账号、姓名与日志内容。
账号概览
最新输出与日志数量
- {account.name} -
{account.host}
- {account.latestMessage} -
实时日志流
- 保留关键信息,把更多高度让给正文 -
- {log.content} -