feat: build account dashboard and settings workspace
This commit is contained in:
@@ -13,7 +13,8 @@
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"axios": "^1.13.6",
|
||||
"solid-js": "^1.9.11",
|
||||
"tailwindcss": "^4.2.2"
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
zustand:
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.12.0
|
||||
@@ -863,6 +866,24 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
zustand@5.0.12:
|
||||
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=18.0.0'
|
||||
use-sync-external-store: '>=1.2.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
use-sync-external-store:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
@@ -1522,3 +1543,5 @@ snapshots:
|
||||
vite: 8.0.2(@types/node@24.12.0)(jiti@2.6.1)
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
zustand@5.0.12: {}
|
||||
|
||||
136
src/App.tsx
136
src/App.tsx
@@ -1,69 +1,103 @@
|
||||
import type { ParentComponent } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
const asideList = [
|
||||
{ label: "账号", url: "/account" },
|
||||
{ label: "设置", url: "/setting" },
|
||||
];
|
||||
const userInfo = {
|
||||
name: "张三",
|
||||
id: "123456",
|
||||
dept: "大数据学院",
|
||||
class: "3班",
|
||||
gender: "男",
|
||||
};
|
||||
const infoList = [
|
||||
["名字", userInfo.name],
|
||||
["学号", userInfo.id],
|
||||
["学院", userInfo.dept],
|
||||
["班级", userInfo.class],
|
||||
["性别", userInfo.gender],
|
||||
];
|
||||
|
||||
const App: ParentComponent = (props) => {
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (url: string) =>
|
||||
location.pathname === url ||
|
||||
(location.pathname === "/" && url === "/account");
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-full flex-col">
|
||||
<header class="m-2 flex shrink-0 flex-row items-center justify-between space-x-2 rounded-lg border-2 border-cyan-500/20 bg-zinc-100 px-5 py-2">
|
||||
<div class="rounded-xl bg-cyan-300/50 px-2 py-1 text-xl">
|
||||
<A href="/">标题</A>
|
||||
</div>
|
||||
<details class="relative rounded-xl bg-zinc-700/50 px-2 py-1 text-xl">
|
||||
<summary>个人信息</summary>
|
||||
<div class="absolute top-full right-0 w-max gap-y-2 rounded-xl border border-zinc-100 bg-zinc-400/50 p-2">
|
||||
{infoList.map(([label, value]) => (
|
||||
<p class="text-lg">
|
||||
{label}: <span>{value}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div class="flex min-h-0 min-w-0 flex-1">
|
||||
<aside class="m-2 flex shrink-0 flex-col gap-y-1 rounded-lg border-2 border-cyan-500/20 bg-zinc-100 px-2 py-2">
|
||||
{asideList.map((item) => {
|
||||
return (
|
||||
<div class="flex h-screen w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(103,232,249,0.24),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#ecfeff_0%,_#f8fafc_52%,_#eef2ff_100%)] text-zinc-800">
|
||||
<div class="flex min-h-0 w-full flex-col p-3 sm:p-4">
|
||||
<header class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/70 bg-white/75 px-5 py-4 shadow-[0_18px_50px_-22px_rgba(14,116,144,0.35)] backdrop-blur-xl">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<A
|
||||
href="/"
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,_#06b6d4,_#22c55e)] text-lg font-semibold text-white shadow-lg shadow-cyan-500/20"
|
||||
>
|
||||
WK
|
||||
</A>
|
||||
<div class="min-w-0">
|
||||
<A
|
||||
href={item.url}
|
||||
class={
|
||||
location.pathname === item.url ||
|
||||
(location.pathname === "/" && item.url === "/account"
|
||||
? true
|
||||
: false)
|
||||
? "rounded-xl border border-zinc-100 bg-blue-300/50 p-2"
|
||||
: "p-2"
|
||||
}
|
||||
href="/"
|
||||
class="block truncate text-2xl font-semibold tracking-wide text-zinc-900"
|
||||
>
|
||||
{item.label}
|
||||
网课控制台
|
||||
</A>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
<main class="m-2 flex flex-1 flex-col justify-center rounded-lg border-2 border-cyan-500/20 bg-zinc-100 px-2 py-2 text-center">
|
||||
{props.children}
|
||||
</main>
|
||||
<p class="truncate text-sm text-zinc-500">
|
||||
管理账号、课程记录与任务日志
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-3 text-right shadow-sm">
|
||||
<p class="text-sm font-medium text-zinc-800">学习控制中枢</p>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
多账号管理 / 课程记录 / 日志面板
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-4 flex min-h-0 min-w-0 flex-1 gap-4">
|
||||
<aside class="flex w-64 shrink-0 flex-col rounded-[28px] border border-white/70 bg-white/70 p-3 shadow-[0_18px_50px_-22px_rgba(15,23,42,0.18)] backdrop-blur-xl">
|
||||
<div class="mb-4 rounded-2xl bg-[linear-gradient(135deg,_rgba(6,182,212,0.12),_rgba(34,197,94,0.14))] px-4 py-4">
|
||||
<p class="text-xs font-medium tracking-[0.24em] text-cyan-700/75 uppercase">
|
||||
Navigation
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
在这里切换账号管理与系统设置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-col gap-2">
|
||||
{asideList.map((item) => {
|
||||
const active = isActive(item.url);
|
||||
|
||||
return (
|
||||
<A
|
||||
href={item.url}
|
||||
class={
|
||||
active
|
||||
? "rounded-2xl border border-cyan-200 bg-[linear-gradient(135deg,_rgba(34,211,238,0.18),_rgba(34,197,94,0.18))] px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm"
|
||||
: "rounded-2xl border border-transparent px-4 py-3 text-sm font-medium text-zinc-600 transition hover:border-zinc-200 hover:bg-zinc-50/80 hover:text-zinc-900"
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{item.label}</span>
|
||||
<span
|
||||
class={
|
||||
active
|
||||
? "text-cyan-700"
|
||||
: "text-zinc-400 transition group-hover:text-zinc-600"
|
||||
}
|
||||
>
|
||||
›
|
||||
</span>
|
||||
</div>
|
||||
</A>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4">
|
||||
<p class="text-sm font-medium text-zinc-800">当前页面</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{asideList.find((item) => isActive(item.url))?.label ?? "账号"}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[32px] border border-white/80 bg-white/80 p-3 shadow-[0_18px_60px_-24px_rgba(15,23,42,0.22)] backdrop-blur-xl sm:p-4">
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -1,5 +1,572 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import AccountSidebar from "~/components/account/AccountSidebar";
|
||||
import AddAccountDialog, {
|
||||
type LoginForm,
|
||||
} from "~/components/account/AddAccountDialog";
|
||||
import CourseWorkspace from "~/components/account/CourseWorkspace";
|
||||
import {
|
||||
loginApi,
|
||||
logoutApi,
|
||||
recordApi,
|
||||
runStudyQueue,
|
||||
type CourseKind,
|
||||
type RecordType,
|
||||
} from "~/service/wk";
|
||||
import { setUnauthorizedHandler } from "~/service/http";
|
||||
import { accountStore, type AccountItem } from "~/store/account";
|
||||
import { getMergedHosts, settingsStore } from "~/store/settings";
|
||||
import type { CourseType } from "~/types/Course";
|
||||
|
||||
const statusOptions: { label: string; value: CourseKind }[] = [
|
||||
{ label: "我的课程", value: "run" },
|
||||
{ label: "已结束", value: "finish" },
|
||||
{ label: "报名中", value: "sign" },
|
||||
];
|
||||
|
||||
const recordTypeOptions: { label: string; value: RecordType }[] = [
|
||||
{ label: "课程", value: "" },
|
||||
{ label: "作业", value: "/work" },
|
||||
{ label: "考试", value: "/exam" },
|
||||
{ label: "讨论", value: "/discuss" },
|
||||
];
|
||||
|
||||
const createDefaultForm = (host: string): LoginForm => ({
|
||||
username: "",
|
||||
password: "",
|
||||
token: "",
|
||||
status: "run",
|
||||
host,
|
||||
});
|
||||
|
||||
const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim();
|
||||
|
||||
const parseDurationToSeconds = (value: string) => {
|
||||
const parts = value.split(":").map(Number);
|
||||
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [hours, minutes, seconds] = parts;
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
};
|
||||
|
||||
const Account = () => {
|
||||
return <>Account</>;
|
||||
const [storeState, setStoreState] = createSignal(accountStore.getState());
|
||||
const [settingsState, setSettingsState] = createSignal(
|
||||
settingsStore.getState(),
|
||||
);
|
||||
const [showDialog, setShowDialog] = createSignal(false);
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
||||
const [loggingOutId, setLoggingOutId] = createSignal("");
|
||||
const [isRefreshingAccount, setIsRefreshingAccount] = createSignal(false);
|
||||
const [errorMessage, setErrorMessage] = createSignal("");
|
||||
const [form, setForm] = createSignal<LoginForm>(
|
||||
createDefaultForm("cqcst.leykeji.com"),
|
||||
);
|
||||
const [recordsLoading, setRecordsLoading] = createSignal(false);
|
||||
const [recordError, setRecordError] = createSignal("");
|
||||
const [isRefreshingRecords, setIsRefreshingRecords] = createSignal(false);
|
||||
const [isRefreshingLogs, setIsRefreshingLogs] = createSignal(false);
|
||||
const [hasRestoredRecords, setHasRestoredRecords] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribeAccount = accountStore.subscribe((state) => {
|
||||
setStoreState(state);
|
||||
});
|
||||
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
||||
setSettingsState(state);
|
||||
});
|
||||
setUnauthorizedHandler(reloginBySession);
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribeAccount();
|
||||
unsubscribeSettings();
|
||||
setUnauthorizedHandler(null);
|
||||
});
|
||||
});
|
||||
|
||||
const accounts = createMemo(() => storeState().accounts);
|
||||
const selectedAccountId = createMemo(() => storeState().selectedAccountId);
|
||||
const expandedAccountId = createMemo(() => storeState().expandedAccountId);
|
||||
const selectedCourseId = createMemo(() => storeState().selectedCourseId);
|
||||
const recordType = createMemo(() => storeState().recordType);
|
||||
const records = createMemo(() => storeState().records);
|
||||
const studyLogsMap = createMemo(() => storeState().studyLogsMap);
|
||||
const runningStudyMap = createMemo(() => storeState().runningStudyMap);
|
||||
const mergedHostOptions = createMemo(() =>
|
||||
getMergedHosts(settingsState().localHosts, settingsState().remoteHosts),
|
||||
);
|
||||
const hostLabels = createMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
mergedHostOptions().map((item) => [item.host, item.label]),
|
||||
) as Record<string, string>,
|
||||
);
|
||||
const defaultHost = createMemo(
|
||||
() => mergedHostOptions()[0]?.host ?? "cqcst.leykeji.com",
|
||||
);
|
||||
|
||||
const selectedAccount = createMemo(() => {
|
||||
return accounts().find((item) => item.id === selectedAccountId()) ?? null;
|
||||
});
|
||||
|
||||
const selectedCourseList = createMemo<CourseType[]>(() => {
|
||||
return selectedAccount()?.courses ?? [];
|
||||
});
|
||||
|
||||
const selectedCourse = createMemo(() => {
|
||||
return (
|
||||
selectedCourseList().find((item) => item.id === selectedCourseId()) ??
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const isRunningStudy = createMemo(() => {
|
||||
const account = selectedAccount();
|
||||
if (!account) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!runningStudyMap()[account.id];
|
||||
});
|
||||
|
||||
const studyLogs = createMemo(() => {
|
||||
const account = selectedAccount();
|
||||
if (!account) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return studyLogsMap()[account.id] ?? [];
|
||||
});
|
||||
|
||||
const updateForm = <K extends keyof LoginForm>(
|
||||
key: K,
|
||||
value: LoginForm[K],
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const appendStudyLog = (message: string, accountId?: string) => {
|
||||
const targetAccountId = accountId ?? selectedAccount()?.id;
|
||||
if (!targetAccountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
accountStore.getState().appendStudyLog(targetAccountId, message);
|
||||
};
|
||||
|
||||
const reloginBySession = async () => {
|
||||
const sessionId = sessionStorage.getItem("session_id");
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = accountStore
|
||||
.getState()
|
||||
.accounts.find((item) => item.sessionId === sessionId);
|
||||
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await loginApi({
|
||||
username: target.username,
|
||||
password: target.auth.password,
|
||||
token: target.auth.token,
|
||||
status: target.status,
|
||||
host: target.host,
|
||||
});
|
||||
|
||||
accountStore.getState().upsertAccount({
|
||||
...target,
|
||||
sessionId: res.data.session_id,
|
||||
user: res.data.user,
|
||||
courses: res.data.courses,
|
||||
});
|
||||
appendStudyLog(`会话失效,已重新登录:${target.user.name}`, target.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "重新登录失败";
|
||||
setErrorMessage(message);
|
||||
appendStudyLog(`重新登录失败:${message}`, target.id);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const applySessionId = (sessionId: string) => {
|
||||
sessionStorage.setItem("session_id", sessionId);
|
||||
};
|
||||
|
||||
const openDialog = () => {
|
||||
setErrorMessage("");
|
||||
setForm(createDefaultForm(defaultHost()));
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
if (isSubmitting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowDialog(false);
|
||||
setErrorMessage("");
|
||||
};
|
||||
|
||||
const handleAddAccount = async () => {
|
||||
const payload = form();
|
||||
const hasAccount =
|
||||
payload.username.trim() !== "" && payload.password.trim() !== "";
|
||||
const hasCookie = payload.token.trim() !== "";
|
||||
|
||||
if (!hasAccount && !hasCookie) {
|
||||
setErrorMessage("请填写账号和密码,或者填写 Cookie。");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const res = await loginApi({
|
||||
username: payload.username.trim(),
|
||||
password: payload.password.trim(),
|
||||
token: payload.token.trim(),
|
||||
status: payload.status,
|
||||
host: payload.host,
|
||||
});
|
||||
|
||||
const accountId = `${res.data.user.id}-${payload.host}-${payload.status}`;
|
||||
const nextAccount: AccountItem = {
|
||||
id: accountId,
|
||||
username: payload.username.trim() || res.data.user.id,
|
||||
host: payload.host,
|
||||
status: payload.status,
|
||||
sessionId: res.data.session_id,
|
||||
auth: {
|
||||
password: payload.password.trim(),
|
||||
token: payload.token.trim(),
|
||||
},
|
||||
user: res.data.user,
|
||||
courses: res.data.courses,
|
||||
};
|
||||
|
||||
accountStore.getState().upsertAccount(nextAccount);
|
||||
accountStore.getState().setSelectedCourseId(null);
|
||||
accountStore.getState().setRecords([]);
|
||||
setShowDialog(false);
|
||||
setForm(createDefaultForm(defaultHost()));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "登录失败,请检查输入信息。";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAccount = (accountId: string) => {
|
||||
accountStore.getState().setSelectedAccountId(accountId);
|
||||
accountStore.getState().setSelectedCourseId(null);
|
||||
accountStore.getState().setRecords([]);
|
||||
setRecordError("");
|
||||
};
|
||||
|
||||
const handleToggleExpand = (accountId: string) => {
|
||||
const nextId = expandedAccountId() === accountId ? "" : accountId;
|
||||
accountStore.getState().setExpandedAccountId(nextId);
|
||||
};
|
||||
|
||||
const handleRefreshAccount = async () => {
|
||||
const account = selectedAccount();
|
||||
if (!account) {
|
||||
setErrorMessage("请先选择账号。");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefreshingAccount(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const res = await loginApi({
|
||||
username: account.username,
|
||||
password: account.auth.password,
|
||||
token: account.auth.token,
|
||||
status: account.status,
|
||||
host: account.host,
|
||||
});
|
||||
|
||||
accountStore.getState().upsertAccount({
|
||||
...account,
|
||||
sessionId: res.data.session_id,
|
||||
user: res.data.user,
|
||||
courses: res.data.courses,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "刷新账号失败,请稍后重试。";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsRefreshingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async (accountId: string) => {
|
||||
setLoggingOutId(accountId);
|
||||
|
||||
try {
|
||||
const target = accounts().find((item) => item.id === accountId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
applySessionId(target.sessionId);
|
||||
await logoutApi();
|
||||
|
||||
accountStore.getState().removeAccount(accountId);
|
||||
if (selectedAccountId() === accountId) {
|
||||
accountStore.getState().setSelectedCourseId(null);
|
||||
accountStore.getState().setRecords([]);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "退出登录失败,请稍后重试。";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setLoggingOutId("");
|
||||
}
|
||||
};
|
||||
|
||||
const loadCourseRecords = async (
|
||||
courseId: number,
|
||||
nextRecordType = recordType(),
|
||||
) => {
|
||||
const account = selectedAccount();
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordsLoading(true);
|
||||
setIsRefreshingRecords(true);
|
||||
setRecordError("");
|
||||
|
||||
try {
|
||||
applySessionId(account.sessionId);
|
||||
const res = await recordApi({
|
||||
course_id: String(courseId),
|
||||
page: 0,
|
||||
record_type: nextRecordType,
|
||||
});
|
||||
accountStore.getState().setRecords(res.data.list);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
|
||||
setRecordError(message);
|
||||
accountStore.getState().setRecords([]);
|
||||
} finally {
|
||||
setRecordsLoading(false);
|
||||
setIsRefreshingRecords(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCourse = async (courseId: number) => {
|
||||
accountStore.getState().setSelectedCourseId(courseId);
|
||||
await loadCourseRecords(courseId);
|
||||
};
|
||||
|
||||
const handleRefreshRecords = async () => {
|
||||
const courseId = selectedCourseId();
|
||||
if (!courseId) {
|
||||
setRecordError("请先选择课程。");
|
||||
return;
|
||||
}
|
||||
|
||||
await loadCourseRecords(courseId);
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
const account = selectedAccount();
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
accountStore.getState().clearStudyLogs(account.id);
|
||||
};
|
||||
|
||||
const handleRefreshLogs = () => {
|
||||
setIsRefreshingLogs(true);
|
||||
appendStudyLog("手动刷新日志面板");
|
||||
queueMicrotask(() => {
|
||||
setIsRefreshingLogs(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartStudy = async () => {
|
||||
const account = selectedAccount();
|
||||
const course = selectedCourse();
|
||||
|
||||
if (!account || !course) {
|
||||
setRecordError("请先选择账号和课程。");
|
||||
return;
|
||||
}
|
||||
|
||||
const queueItems = records()
|
||||
.filter((item) => item.progress !== "1.00")
|
||||
.map((item) => ({
|
||||
nodeId: item.id,
|
||||
name: item.name,
|
||||
currentTime: Number(item.duration || 0),
|
||||
totalTime: parseDurationToSeconds(item.videoDuration),
|
||||
progress: item.progress,
|
||||
completed: stripHtml(item.state) === "已学",
|
||||
}));
|
||||
|
||||
try {
|
||||
accountStore.getState().setAccountRunningStudy(account.id, true);
|
||||
appendStudyLog(`开始刷课:${course.name}`, account.id);
|
||||
await runStudyQueue({
|
||||
accountId: account.id,
|
||||
courseId: course.id,
|
||||
intervalSeconds: 5,
|
||||
items: queueItems,
|
||||
isRunningStudy: () =>
|
||||
!!accountStore.getState().runningStudyMap[account.id],
|
||||
onLog: (message: string) => appendStudyLog(message, account.id),
|
||||
});
|
||||
if (accountStore.getState().runningStudyMap[account.id]) {
|
||||
appendStudyLog(`刷课完成:${course.name}`, account.id);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "刷课流程执行失败。";
|
||||
appendStudyLog(`刷课失败:${message}`, account.id);
|
||||
setRecordError(message);
|
||||
} finally {
|
||||
accountStore.getState().setAccountRunningStudy(account.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopStudy = () => {
|
||||
const account = selectedAccount();
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
accountStore.getState().setAccountRunningStudy(account.id, false);
|
||||
appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const courseId = selectedCourseId();
|
||||
const account = selectedAccount();
|
||||
const type = recordType();
|
||||
|
||||
if (!hasRestoredRecords()) {
|
||||
setHasRestoredRecords(true);
|
||||
|
||||
if (courseId && account && records().length > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!courseId || !account) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadCourseRecords(courseId, type);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(135deg,_rgba(236,254,255,0.95),_rgba(255,255,255,0.92))] px-5 py-4 shadow-[0_18px_45px_-28px_rgba(8,145,178,0.35)]">
|
||||
<div>
|
||||
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
|
||||
Account Center
|
||||
</p>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-zinc-900">账号管理</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
管理账号登录、课程记录与运行日志
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="rounded-2xl bg-[linear-gradient(135deg,_#06b6d4,_#14b8a6)] px-4 py-3 text-sm font-medium text-white shadow-lg shadow-cyan-500/20 transition hover:translate-y-[-1px] hover:shadow-cyan-500/30"
|
||||
onClick={openDialog}
|
||||
>
|
||||
添加账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex min-h-0 flex-1 flex-col gap-4 xl:flex-row">
|
||||
<AccountSidebar
|
||||
accounts={accounts()}
|
||||
selectedAccountId={selectedAccountId()}
|
||||
expandedAccountId={expandedAccountId()}
|
||||
statusOptions={statusOptions}
|
||||
hostLabels={hostLabels()}
|
||||
isRefreshingAccount={isRefreshingAccount()}
|
||||
loggingOutId={loggingOutId()}
|
||||
densityMode={settingsState().densityMode}
|
||||
sidebarWidth={settingsState().sidebarWidth}
|
||||
onRefreshAccount={() => void handleRefreshAccount()}
|
||||
onSelectAccount={handleSelectAccount}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onLogout={(accountId) => void handleLogout(accountId)}
|
||||
/>
|
||||
|
||||
<CourseWorkspace
|
||||
selectedAccount={selectedAccount()}
|
||||
selectedCourseList={selectedCourseList()}
|
||||
selectedCourseId={selectedCourseId()}
|
||||
selectedCourse={selectedCourse()}
|
||||
recordType={recordType()}
|
||||
recordTypeOptions={recordTypeOptions}
|
||||
records={records()}
|
||||
studyLogs={studyLogs()}
|
||||
recordsLoading={recordsLoading()}
|
||||
recordError={recordError()}
|
||||
isRefreshingRecords={isRefreshingRecords()}
|
||||
isRunningStudy={isRunningStudy()}
|
||||
isRefreshingLogs={isRefreshingLogs()}
|
||||
autoScrollLogs={settingsState().autoScrollLogs}
|
||||
showLogTimestamps={settingsState().showLogTimestamps}
|
||||
densityMode={settingsState().densityMode}
|
||||
logFontSize={settingsState().logFontSize}
|
||||
onSelectCourse={(courseId) => void handleSelectCourse(courseId)}
|
||||
onRefreshRecords={() => void handleRefreshRecords()}
|
||||
onChangeRecordType={(value) =>
|
||||
accountStore.getState().setRecordType(value)
|
||||
}
|
||||
onStartStudy={() => void handleStartStudy()}
|
||||
onStopStudy={handleStopStudy}
|
||||
onRefreshLogs={handleRefreshLogs}
|
||||
onClearLogs={handleClearLogs}
|
||||
renderRecordState={stripHtml}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AddAccountDialog
|
||||
open={showDialog}
|
||||
onClose={closeDialog}
|
||||
onSubmit={() => void handleAddAccount()}
|
||||
isSubmitting={isSubmitting()}
|
||||
errorMessage={errorMessage()}
|
||||
form={form()}
|
||||
statusOptions={statusOptions}
|
||||
hostOptions={mergedHostOptions().map((item) => ({
|
||||
label: item.label,
|
||||
host: item.host,
|
||||
}))}
|
||||
updateForm={updateForm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
||||
|
||||
@@ -1,5 +1,439 @@
|
||||
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js";
|
||||
import { hostApi } from "~/service/wk";
|
||||
import { getMergedHosts, settingsStore } from "~/store/settings";
|
||||
|
||||
const Setting = () => {
|
||||
return <>Setting</>;
|
||||
const [state, setState] = createSignal(settingsStore.getState());
|
||||
const [hostLabel, setHostLabel] = createSignal("");
|
||||
const [hostValue, setHostValue] = createSignal("");
|
||||
const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false);
|
||||
const [hostError, setHostError] = createSignal("");
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = settingsStore.subscribe((nextState) => {
|
||||
setState(nextState);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
const mergedHosts = createMemo(() =>
|
||||
getMergedHosts(state().localHosts, state().remoteHosts),
|
||||
);
|
||||
|
||||
const loadRemoteHosts = async () => {
|
||||
setIsLoadingRemoteHosts(true);
|
||||
setHostError("");
|
||||
|
||||
try {
|
||||
const res = await hostApi();
|
||||
settingsStore.getState().setRemoteHosts(
|
||||
res.data.list.map((item) => ({
|
||||
label: item.name,
|
||||
host: item.host,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "获取远程 Host 失败。";
|
||||
setHostError(message);
|
||||
} finally {
|
||||
setIsLoadingRemoteHosts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addLocalHost = () => {
|
||||
if (!hostLabel().trim() || !hostValue().trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsStore.getState().addLocalHost({
|
||||
label: hostLabel().trim(),
|
||||
host: hostValue().trim(),
|
||||
});
|
||||
setHostLabel("");
|
||||
setHostValue("");
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (state().remoteHosts.length === 0) {
|
||||
void loadRemoteHosts();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(135deg,_rgba(255,255,255,0.92),_rgba(240,249,255,0.96))] px-5 py-4 shadow-[0_18px_45px_-28px_rgba(15,23,42,0.18)]">
|
||||
<div>
|
||||
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
|
||||
Settings Center
|
||||
</p>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-zinc-900">偏好设置</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
管理本地缓存、界面偏好和 Host 来源策略
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white/90 px-4 py-3 text-right shadow-sm">
|
||||
<p class="text-sm font-medium text-zinc-800">本地优先</p>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
远端 Host 获取后会与本地列表合并去重
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid min-h-0 flex-1 gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
||||
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
|
||||
<div class="flex items-center justify-between gap-4 border-b border-zinc-200 pb-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-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-600 transition hover:bg-rose-100"
|
||||
onClick={() => settingsStore.getState().clearAllPersistedData()}
|
||||
>
|
||||
清空全部缓存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">账号缓存</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
保留账号、课程和登录相关信息
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().persistAccounts}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setPersistSection(
|
||||
"accounts",
|
||||
event.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
|
||||
onClick={() =>
|
||||
settingsStore.getState().clearPersistedSection("accounts")
|
||||
}
|
||||
>
|
||||
清空账号缓存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">记录缓存</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
保留当前课程记录和筛选类型
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().persistRecords}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setPersistSection(
|
||||
"records",
|
||||
event.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
|
||||
onClick={() =>
|
||||
settingsStore.getState().clearPersistedSection("records")
|
||||
}
|
||||
>
|
||||
清空记录缓存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">日志缓存</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
保留任务日志历史,刷新后继续查看
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().persistLogs}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setPersistSection("logs", event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
|
||||
onClick={() =>
|
||||
settingsStore.getState().clearPersistedSection("logs")
|
||||
}
|
||||
>
|
||||
清空日志缓存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
|
||||
<div class="border-b border-zinc-200 pb-4">
|
||||
<p class="text-lg font-semibold text-zinc-900">界面偏好</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
根据你的使用习惯调整侧栏、日志和展示密度
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">日志自动滚动</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
新日志出现时自动滚动到最底部
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().autoScrollLogs}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setAutoScrollLogs(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">显示日志时间戳</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
后续日志输出可以追加格式化时间
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().showLogTimestamps}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setShowLogTimestamps(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<label class="block">
|
||||
<p class="font-medium text-zinc-900">界面密度</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
选择更舒适或更紧凑的展示方式
|
||||
</p>
|
||||
<select
|
||||
class="mt-3 w-full rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
|
||||
value={state().densityMode}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setDensityMode(
|
||||
event.currentTarget.value as
|
||||
| "comfortable"
|
||||
| "compact",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="comfortable">舒适</option>
|
||||
<option value="compact">紧凑</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<label class="block">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">日志字号</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
当前:{state().logFontSize}px
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
class="mt-4 w-full"
|
||||
type="range"
|
||||
min="11"
|
||||
max="16"
|
||||
value={state().logFontSize}
|
||||
onInput={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setLogFontSize(Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<label class="block">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">侧栏宽度</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
当前:{state().sidebarWidth}px
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
class="mt-4 w-full"
|
||||
type="range"
|
||||
min="280"
|
||||
max="380"
|
||||
step="10"
|
||||
value={state().sidebarWidth}
|
||||
onInput={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setSidebarWidth(Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
||||
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
|
||||
<div class="border-b border-zinc-200 pb-4">
|
||||
<p class="text-lg font-semibold text-zinc-900">Host 配置策略</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
远端 Host 后续通过接口请求,本地 Host
|
||||
手动添加,最终合并并去重,优先保留本地配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="font-medium text-zinc-900">添加本地 Host</p>
|
||||
<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={isLoadingRemoteHosts()}
|
||||
onClick={() => void loadRemoteHosts()}
|
||||
>
|
||||
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
|
||||
value={hostLabel()}
|
||||
onInput={(event) => setHostLabel(event.currentTarget.value)}
|
||||
placeholder="名称,如:校内测试"
|
||||
/>
|
||||
<input
|
||||
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
|
||||
value={hostValue()}
|
||||
onInput={(event) => setHostValue(event.currentTarget.value)}
|
||||
placeholder="Host,如:example.com"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600"
|
||||
onClick={addLocalHost}
|
||||
>
|
||||
添加本地 Host
|
||||
</button>
|
||||
|
||||
{hostError() ? (
|
||||
<div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
|
||||
{hostError()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<p class="font-medium text-zinc-900">本地 Host</p>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<For each={state().localHosts}>
|
||||
{(item) => (
|
||||
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">
|
||||
{item.label}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{item.host}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-rose-200 px-3 py-1 text-xs text-rose-600 transition hover:bg-rose-50"
|
||||
onClick={() =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.removeLocalHost(item.host)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<p class="font-medium text-zinc-900">合并结果</p>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<For each={mergedHosts()}>
|
||||
{(item) => (
|
||||
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<p class="font-medium text-zinc-900">{item.label}</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">{item.host}</p>
|
||||
<p class="mt-2 text-xs text-cyan-700">
|
||||
来源:{item.source === "local" ? "本地优先" : "远端"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setting;
|
||||
|
||||
67
src/service/http.ts
Normal file
67
src/service/http.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
type UnauthorizedHandler = () => Promise<boolean>;
|
||||
|
||||
let unauthorizedHandler: UnauthorizedHandler | null = null;
|
||||
|
||||
export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => {
|
||||
unauthorizedHandler = handler;
|
||||
};
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const sessionID = sessionStorage.getItem("session_id");
|
||||
if (sessionID) {
|
||||
config.headers["X-Session-Id"] = sessionID;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const config = error.config as
|
||||
| (AxiosRequestConfig & { _retry?: boolean })
|
||||
| undefined;
|
||||
const status = error.response?.status;
|
||||
const url = config?.url ?? "";
|
||||
|
||||
if (
|
||||
status === 401 &&
|
||||
config &&
|
||||
!config._retry &&
|
||||
unauthorizedHandler &&
|
||||
!url.includes("/api/login")
|
||||
) {
|
||||
config._retry = true;
|
||||
const ok = await unauthorizedHandler();
|
||||
|
||||
if (ok) {
|
||||
return instance.request(config);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
const http = {
|
||||
get<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return instance.get<unknown, T>(url, config);
|
||||
},
|
||||
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return instance.post<unknown, T>(url, data, config);
|
||||
},
|
||||
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return instance.put<unknown, T>(url, data, config);
|
||||
},
|
||||
delete<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return instance.delete<unknown, T>(url, config);
|
||||
},
|
||||
};
|
||||
|
||||
export default http;
|
||||
205
src/service/wk.ts
Normal file
205
src/service/wk.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { Accessor } from "solid-js";
|
||||
import http from "~/service/http";
|
||||
import type { CourseType } from "~/types/Course";
|
||||
import type { userInfoType } from "~/types/Userinfo";
|
||||
|
||||
export type CourseKind = "run" | "finish" | "sign";
|
||||
|
||||
export type RecordType = "" | "/work" | "/exam" | "/discuss";
|
||||
|
||||
export type StudyStatus = 1 | 2 | 3;
|
||||
|
||||
type ApiResponse<T> = {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type LoginReq = {
|
||||
username: string;
|
||||
password: string;
|
||||
token: string;
|
||||
status: CourseKind;
|
||||
host: string;
|
||||
};
|
||||
|
||||
export type LoginData = {
|
||||
courses: CourseType[];
|
||||
session_id: string;
|
||||
user: userInfoType;
|
||||
};
|
||||
|
||||
export type LoginRes = ApiResponse<LoginData>;
|
||||
|
||||
export type RecordItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string | null;
|
||||
chapterId: string;
|
||||
courseId: string;
|
||||
videoFile: string;
|
||||
videoDuration: string;
|
||||
votingPath: string | null;
|
||||
tabVideo: string;
|
||||
tabFile: string;
|
||||
tabVote: string;
|
||||
tabWork: string;
|
||||
tabExam: string;
|
||||
sort: string;
|
||||
videoMode: string;
|
||||
localFile: string;
|
||||
schoolId: string;
|
||||
lock: string;
|
||||
unlockTime: string;
|
||||
bid: string;
|
||||
duration: string;
|
||||
progress: string;
|
||||
state: string;
|
||||
viewCount: string;
|
||||
finalTime: string;
|
||||
error: number;
|
||||
errorMessage: string;
|
||||
beginTime: string;
|
||||
viewedDuration: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type PageInfo = {
|
||||
keyName: string;
|
||||
page: number;
|
||||
pageCount: number;
|
||||
recordsCount: number;
|
||||
onlyCount: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type RecordData = {
|
||||
list: RecordItem[];
|
||||
page_info: PageInfo;
|
||||
};
|
||||
|
||||
export type RecordRes = ApiResponse<RecordData>;
|
||||
|
||||
export type StudyData = {
|
||||
state: number;
|
||||
studyId: number;
|
||||
status: boolean;
|
||||
msg: string;
|
||||
};
|
||||
|
||||
export type StudyRes = ApiResponse<StudyData>;
|
||||
|
||||
export type LogoutRes = {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type HostItem = {
|
||||
host: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type HostRes = ApiResponse<{
|
||||
list: HostItem[];
|
||||
}>;
|
||||
|
||||
export type RecordReq = {
|
||||
course_id: string;
|
||||
page: number;
|
||||
record_type?: RecordType;
|
||||
};
|
||||
|
||||
export type StudyReq = {
|
||||
node_id: string;
|
||||
study_id: string;
|
||||
study_time: string;
|
||||
status: StudyStatus;
|
||||
};
|
||||
|
||||
export type StudyRunnerItem = {
|
||||
nodeId: string;
|
||||
name: string;
|
||||
currentTime: number;
|
||||
totalTime: number;
|
||||
progress: string;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export type StudyRunnerPayload = {
|
||||
accountId: string;
|
||||
courseId: number;
|
||||
intervalSeconds: number;
|
||||
items: StudyRunnerItem[];
|
||||
isRunningStudy: Accessor<boolean>;
|
||||
onLog?: (message: string) => void;
|
||||
};
|
||||
|
||||
export const loginApi = async (payload: LoginReq) => {
|
||||
const res = await http.post<LoginRes>("/api/login", payload);
|
||||
|
||||
if (res.data?.session_id) {
|
||||
sessionStorage.setItem("session_id", res.data.session_id);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const recordApi = async (payload: RecordReq) => {
|
||||
return await http.post<RecordRes>("/api/v2/record", payload);
|
||||
};
|
||||
|
||||
export const studyApi = async (payload: StudyReq) => {
|
||||
return await http.post<StudyRes>("/api/v2/study", payload);
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
|
||||
const stopFlag = _payload.isRunningStudy;
|
||||
for (const item of _payload.items) {
|
||||
let currentTime = 0;
|
||||
let count = 0;
|
||||
let study_id = 0;
|
||||
|
||||
while (currentTime <= item.totalTime) {
|
||||
if (!stopFlag()) {
|
||||
_payload.onLog?.("⛔ 已手动停止");
|
||||
return;
|
||||
}
|
||||
const message = `[${item.name}]: ${currentTime}/${item.totalTime}`;
|
||||
console.log(message);
|
||||
_payload.onLog?.(message);
|
||||
|
||||
try {
|
||||
const resp = await studyApi({
|
||||
node_id: item.nodeId,
|
||||
study_id: String(study_id),
|
||||
study_time: String(currentTime),
|
||||
status: count === 0 ? 1 : currentTime >= item.totalTime ? 3 : 2,
|
||||
});
|
||||
study_id = resp.data.studyId;
|
||||
|
||||
if (currentTime === item.totalTime) break;
|
||||
|
||||
currentTime = Math.min(currentTime + 5, item.totalTime);
|
||||
count++;
|
||||
} catch (error) {
|
||||
const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`;
|
||||
console.log(errorMessage);
|
||||
_payload.onLog?.(errorMessage);
|
||||
}
|
||||
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const logoutApi = async () => {
|
||||
const res = await http.post<LogoutRes>("/api/v2/logout");
|
||||
sessionStorage.removeItem("session_id");
|
||||
return res;
|
||||
};
|
||||
|
||||
export const hostApi = async () => {
|
||||
return await http.get<HostRes>("/api/v1/host");
|
||||
};
|
||||
140
src/store/account.ts
Normal file
140
src/store/account.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import type { CourseKind, RecordItem, RecordType } from "~/service/wk";
|
||||
import type { CourseType } from "~/types/Course";
|
||||
import type { userInfoType } from "~/types/Userinfo";
|
||||
|
||||
export type AccountAuth = {
|
||||
password: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type AccountItem = {
|
||||
id: string;
|
||||
username: string;
|
||||
host: string;
|
||||
status: CourseKind;
|
||||
sessionId: string;
|
||||
auth: AccountAuth;
|
||||
user: userInfoType;
|
||||
courses: CourseType[];
|
||||
};
|
||||
|
||||
type AccountState = {
|
||||
accounts: AccountItem[];
|
||||
selectedAccountId: string;
|
||||
expandedAccountId: string;
|
||||
selectedCourseId: number | null;
|
||||
recordType: RecordType;
|
||||
records: RecordItem[];
|
||||
studyLogsMap: Record<string, string[]>;
|
||||
runningStudyMap: Record<string, boolean>;
|
||||
setSelectedAccountId: (accountId: string) => void;
|
||||
setExpandedAccountId: (accountId: string) => void;
|
||||
setSelectedCourseId: (courseId: number | null) => void;
|
||||
setRecordType: (recordType: RecordType) => void;
|
||||
setRecords: (records: RecordItem[]) => void;
|
||||
setAccountRunningStudy: (accountId: string, value: boolean) => void;
|
||||
appendStudyLog: (accountId: string, message: string) => void;
|
||||
clearStudyLogs: (accountId: string) => void;
|
||||
upsertAccount: (account: AccountItem) => void;
|
||||
removeAccount: (accountId: string) => void;
|
||||
};
|
||||
|
||||
export const accountStore = createStore<AccountState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
accounts: [],
|
||||
selectedAccountId: "",
|
||||
expandedAccountId: "",
|
||||
selectedCourseId: null,
|
||||
recordType: "",
|
||||
records: [],
|
||||
studyLogsMap: {},
|
||||
runningStudyMap: {},
|
||||
setSelectedAccountId: (accountId) =>
|
||||
set({ selectedAccountId: accountId }),
|
||||
setExpandedAccountId: (accountId) =>
|
||||
set({ expandedAccountId: accountId }),
|
||||
setSelectedCourseId: (courseId) => set({ selectedCourseId: courseId }),
|
||||
setRecordType: (recordType) => set({ recordType }),
|
||||
setRecords: (records) => set({ records }),
|
||||
setAccountRunningStudy: (accountId, value) =>
|
||||
set((state) => ({
|
||||
runningStudyMap: {
|
||||
...state.runningStudyMap,
|
||||
[accountId]: value,
|
||||
},
|
||||
})),
|
||||
appendStudyLog: (accountId, message) =>
|
||||
set((state) => ({
|
||||
studyLogsMap: {
|
||||
...state.studyLogsMap,
|
||||
[accountId]: [...(state.studyLogsMap[accountId] ?? []), message],
|
||||
},
|
||||
})),
|
||||
clearStudyLogs: (accountId) =>
|
||||
set((state) => ({
|
||||
studyLogsMap: {
|
||||
...state.studyLogsMap,
|
||||
[accountId]: [],
|
||||
},
|
||||
})),
|
||||
upsertAccount: (account) =>
|
||||
set((state) => ({
|
||||
accounts: [
|
||||
account,
|
||||
...state.accounts.filter((item) => item.id !== account.id),
|
||||
],
|
||||
selectedAccountId: account.id,
|
||||
expandedAccountId: account.id,
|
||||
})),
|
||||
removeAccount: (accountId) =>
|
||||
set((state) => {
|
||||
const nextAccounts = state.accounts.filter(
|
||||
(item) => item.id !== accountId,
|
||||
);
|
||||
return {
|
||||
accounts: nextAccounts,
|
||||
selectedAccountId:
|
||||
state.selectedAccountId === accountId
|
||||
? ""
|
||||
: state.selectedAccountId,
|
||||
expandedAccountId:
|
||||
state.expandedAccountId === accountId
|
||||
? ""
|
||||
: state.expandedAccountId,
|
||||
selectedCourseId:
|
||||
state.selectedAccountId === accountId
|
||||
? null
|
||||
: state.selectedCourseId,
|
||||
records: state.selectedAccountId === accountId ? [] : state.records,
|
||||
studyLogsMap: Object.fromEntries(
|
||||
Object.entries(state.studyLogsMap).filter(
|
||||
([key]) => key !== accountId,
|
||||
),
|
||||
),
|
||||
runningStudyMap: Object.fromEntries(
|
||||
Object.entries(state.runningStudyMap).filter(
|
||||
([key]) => key !== accountId,
|
||||
),
|
||||
),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "account-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
accounts: state.accounts,
|
||||
selectedAccountId: state.selectedAccountId,
|
||||
expandedAccountId: state.expandedAccountId,
|
||||
selectedCourseId: state.selectedCourseId,
|
||||
recordType: state.recordType,
|
||||
records: state.records,
|
||||
studyLogsMap: state.studyLogsMap,
|
||||
runningStudyMap: state.runningStudyMap,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
127
src/store/settings.ts
Normal file
127
src/store/settings.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
export type HostOption = {
|
||||
label: string;
|
||||
host: string;
|
||||
source: "local" | "remote";
|
||||
};
|
||||
|
||||
type CacheSection = "accounts" | "records" | "logs";
|
||||
type DensityMode = "comfortable" | "compact";
|
||||
|
||||
type SettingsState = {
|
||||
persistAccounts: boolean;
|
||||
persistRecords: boolean;
|
||||
persistLogs: boolean;
|
||||
autoScrollLogs: boolean;
|
||||
showLogTimestamps: boolean;
|
||||
densityMode: DensityMode;
|
||||
logFontSize: number;
|
||||
sidebarWidth: number;
|
||||
localHosts: HostOption[];
|
||||
remoteHosts: HostOption[];
|
||||
setPersistSection: (section: CacheSection, value: boolean) => void;
|
||||
clearPersistedSection: (section: CacheSection) => void;
|
||||
clearAllPersistedData: () => void;
|
||||
setAutoScrollLogs: (value: boolean) => void;
|
||||
setShowLogTimestamps: (value: boolean) => void;
|
||||
setDensityMode: (value: DensityMode) => void;
|
||||
setLogFontSize: (value: number) => void;
|
||||
setSidebarWidth: (value: number) => void;
|
||||
addLocalHost: (host: Omit<HostOption, "source">) => void;
|
||||
removeLocalHost: (host: string) => void;
|
||||
setRemoteHosts: (hosts: Omit<HostOption, "source">[]) => void;
|
||||
};
|
||||
|
||||
const accountStorageKey = "account-storage";
|
||||
|
||||
const uniqueHosts = (hosts: HostOption[]) => {
|
||||
const map = new Map<string, HostOption>();
|
||||
|
||||
for (const item of hosts) {
|
||||
if (!map.has(item.host)) {
|
||||
map.set(item.host, item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export const getMergedHosts = (
|
||||
localHosts: HostOption[],
|
||||
remoteHosts: HostOption[],
|
||||
) => {
|
||||
return uniqueHosts([...localHosts, ...remoteHosts]);
|
||||
};
|
||||
|
||||
export const settingsStore = createStore<SettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
persistAccounts: true,
|
||||
persistRecords: true,
|
||||
persistLogs: true,
|
||||
autoScrollLogs: true,
|
||||
showLogTimestamps: false,
|
||||
densityMode: "comfortable",
|
||||
logFontSize: 12,
|
||||
sidebarWidth: 320,
|
||||
localHosts: [
|
||||
{ label: "默认站点", host: "cqcst.leykeji.com", source: "local" },
|
||||
],
|
||||
remoteHosts: [],
|
||||
setPersistSection: (section, value) => {
|
||||
if (section === "accounts") set({ persistAccounts: value });
|
||||
if (section === "records") set({ persistRecords: value });
|
||||
if (section === "logs") set({ persistLogs: value });
|
||||
},
|
||||
clearPersistedSection: (section) => {
|
||||
if (section === "accounts") {
|
||||
localStorage.removeItem(accountStorageKey);
|
||||
}
|
||||
|
||||
if (section === "records") {
|
||||
set({ persistRecords: false });
|
||||
queueMicrotask(() => set({ persistRecords: true }));
|
||||
}
|
||||
|
||||
if (section === "logs") {
|
||||
set({ persistLogs: false });
|
||||
queueMicrotask(() => set({ persistLogs: true }));
|
||||
}
|
||||
},
|
||||
clearAllPersistedData: () => {
|
||||
localStorage.removeItem(accountStorageKey);
|
||||
get().clearPersistedSection("records");
|
||||
get().clearPersistedSection("logs");
|
||||
},
|
||||
setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),
|
||||
setShowLogTimestamps: (value) => set({ showLogTimestamps: value }),
|
||||
setDensityMode: (value) => set({ densityMode: value }),
|
||||
setLogFontSize: (value) => set({ logFontSize: value }),
|
||||
setSidebarWidth: (value) => set({ sidebarWidth: value }),
|
||||
addLocalHost: (host) =>
|
||||
set((state) => ({
|
||||
localHosts: uniqueHosts([
|
||||
{ ...host, source: "local" },
|
||||
...state.localHosts,
|
||||
...state.remoteHosts,
|
||||
]).filter((item) => item.source === "local"),
|
||||
})),
|
||||
removeLocalHost: (host) =>
|
||||
set((state) => ({
|
||||
localHosts: state.localHosts.filter((item) => item.host !== host),
|
||||
})),
|
||||
setRemoteHosts: (hosts) =>
|
||||
set({
|
||||
remoteHosts: uniqueHosts(
|
||||
hosts.map((item) => ({ ...item, source: "remote" as const })),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "settings-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
10
src/types/Course.ts
Normal file
10
src/types/Course.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type CourseType = {
|
||||
name: string;
|
||||
id: number;
|
||||
teacher: string;
|
||||
progress: string;
|
||||
start_time: string;
|
||||
stop_time: string;
|
||||
credit: number;
|
||||
type: string;
|
||||
};
|
||||
7
src/types/Userinfo.ts
Normal file
7
src/types/Userinfo.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type userInfoType = {
|
||||
id: string;
|
||||
name: string;
|
||||
dept: string;
|
||||
class: string;
|
||||
gender: string;
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["src/*"]
|
||||
},
|
||||
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user