- 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>
650 lines
29 KiB
TypeScript
650 lines
29 KiB
TypeScript
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;
|