Files
wk-frontend/src/components/account/CourseWorkspace.tsx
zhilv a182c64f82 🔖 release(v0.1.4): bump version and UI optimizations
- Remove unused version display logic and update summary
- Add silent audio playback to prevent browser tab throttling
- Update CourseWorkspace and Setting components
- Bump version from 0.1.3 to 0.1.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:45:39 +08:00

650 lines
29 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,
createMemo,
createSignal,
type JSX,
} from "solid-js";
import type { CourseKind, RecordType, WorkListItem, ExamListItem } from "~/service/wk";
import type { AccountItem } from "~/store/account";
import type { CourseType } from "~/types/Course";
import type { RecordItem } from "~/service/wk";
const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim();
type RecordTypeOption = {
label: string;
value: RecordType;
};
type CourseRecordTypeOption = {
label: string;
value: CourseKind;
};
type RecordFilter = "all" | "unlearned" | "learned";
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[];
workList: WorkListItem[];
examList: ExamListItem[];
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;
const [recordFilter, setRecordFilter] = createSignal<RecordFilter>("all");
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";
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 (
<section class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[22px] border border-white/80 bg-white/85 shadow-[0_12px_34px_-24px_rgba(15,23,42,0.26)]">
<div class="flex flex-col gap-1.5 border-b border-zinc-200/80 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-[15px] font-semibold text-zinc-900 sm:text-base">
</p>
<p class="mt-0.5 text-xs text-zinc-500 sm:text-sm">
</p>
</div>
<Show when={props.selectedAccount}>
<div class="rounded-full bg-cyan-50 px-2 py-0.5 text-xs 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-1.5 p-1.5 lg:grid-cols-[280px_minmax(0,1fr)]"
: "grid min-h-0 flex-1 gap-2 p-2 lg:grid-cols-[300px_minmax(0,1fr)]"
}
>
<div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200 px-2.5 py-2">
<div>
<p class="text-xs font-semibold text-zinc-800 sm:text-sm">
</p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs 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-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingCourseRecords}
onClick={props.onRefreshCourseRecords}
>
{props.isRefreshingCourseRecords ? "刷新中..." : "刷新记录"}
</button>
</div>
</div>
<div class={compact() ? "flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-1.5" : "flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto p-2"}>
<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-[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-[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)}
>
<p class="truncate text-sm font-semibold text-zinc-900">
{course.name}
</p>
<div class="mt-1 grid gap-0.5 text-xs 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-1.5 grid-rows-[minmax(0,1fr)_minmax(140px,30vh)]"
: "grid min-h-0 gap-2 grid-rows-[minmax(0,1fr)_minmax(140px,30vh)]"
}
>
<div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200 px-2.5 py-2">
<div>
<p class="text-xs font-semibold text-zinc-800 sm:text-sm">
</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-1.5">
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={
!props.selectedCourse || props.isRefreshingRecords
}
onClick={props.onRefreshRecords}
>
{props.isRefreshingRecords ? "刷新中..." : "刷新记录"}
</button>
<select
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs 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}
disabled={item.value === "/discuss"}
class={item.value === "/discuss" ? "text-zinc-400" : ""}
>
{item.label}
</option>
)}
</For>
</select>
<Show when={props.recordType === ""}>
<button
type="button"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 transition hover:bg-cyan-100 active:bg-cyan-200"
onClick={
props.isRunningStudy
? props.onStopStudy
: props.onStartStudy
}
>
{props.isRunningStudy ? "停止刷课" : "开始刷课"}
</button>
</Show>
</div>
</div>
<Show when={props.recordType === ""}>
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5">
<div class="flex flex-wrap items-center gap-1.5 text-xs">
<button
type="button"
class={recordFilter() === "all" ? "rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-0.5 font-medium text-zinc-700 active:bg-zinc-200" : "rounded-full border border-zinc-200 bg-white px-2.5 py-0.5 text-zinc-600 transition hover:bg-zinc-100 active:bg-zinc-200"}
onClick={() => setRecordFilter("all")}
>
{recordStats().total}
</button>
<button
type="button"
class={recordFilter() === "unlearned" ? "rounded-full border border-amber-200 bg-amber-100 px-2.5 py-0.5 font-medium text-amber-700 active:bg-amber-200" : "rounded-full border border-amber-200 bg-white px-2.5 py-0.5 text-amber-700 transition hover:bg-amber-50 active:bg-amber-100"}
onClick={() => setRecordFilter("unlearned")}
>
{recordStats().unlearned}
</button>
<button
type="button"
class={recordFilter() === "learned" ? "rounded-full border border-emerald-200 bg-emerald-100 px-2.5 py-0.5 font-medium text-emerald-700 active:bg-emerald-200" : "rounded-full border border-emerald-200 bg-white px-2.5 py-0.5 text-emerald-700 transition hover:bg-emerald-50 active:bg-emerald-100"}
onClick={() => setRecordFilter("learned")}
>
{recordStats().learned}
</button>
</div>
<p class="text-xs text-zinc-500">
{recordFilter() === "all" ? "全部" : recordFilter() === "unlearned" ? "未学" : "已学"}
</p>
</div>
</Show>
<Show when={props.recordType === "/work" && props.workList.length > 0}>
<div class="flex items-center gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5 text-xs text-zinc-500">
{props.workList.length}
</div>
</Show>
<Show when={props.recordType === "/exam" && props.examList.length > 0}>
<div class="flex items-center gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5 text-xs text-zinc-500">
{props.examList.length}
</div>
</Show>
<div
class={
compact()
? "min-h-0 flex-1 overflow-y-auto p-1.5"
: "min-h-0 flex-1 overflow-y-auto p-2"
}
>
<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.recordType === "" &&
filteredRecords().length === 0
}
>
<EmptyState>
{props.records.length === 0
? "当前分类下没有记录。"
: "当前筛选下没有记录。"}
</EmptyState>
</Show>
<Show
when={
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.recordType === "/work" &&
props.workList.length === 0
}
>
<EmptyState></EmptyState>
</Show>
<Show
when={
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.recordType === "/exam" &&
props.examList.length === 0
}
>
<EmptyState></EmptyState>
</Show>
<Show when={props.recordType === ""}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={filteredRecords()}>
{(record) => {
const stateText =
props.renderRecordState(record.state) || "未知状态";
const learned =
stateText.includes("已学") || record.progress === "1.00";
return (
<div
class={
learned
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{record.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{record.id} | {record.chapterId}
</p>
</div>
<span
class={
learned
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{record.videoDuration}</p>
<p>{record.duration}</p>
<p>{record.progress}</p>
<p>{record.beginTime || "-"}</p>
<p>{record.finalTime || "-"}</p>
<p>{record.viewCount}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
<Show when={props.recordType === "/work"}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={props.workList}>
{(work) => {
const stateRaw = stripHtml(work.state);
const stateText = stateRaw || "未做";
const done = stateRaw.includes("已阅") || stateRaw.includes("已提交") || stateRaw.includes("已完成");
return (
<div
class={
done
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{work.title || work.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{work.id} | {work.chapterId}
</p>
</div>
<span
class={
done
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{work.typeName || work.type || "-"}</p>
<p>{work.score || "-"}</p>
<p>{stripHtml(work.finalScore) || "-"}</p>
<p>{work.topicNumber || "-"}</p>
<p>{work.addTime || "-"}</p>
<p>{work.finishTime !== "-" ? work.finishTime : "-"}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
<Show when={props.recordType === "/exam"}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={props.examList}>
{(exam) => {
const stateRaw = stripHtml(exam.state);
const stateText = stateRaw || "未做";
const done = stateRaw.includes("已阅") || stateRaw.includes("已提交") || stateRaw.includes("已完成");
return (
<div
class={
done
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{exam.title || exam.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{exam.id} | {exam.chapterId}
</p>
</div>
<span
class={
done
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{exam.limitedTime ? `${exam.limitedTime}分钟` : "-"}</p>
<p>{exam.score || "-"}</p>
<p>{stripHtml(exam.finalScore) || "-"}</p>
<p>{exam.topicNumber || "-"}</p>
<p>{exam.addTime || "-"}</p>
<p>{exam.finishTime !== "-" ? exam.finishTime : "-"}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
</div>
</div>
<div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-white">
<div class="flex items-center justify-between border-b border-zinc-200 px-2.5 py-2">
<div>
<p class="text-xs font-semibold text-zinc-800 sm:text-sm">
</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-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingLogs}
onClick={props.onRefreshLogs}
>
{props.isRefreshingLogs ? "刷新中..." : "刷新日志"}
</button>
<button
type="button"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={props.onClearLogs}
>
</button>
</div>
</div>
<div
ref={logContainerRef}
class="min-h-0 flex-1 overflow-y-auto bg-zinc-950 px-2 py-1.5 font-mono text-emerald-300"
>
<Show
when={props.studyLogs.length > 0}
fallback={<p></p>}
>
<div class="space-y-1">
<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;