feat: build account dashboard and settings workspace
This commit is contained in:
346
src/components/account/CourseWorkspace.tsx
Normal file
346
src/components/account/CourseWorkspace.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { For, Show, createEffect, type JSX } from "solid-js";
|
||||
import type { 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;
|
||||
};
|
||||
|
||||
interface CourseWorkspaceProps {
|
||||
selectedAccount: AccountItem | null;
|
||||
selectedCourseList: CourseType[];
|
||||
selectedCourseId: number | null;
|
||||
selectedCourse: CourseType | null;
|
||||
recordType: RecordType;
|
||||
recordTypeOptions: RecordTypeOption[];
|
||||
records: RecordItem[];
|
||||
studyLogs: string[];
|
||||
recordsLoading: boolean;
|
||||
recordError: string;
|
||||
isRefreshingRecords: boolean;
|
||||
isRunningStudy: boolean;
|
||||
isRefreshingLogs: boolean;
|
||||
autoScrollLogs: boolean;
|
||||
showLogTimestamps: boolean;
|
||||
densityMode: "comfortable" | "compact";
|
||||
logFontSize: number;
|
||||
onSelectCourse: (courseId: number) => void;
|
||||
onRefreshRecords: () => void;
|
||||
onChangeRecordType: (value: RecordType) => void;
|
||||
onStartStudy: () => void;
|
||||
onStopStudy: () => void;
|
||||
onRefreshLogs: () => void;
|
||||
onClearLogs: () => void;
|
||||
renderRecordState: (value: string) => string;
|
||||
}
|
||||
|
||||
const EmptyState = (props: { children: JSX.Element }) => (
|
||||
<div class="rounded-[24px] border border-dashed border-zinc-300 bg-white px-4 py-8 text-center text-zinc-500">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
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-[24px] border border-zinc-200 bg-[linear-gradient(180deg,_rgba(248,250,252,0.9),_rgba(255,255,255,0.95))]">
|
||||
<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={
|
||||
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"
|
||||
}
|
||||
>
|
||||
<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-[24px] 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>
|
||||
</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-[24px] 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-[24px] 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
|
||||
? `${new Date().toLocaleTimeString()} `
|
||||
: ""}
|
||||
{log}
|
||||
</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseWorkspace;
|
||||
Reference in New Issue
Block a user