feat: build account dashboard and settings workspace

This commit is contained in:
2026-03-26 23:03:45 +08:00
parent be4bc8a3af
commit 8ee9a696b4
18 changed files with 2468 additions and 54 deletions

View 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;

View 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;

View 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;

View 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;

View 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;