feat: build account dashboard and settings workspace
This commit is contained in:
170
src/components/account/AccountSidebar.tsx
Normal file
170
src/components/account/AccountSidebar.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import type { CourseKind } from "~/service/wk";
|
||||
import type { AccountItem } from "~/store/account";
|
||||
|
||||
type StatusOption = {
|
||||
label: string;
|
||||
value: CourseKind;
|
||||
};
|
||||
|
||||
interface AccountSidebarProps {
|
||||
accounts: AccountItem[];
|
||||
selectedAccountId: string;
|
||||
expandedAccountId: string;
|
||||
statusOptions: StatusOption[];
|
||||
hostLabels: Record<string, string>;
|
||||
isRefreshingAccount: boolean;
|
||||
loggingOutId: string;
|
||||
densityMode: "comfortable" | "compact";
|
||||
sidebarWidth: number;
|
||||
onRefreshAccount: () => void;
|
||||
onSelectAccount: (accountId: string) => void;
|
||||
onToggleExpand: (accountId: string) => void;
|
||||
onLogout: (accountId: string) => void;
|
||||
}
|
||||
|
||||
const AccountSidebar = (props: AccountSidebarProps) => {
|
||||
const compact = () => props.densityMode === "compact";
|
||||
|
||||
return (
|
||||
<section
|
||||
class="flex min-h-0 flex-col overflow-hidden rounded-[28px] border border-white/80 bg-white/85 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.3)] xl:shrink-0"
|
||||
style={{
|
||||
width: `${props.sidebarWidth}px`,
|
||||
"min-width": `${props.sidebarWidth}px`,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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.selectedAccountId || props.isRefreshingAccount}
|
||||
onClick={props.onRefreshAccount}
|
||||
>
|
||||
{props.isRefreshingAccount ? "刷新中..." : "刷新账号"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={
|
||||
compact()
|
||||
? "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3"
|
||||
: "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
|
||||
}
|
||||
>
|
||||
<For each={props.accounts}>
|
||||
{(account) => {
|
||||
const selected = account.id === props.selectedAccountId;
|
||||
const expanded = account.id === props.expandedAccountId;
|
||||
const platformLabel =
|
||||
props.hostLabels[account.host] ?? account.host;
|
||||
const statusLabel =
|
||||
props.statusOptions.find((item) => item.value === account.status)
|
||||
?.label ?? account.status;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={
|
||||
selected
|
||||
? compact()
|
||||
? "rounded-[22px] border border-cyan-300 bg-[linear-gradient(145deg,_rgba(236,254,255,0.95),_rgba(240,253,244,0.95))] px-3 py-3 shadow-sm"
|
||||
: "rounded-[24px] border border-cyan-300 bg-[linear-gradient(145deg,_rgba(236,254,255,0.95),_rgba(240,253,244,0.95))] px-4 py-4 shadow-sm"
|
||||
: compact()
|
||||
? "rounded-[22px] border border-zinc-200 bg-[linear-gradient(180deg,_rgba(250,250,250,0.9),_rgba(255,255,255,0.95))] px-3 py-3 shadow-sm transition hover:border-cyan-200 hover:bg-white"
|
||||
: "rounded-[24px] border border-zinc-200 bg-[linear-gradient(180deg,_rgba(250,250,250,0.9),_rgba(255,255,255,0.95))] px-4 py-4 shadow-sm transition hover:border-cyan-200 hover:bg-white"
|
||||
}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left"
|
||||
onClick={() => props.onSelectAccount(account.id)}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class={
|
||||
compact()
|
||||
? "truncate text-base font-semibold text-zinc-900"
|
||||
: "truncate text-lg font-semibold text-zinc-900"
|
||||
}
|
||||
>
|
||||
{account.user.name} + {platformLabel}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
学号:{account.user.id}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-cyan-700 shadow-sm">
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span class="text-sm text-zinc-400">
|
||||
{expanded ? "收起" : "展开"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-sm text-zinc-400 transition hover:text-zinc-600"
|
||||
onClick={() => props.onToggleExpand(account.id)}
|
||||
>
|
||||
{expanded ? "收起" : "展开"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={expanded}>
|
||||
<div class="mt-4 grid gap-2 border-t border-cyan-100 pt-4 text-sm text-zinc-600">
|
||||
<p>学院:{account.user.dept}</p>
|
||||
<p>班级:{account.user.class}</p>
|
||||
<p>性别:{account.user.gender}</p>
|
||||
<p>站点:{account.host}</p>
|
||||
<p>账号:{account.username || "-"}</p>
|
||||
<p>课程数:{account.courses.length}</p>
|
||||
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-cyan-200 bg-white px-3 py-2 text-sm text-cyan-700 transition hover:bg-cyan-50"
|
||||
onClick={() => props.onToggleExpand(account.id)}
|
||||
>
|
||||
收起信息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-rose-200 bg-white px-3 py-2 text-sm text-rose-500 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={props.loggingOutId === account.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onLogout(account.id);
|
||||
}}
|
||||
>
|
||||
{props.loggingOutId === account.id
|
||||
? "退出中..."
|
||||
: "退出登录"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={props.accounts.length === 0}>
|
||||
<div class="rounded-[24px] border border-dashed border-zinc-300 bg-zinc-50/80 px-5 py-10 text-center text-zinc-500">
|
||||
还没有账号,点击右上角“添加账号”开始登录。
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSidebar;
|
||||
147
src/components/account/AddAccountDialog.tsx
Normal file
147
src/components/account/AddAccountDialog.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import Dialog from "~/components/dialog/Dialog";
|
||||
import type { CourseKind } from "~/service/wk";
|
||||
|
||||
type HostOption = {
|
||||
label: string;
|
||||
host: string;
|
||||
};
|
||||
|
||||
type StatusOption = {
|
||||
label: string;
|
||||
value: CourseKind;
|
||||
};
|
||||
|
||||
export type LoginForm = {
|
||||
username: string;
|
||||
password: string;
|
||||
token: string;
|
||||
status: CourseKind;
|
||||
host: string;
|
||||
};
|
||||
|
||||
interface AddAccountDialogProps {
|
||||
open: () => boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
errorMessage: string;
|
||||
form: LoginForm;
|
||||
statusOptions: StatusOption[];
|
||||
hostOptions: HostOption[];
|
||||
updateForm: <K extends keyof LoginForm>(key: K, value: LoginForm[K]) => void;
|
||||
}
|
||||
|
||||
const AddAccountDialog = (props: AddAccountDialogProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
title="添加账号"
|
||||
widthClass="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-zinc-200 px-4 py-2 text-zinc-700 transition hover:bg-zinc-100"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl bg-cyan-500 px-4 py-2 text-white transition hover:bg-cyan-600 disabled:cursor-not-allowed disabled:bg-cyan-300"
|
||||
disabled={props.isSubmitting}
|
||||
onClick={props.onSubmit}
|
||||
>
|
||||
{props.isSubmitting ? "登录中..." : "添加"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-2 text-sm text-zinc-700">
|
||||
<span>账号</span>
|
||||
<input
|
||||
class="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 transition outline-none focus:border-cyan-400 focus:bg-white"
|
||||
value={props.form.username}
|
||||
onInput={(event) =>
|
||||
props.updateForm("username", event.currentTarget.value)
|
||||
}
|
||||
placeholder="请输入账号"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-zinc-700">
|
||||
<span>密码</span>
|
||||
<input
|
||||
type="password"
|
||||
class="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 transition outline-none focus:border-cyan-400 focus:bg-white"
|
||||
value={props.form.password}
|
||||
onInput={(event) =>
|
||||
props.updateForm("password", event.currentTarget.value)
|
||||
}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-zinc-700 md:col-span-2">
|
||||
<span>Cookie</span>
|
||||
<textarea
|
||||
class="min-h-28 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 transition outline-none focus:border-cyan-400 focus:bg-white"
|
||||
value={props.form.token}
|
||||
onInput={(event) =>
|
||||
props.updateForm("token", event.currentTarget.value)
|
||||
}
|
||||
placeholder="可直接粘贴 Cookie 或 token,账号密码与 Cookie 二选一即可"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-zinc-700">
|
||||
<span>状态</span>
|
||||
<select
|
||||
class="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 transition outline-none focus:border-cyan-400 focus:bg-white"
|
||||
value={props.form.status}
|
||||
onChange={(event) =>
|
||||
props.updateForm(
|
||||
"status",
|
||||
event.currentTarget.value as CourseKind,
|
||||
)
|
||||
}
|
||||
>
|
||||
<For each={props.statusOptions}>
|
||||
{(item) => <option value={item.value}>{item.label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-zinc-700">
|
||||
<span>Host</span>
|
||||
<select
|
||||
class="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 transition outline-none focus:border-cyan-400 focus:bg-white"
|
||||
value={props.form.host}
|
||||
onChange={(event) =>
|
||||
props.updateForm("host", event.currentTarget.value)
|
||||
}
|
||||
>
|
||||
<For each={props.hostOptions}>
|
||||
{(item) => (
|
||||
<option value={item.host}>
|
||||
{item.label}({item.host})
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={props.errorMessage}>
|
||||
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
|
||||
{props.errorMessage}
|
||||
</div>
|
||||
</Show>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAccountDialog;
|
||||
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;
|
||||
39
src/components/courseList/CourseList.tsx
Normal file
39
src/components/courseList/CourseList.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { CourseType } from "~/types/Course";
|
||||
import type { Accessor } from "solid-js";
|
||||
|
||||
const courseLabel: [string, keyof CourseType][] = [
|
||||
["课程", "name"],
|
||||
["课程号", "id"],
|
||||
["老师", "teacher"],
|
||||
["进度", "progress"],
|
||||
["开始时间", "start_time"],
|
||||
["结束时间", "stop_time"],
|
||||
["学分", "credit"],
|
||||
["类型", "type"],
|
||||
];
|
||||
|
||||
interface CourseListProps {
|
||||
courseList: Accessor<CourseType[]>;
|
||||
}
|
||||
|
||||
const CourseList = (props: CourseListProps) => {
|
||||
return (
|
||||
<div class="m4-2 flex min-h-0 flex-1 flex-col gap-y-4 overflow-y-auto p-5">
|
||||
{props.courseList().map((course) => {
|
||||
return (
|
||||
<div class="grid grid-cols-3 rounded-xl border bg-zinc-200 p-4 py-2 shadow-sm">
|
||||
{courseLabel.map(([labelText, field]) => {
|
||||
return (
|
||||
<p class="text-base leading-7">
|
||||
{labelText}: <span>{course[field]}</span>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseList;
|
||||
86
src/components/dialog/Dialog.tsx
Normal file
86
src/components/dialog/Dialog.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Show,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
} from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
|
||||
interface DialogProps {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
widthClass?: string;
|
||||
closeOnOverlay?: boolean;
|
||||
}
|
||||
|
||||
const Dialog: ParentComponent<DialogProps> = (props) => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && props.open()) {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (props.closeOnOverlay !== false) {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.open()}>
|
||||
<Portal>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭对话框"
|
||||
class="absolute inset-0 bg-zinc-950/35 backdrop-blur-sm"
|
||||
onClick={handleOverlayClick}
|
||||
/>
|
||||
|
||||
<div
|
||||
class={`relative z-10 flex w-full ${props.widthClass ?? "max-w-lg"} flex-col rounded-2xl border border-zinc-200 bg-white shadow-xl`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-5 py-4">
|
||||
<h2 class="text-lg font-semibold text-zinc-900">
|
||||
{props.title ?? "提示"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[70vh] overflow-y-auto px-5 py-4 text-zinc-700">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
<Show when={props.footer}>
|
||||
<div class="flex justify-end gap-2 border-t border-zinc-200 px-5 py-4">
|
||||
{props.footer}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dialog;
|
||||
Reference in New Issue
Block a user