Files
wk-frontend/src/components/account/CourseWorkspace.tsx
zhilv 9e7131f210 fix: 修复问题
- 修复刷课错误不会停止
- 添加课程刷新
- 添加课程列表、记录列表缓存
- 显示当前版本
2026-03-28 19:22:31 +08:00

406 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { For, Show, createEffect, type JSX } from "solid-js";
import type { CourseKind, RecordType } from "~/service/wk";
import type { AccountItem } from "~/store/account";
import type { CourseType } from "~/types/Course";
import type { RecordItem } from "~/service/wk";
type RecordTypeOption = {
label: string;
value: RecordType;
};
type CourseRecordTypeOption = {
label: string;
value: CourseKind;
};
interface CourseWorkspaceProps {
selectedAccount: AccountItem | null;
selectedCourseList: CourseType[];
selectedCourseId: number | null;
selectedCourse: CourseType | null;
recordType: RecordType;
courseKind: CourseKind;
currentCourseKindLabel: string;
showingCachedRecords: boolean;
recordTypeOptions: RecordTypeOption[];
courseRecordTypeOptions: CourseRecordTypeOption[];
records: RecordItem[];
studyLogs: string[];
recordsLoading: boolean;
recordError: string;
isRefreshingRecords: boolean;
isRefreshingCourseRecords: boolean;
isRunningStudy: boolean;
isRefreshingLogs: boolean;
autoScrollLogs: boolean;
showLogTimestamps: boolean;
densityMode: "comfortable" | "compact";
logFontSize: number;
onSelectCourse: (courseId: number) => void;
onRefreshRecords: () => void;
onRefreshCourseRecords: () => void;
onChangeRecordType: (value: RecordType) => void;
onChangeCourseRecordType: (value: CourseKind) => void;
onStartStudy: () => void;
onStopStudy: () => void;
onRefreshLogs: () => void;
onClearLogs: () => void;
renderRecordState: (value: string) => string;
}
const EmptyState = (props: { children: JSX.Element }) => (
<div class="rounded-3xl border border-dashed border-zinc-300 bg-white px-4 py-8 text-center text-zinc-500">
{props.children}
</div>
);
const extractTimestamp = (message: string) => {
const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/);
return match?.[1] ?? null;
};
const stripTimestamp = (message: string) => {
return message.replace(/^\[(\d{2}:\d{2}:\d{2})\]\s*/, "");
};
const CourseWorkspace = (props: CourseWorkspaceProps) => {
let logContainerRef: HTMLDivElement | undefined;
createEffect(() => {
props.studyLogs.length;
if (!props.autoScrollLogs) {
return;
}
requestAnimationFrame(() => {
const element = logContainerRef;
if (element) {
element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
}
});
});
createEffect(() => {
if (logContainerRef) {
logContainerRef.style.fontSize = `${props.logFontSize}px`;
}
});
const compact = () => props.densityMode === "compact";
return (
<section class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-white/80 bg-white/85 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.3)]">
<div class="flex items-center justify-between border-b border-zinc-200/80 px-5 py-4">
<div>
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"></p>
</div>
<Show when={props.selectedAccount}>
<div class="rounded-full bg-cyan-50 px-3 py-1 text-sm text-cyan-700">
{props.selectedAccount?.user.name}
</div>
</Show>
</div>
<Show
when={props.selectedAccount}
fallback={
<div class="flex h-full items-center justify-center px-6 text-zinc-500">
</div>
}
>
<div
class={
compact()
? "grid min-h-0 flex-1 gap-3 p-3 xl:grid-cols-[320px_minmax(0,1fr)]"
: "grid min-h-0 flex-1 gap-4 p-4 xl:grid-cols-[340px_minmax(0,1fr)]"
}
>
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
<div class="border-b border-zinc-200 px-4 py-3">
<p class="text-sm font-semibold text-zinc-800"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm transition outline-none focus:border-cyan-400"
value={props.courseKind}
onChange={(event) =>
props.onChangeCourseRecordType(
event.currentTarget.value as CourseKind,
)
}
>
<For each={props.courseRecordTypeOptions}>
{(item) => <option value={item.value}>{item.label}</option>}
</For>
</select>
<button
type="button"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingCourseRecords}
onClick={props.onRefreshCourseRecords}
>
{props.isRefreshingCourseRecords ? "刷新中..." : "刷新记录"}
</button>
</div>
</div>
<div
class={
compact()
? "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2.5"
: "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3"
}
>
<Show when={props.selectedCourseList.length === 0}>
<EmptyState>
{props.currentCourseKindLabel}
</EmptyState>
</Show>
<For each={props.selectedCourseList}>
{(course) => {
const selected = () => course.id === props.selectedCourseId;
return (
<button
type="button"
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"
: 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"
}
onClick={() => props.onSelectCourse(course.id)}
>
<p class="truncate text-base font-semibold text-zinc-900">
{course.name}
</p>
<div class="mt-3 grid gap-1 text-sm text-zinc-600">
<p>{course.id}</p>
<p>{course.teacher}</p>
<p>{course.progress}</p>
</div>
</button>
);
}}
</For>
</div>
</div>
<div
class={
compact()
? "grid min-h-0 gap-3 xl:grid-rows-[minmax(0,1fr)_240px]"
: "grid min-h-0 gap-4 xl:grid-rows-[minmax(0,1fr)_260px]"
}
>
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
<div>
<p class="text-sm font-semibold text-zinc-800"></p>
<p class="mt-1 text-xs text-zinc-500">
{props.selectedCourse
? props.selectedCourse.name
: "请选择课程"}
</p>
<Show
when={props.showingCachedRecords && !props.recordsLoading}
>
<p class="mt-1 text-xs text-amber-600">
</p>
</Show>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={
!props.selectedCourse || props.isRefreshingRecords
}
onClick={props.onRefreshRecords}
>
{props.isRefreshingRecords ? "刷新中..." : "刷新记录"}
</button>
<select
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm transition outline-none focus:border-cyan-400"
value={props.recordType}
onChange={(event) =>
props.onChangeRecordType(
event.currentTarget.value as RecordType,
)
}
>
<For each={props.recordTypeOptions}>
{(item) => (
<option value={item.value}>{item.label}</option>
)}
</For>
</select>
<Show when={props.recordType === ""}>
<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"
onClick={
props.isRunningStudy
? props.onStopStudy
: props.onStartStudy
}
>
{props.isRunningStudy ? "停止刷课" : "开始刷课"}
</button>
</Show>
</div>
</div>
<div
class={
compact()
? "min-h-0 flex-1 overflow-y-auto p-2.5"
: "min-h-0 flex-1 overflow-y-auto p-3"
}
>
<Show when={props.recordsLoading}>
<EmptyState>...</EmptyState>
</Show>
<Show when={!props.recordsLoading && props.recordError}>
<div class="rounded-3xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{props.recordError}
</div>
</Show>
<Show
when={
!props.recordsLoading &&
!props.recordError &&
!props.selectedCourse
}
>
<EmptyState></EmptyState>
</Show>
<Show
when={
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.records.length === 0
}
>
<EmptyState></EmptyState>
</Show>
<div class="flex flex-col gap-3">
<For each={props.records}>
{(record) => (
<div
class={
compact()
? "rounded-[20px] border border-zinc-200 bg-white px-3 py-3 shadow-sm"
: "rounded-[22px] border border-zinc-200 bg-white px-4 py-4 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="truncate text-base font-semibold text-zinc-900">
{record.name}
</p>
<p class="mt-1 text-sm text-zinc-500">
ID{record.id} | {record.chapterId}
</p>
</div>
<span class="rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700">
{props.renderRecordState(record.state) ||
"未知状态"}
</span>
</div>
<div class="mt-4 grid gap-2 text-sm text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{record.videoDuration}</p>
<p>{record.duration}</p>
<p>{record.progress}</p>
<p>{record.beginTime || "-"}</p>
<p>{record.finalTime || "-"}</p>
<p>{record.viewCount}</p>
</div>
</div>
)}
</For>
</div>
</div>
</div>
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-white">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<div>
<p class="text-sm font-semibold text-zinc-800"></p>
<p class="mt-1 text-xs text-zinc-500">
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingLogs}
onClick={props.onRefreshLogs}
>
{props.isRefreshingLogs ? "刷新中..." : "刷新日志"}
</button>
<button
type="button"
class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100"
onClick={props.onClearLogs}
>
</button>
</div>
</div>
<div
ref={logContainerRef}
class="min-h-0 flex-1 overflow-y-auto bg-zinc-950 px-4 py-3 font-mono text-emerald-300"
>
<Show
when={props.studyLogs.length > 0}
fallback={<p></p>}
>
<div class="space-y-2">
<For each={props.studyLogs}>
{(log, index) => (
<p>
[{index() + 1}]{" "}
{props.showLogTimestamps && extractTimestamp(log)
? `${extractTimestamp(log)} `
: ""}
{stripTimestamp(log)}
</p>
)}
</For>
</div>
</Show>
</div>
</div>
</div>
</div>
</Show>
</section>
);
};
export default CourseWorkspace;