feat: UI optimizations - button feedback, layout fixes, cache clearing, work/exam records

- Add active: state feedback to all buttons across the app
- Fix cache clearing to update Zustand store (not just localStorage)
- Remove checkboxes from settings cache section, compact layout
- Settings page: single outer scroll instead of dual-column scroll
- CourseWorkspace: elastic log panel height, work/exam record counts
- Integrate WorkList/ExamList types and display in UI
- Delete unused CourseList.tsx component
- Fix wk.ts: strict equality, remove unused import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 17:36:02 +08:00
parent 13f0be162b
commit a1911573d1
13 changed files with 603 additions and 302 deletions

View File

@@ -20,6 +20,8 @@ import {
type CourseKind,
type RecordItem,
type RecordType,
type WorkListItem,
type ExamListItem,
} from "~/service/wk";
import { setUnauthorizedHandler } from "~/service/http";
import { accountStore, type AccountItem } from "~/store/account";
@@ -196,7 +198,10 @@ const Account = () => {
const courseKind = createMemo(() => storeState().courseKind);
const recordType = createMemo(() => storeState().recordType);
const records = createMemo(() => storeState().records);
const workList = createMemo(() => storeState().workList);
const examList = createMemo(() => storeState().examList);
const recordCacheMap = createMemo(() => storeState().recordCacheMap);
const workExamCacheMap = createMemo(() => storeState().workExamCacheMap);
const studyLogsMap = createMemo(() => storeState().studyLogsMap);
const runningStudyMap = createMemo(() => storeState().runningStudyMap);
const mergedHostOptions = createMemo(() =>
@@ -567,33 +572,83 @@ const Account = () => {
setRecordError("");
try {
const res = await getAccountClient(accountId).recordApi({
course_id: String(courseId),
page: 0,
record_type: nextRecordType,
});
if (requestToken !== recordRequestToken) {
return;
}
const cacheKey = createRecordCacheKey(accountId, courseId, nextRecordType);
const client = getAccountClient(accountId);
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as RecordItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
if (nextRecordType === "/work") {
const res = await client.workListApi({
course_id: String(courseId),
page: 0,
record_type: "/work",
});
if (requestToken !== recordRequestToken) return;
accountStore.getState().setRecords(list);
accountStore
.getState()
.setRecordCache(
createRecordCacheKey(accountId, courseId, nextRecordType),
list,
);
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as WorkListItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setWorkList(list);
accountStore.getState().setRecords([]);
accountStore.getState().setExamList([]);
accountStore.getState().setWorkExamCache(cacheKey, list);
} else if (nextRecordType === "/exam") {
const res = await client.examListApi({
course_id: String(courseId),
page: 0,
record_type: "/exam",
});
if (requestToken !== recordRequestToken) return;
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as ExamListItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setExamList(list);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setWorkExamCache(cacheKey, list);
} else {
const res = await client.recordApi({
course_id: String(courseId),
page: 0,
record_type: nextRecordType,
});
if (requestToken !== recordRequestToken) {
return;
}
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as RecordItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setRecords(list);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
accountStore
.getState()
.setRecordCache(cacheKey, list);
}
} catch (error) {
if (requestToken !== recordRequestToken) {
return;
@@ -602,6 +657,8 @@ const Account = () => {
error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
setRecordError(message);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
} finally {
if (requestToken === recordRequestToken) {
setRecordsLoading(false);
@@ -729,10 +786,23 @@ const Account = () => {
return;
}
const cachedRecords =
recordCacheMap()[createRecordCacheKey(accountId, courseId, type)] ??
[];
accountStore.getState().setRecords(cachedRecords);
const cacheKey = createRecordCacheKey(accountId, courseId, type);
if (type === "/work" || type === "/exam") {
const cachedData = workExamCacheMap()[cacheKey] ?? [];
if (type === "/work") {
accountStore.getState().setWorkList(cachedData as WorkListItem[]);
accountStore.getState().setExamList([]);
} else {
accountStore.getState().setExamList(cachedData as ExamListItem[]);
accountStore.getState().setWorkList([]);
}
accountStore.getState().setRecords([]);
} else {
const cachedRecords = recordCacheMap()[cacheKey] ?? [];
accountStore.getState().setRecords(cachedRecords);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
}
},
),
);
@@ -744,12 +814,19 @@ const Account = () => {
const cacheKey = account
? createRecordCacheKey(account.id, courseId ?? 0, type)
: "";
const hasCachedRecords = cacheKey ? cacheKey in recordCacheMap() : false;
const hasCachedRecords = cacheKey
? cacheKey in recordCacheMap() || cacheKey in workExamCacheMap()
: false;
if (!hasRestoredRecords()) {
setHasRestoredRecords(true);
if (courseId && account && (records().length > 0 || hasCachedRecords)) {
const hasAnyData =
records().length > 0 ||
workList().length > 0 ||
examList().length > 0 ||
hasCachedRecords;
if (courseId && account && hasAnyData) {
return;
}
}
@@ -818,6 +895,8 @@ const Account = () => {
recordTypeOptions={recordTypeOptions}
courseRecordTypeOptions={statusOptions}
records={records()}
workList={workList()}
examList={examList()}
studyLogs={studyLogs()}
recordsLoading={recordsLoading()}
recordError={recordError()}
@@ -840,6 +919,8 @@ const Account = () => {
accountStore.getState().setCourseKind(value);
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
}}
onStartStudy={() => void handleStartStudy()}
onStopStudy={handleStopStudy}

View File

@@ -469,7 +469,7 @@ const DebugLogs = () => {
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<button
type="button"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20 active:bg-cyan-400/30"
onClick={connectDebugSocket}
disabled={
debugSocketState() === "open" ||
@@ -480,21 +480,21 @@ const DebugLogs = () => {
</button>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10 active:bg-white/20"
onClick={() => void loadDebugSnapshot()}
>
</button>
<button
type="button"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20 active:bg-cyan-400/30"
onClick={downloadDebugLogs}
>
</button>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10 active:bg-white/20"
onClick={disconnectDebugSocket}
disabled={debugSocketState() !== "open"}
>
@@ -502,7 +502,7 @@ const DebugLogs = () => {
</button>
<button
type="button"
class="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1.5 text-xs font-medium text-rose-100 transition hover:bg-rose-400/20"
class="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1.5 text-xs font-medium text-rose-100 transition hover:bg-rose-400/20 active:bg-rose-400/30"
onClick={clearDebugLogs}
>
@@ -543,7 +543,7 @@ const DebugLogs = () => {
class={`grid w-full grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] items-start gap-3 rounded-[16px] border px-3 py-2 text-left transition ${
selectedDebugEntryId() === entry.id
? "border-cyan-300/35 bg-cyan-400/10"
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6"
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6 active:bg-white/10"
}`}
onClick={() => setSelectedDebugEntryId(entry.id)}
>
@@ -590,7 +590,7 @@ const DebugLogs = () => {
class={`rounded-full border px-3 py-1.5 text-xs transition ${
selectedDetailTab() === tab.id
? "border-cyan-300/35 bg-cyan-400/12 text-cyan-100"
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10"
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 active:bg-white/20"
}`}
onClick={() => setSelectedDetailTab(tab.id)}
>

View File

@@ -198,7 +198,7 @@ const Logs = () => {
</Show>
<button
type="button"
class="rounded-full border border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))] px-3.5 py-1.5 text-sm font-medium text-rose-600 transition hover:-translate-y-px hover:bg-rose-50"
class="rounded-full border border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))] px-3.5 py-1.5 text-sm font-medium text-rose-600 transition hover:-translate-y-px hover:bg-rose-50 active:bg-rose-100 active:scale-[0.97]"
onClick={clearAllLogs}
>

View File

@@ -138,123 +138,78 @@ const Setting = () => {
</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 class="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<div class="flex flex-col gap-4">
<div class="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-base font-semibold text-zinc-900"></p>
<p class="mt-0.5 text-xs text-zinc-500">
</p>
</div>
<button
type="button"
class="rounded-lg border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-xs text-rose-600 transition hover:bg-rose-100 active:bg-rose-200"
onClick={() => settingsStore.getState().clearAllPersistedData()}
>
</button>
</div>
<div class="mt-3 grid gap-2">
<div class="flex items-center justify-between gap-2 rounded-xl border border-zinc-200 bg-zinc-50/80 px-3 py-2">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("accounts")
}
>
</button>
</div>
<div class="flex items-center justify-between gap-2 rounded-xl border border-zinc-200 bg-zinc-50/80 px-3 py-2">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("records")
}
>
</button>
</div>
<div class="flex items-center justify-between gap-2 rounded-xl border border-zinc-200 bg-zinc-50/80 px-3 py-2">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("logs")
}
>
</button>
</div>
</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">
使
@@ -419,9 +374,9 @@ const Setting = () => {
</div>
</div>
</div>
</section>
</div>
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<div class="flex flex-col gap-4">
<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>
@@ -436,7 +391,7 @@ const Setting = () => {
<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"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoadingRemoteHosts()}
onClick={() => void loadRemoteHosts()}
>
@@ -459,7 +414,7 @@ const Setting = () => {
</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"
class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600 active:bg-cyan-700"
onClick={addLocalHost}
>
Host
@@ -490,7 +445,7 @@ const Setting = () => {
</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"
class="rounded-lg border border-rose-200 px-3 py-1 text-xs text-rose-600 transition hover:bg-rose-50 active:bg-rose-100"
onClick={() =>
settingsStore
.getState()
@@ -524,7 +479,7 @@ const Setting = () => {
</div>
</div>
</div>
</section>
</div>
</div>
</div>
);