From 30c476e2f303f95251822e83a2410dcee5bf3a45 Mon Sep 17 00:00:00 2001 From: overtrue Date: Sat, 11 Jul 2026 13:14:49 +0800 Subject: [PATCH] refactor: harden task management and TypeScript safety --- .github/workflows/release.yml | 4 +- .github/workflows/test.yml | 2 +- app/(dashboard)/access-keys/page.tsx | 15 +- app/(dashboard)/users/page.tsx | 15 +- components/license/enterprise-section.tsx | 1 + components/object/list.tsx | 1 + components/object/preview-modal.tsx | 19 +- components/tasks/item.tsx | 36 +- components/tasks/panel.tsx | 40 ++- components/tasks/stats-button.tsx | 6 +- contexts/task-context.tsx | 78 ++--- hooks/use-local-storage.ts | 8 +- hooks/use-permissions.tsx | 20 +- hooks/use-tiers.ts | 2 +- lib/api-client.ts | 105 +++--- lib/api-request-log.ts | 32 ++ lib/bucket-cors.ts | 2 +- lib/console-policy-parser.ts | 17 +- lib/delete-task.ts | 25 +- lib/object-zip-download.ts | 6 +- lib/task-manager.ts | 364 +++++++++++++-------- lib/upload-task.ts | 41 +-- package.json | 1 + scripts/register-typescript-loader.mjs | 3 + scripts/typescript-loader.mjs | 36 ++ tests/lib/api-client-source.test.js | 10 +- tests/lib/api-client.test.ts | 71 ++++ tests/lib/delete-task.test.ts | 25 ++ tests/lib/object-list-source.test.js | 7 + tests/lib/object-list-state.test.ts | 2 +- tests/lib/object-lock-permissions.test.ts | 22 ++ tests/lib/object-preview-source.test.js | 9 + tests/lib/object-zip-download.test.js | 8 +- tests/lib/session-safety-source.test.js | 28 ++ tests/lib/task-manager.test.ts | 303 +++++++++++++++++ tests/lib/task-stats-button-source.test.js | 11 + tests/lib/upload-task.test.ts | 60 ++++ 37 files changed, 1084 insertions(+), 351 deletions(-) create mode 100644 lib/api-request-log.ts create mode 100644 scripts/register-typescript-loader.mjs create mode 100644 scripts/typescript-loader.mjs create mode 100644 tests/lib/api-client.test.ts create mode 100644 tests/lib/delete-task.test.ts create mode 100644 tests/lib/session-safety-source.test.js create mode 100644 tests/lib/task-manager.test.ts create mode 100644 tests/lib/upload-task.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c67670c0..c2772705 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,9 +63,7 @@ jobs: run: | echo "## ๐Ÿงช Running Full Test Suite" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ -d "tests" ] && [ -n "$(find tests -name '*.test.ts' -o -name '*.spec.ts' 2>/dev/null)" ]; then - pnpm run test:run 2>/dev/null || true - fi + pnpm run test:run echo "โœ… All tests passed!" >> $GITHUB_STEP_SUMMARY - name: ๐Ÿ” Validate code quality diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a8c2430..9ab67337 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,7 +73,7 @@ jobs: run: pnpm ci - name: ๐Ÿงช Run unit tests - run: pnpm run test:run 2>/dev/null || echo "No test:run script, skipping" + run: pnpm run test:run - name: ๐Ÿ“Š Upload test results uses: actions/upload-artifact@v7 diff --git a/app/(dashboard)/access-keys/page.tsx b/app/(dashboard)/access-keys/page.tsx index 33f70449..5c1aafd2 100644 --- a/app/(dashboard)/access-keys/page.tsx +++ b/app/(dashboard)/access-keys/page.tsx @@ -184,16 +184,19 @@ export default function AccessKeysPage() { positiveText: t("Confirm"), negativeText: t("Cancel"), onPositiveClick: async () => { - try { - await Promise.all(selectedKeys.map((key) => deleteServiceAccount(key))) + const results = await Promise.allSettled(selectedKeys.map((key) => deleteServiceAccount(key))) + const failures = results.filter((result) => result.status === "rejected") + + if (failures.length === 0) { message.success(t("Delete Success")) table.resetRowSelection() - await listUserAccounts() - } catch (error) { + } else { + const firstFailure = failures[0] + const error = firstFailure?.status === "rejected" ? firstFailure.reason : undefined console.error(error) - const msg = (error as Error)?.message || t("Delete Failed") - message.error(msg) + message.error((error as Error)?.message || t("Delete Failed")) } + await listUserAccounts() }, }) } diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx index 0f72846e..4705c59b 100644 --- a/app/(dashboard)/users/page.tsx +++ b/app/(dashboard)/users/page.tsx @@ -199,16 +199,19 @@ export default function UsersPage() { positiveText: t("Confirm"), negativeText: t("Cancel"), onPositiveClick: async () => { - try { - await Promise.all(selectedKeys.map((key) => deleteUser(key))) + const results = await Promise.allSettled(selectedKeys.map((key) => deleteUser(key))) + const failures = results.filter((result) => result.status === "rejected") + + if (failures.length === 0) { message.success(t("Delete Success")) table.resetRowSelection() - await getDataList() - } catch (error) { + } else { + const firstFailure = failures[0] + const error = firstFailure?.status === "rejected" ? firstFailure.reason : undefined console.error(error) - const msg = (error as Error)?.message || t("Delete Failed") - message.error(msg) + message.error((error as Error)?.message || t("Delete Failed")) } + await getDataList() }, }) } diff --git a/components/license/enterprise-section.tsx b/components/license/enterprise-section.tsx index c9f08ba9..7be9d831 100644 --- a/components/license/enterprise-section.tsx +++ b/components/license/enterprise-section.tsx @@ -210,6 +210,7 @@ export function LicenseEnterpriseSection() { window.open( "https://ww18.53kf.com/webCompany.php?arg=11003151&kf_sign=DA4MDMTc0Ng4MjE1MjEzODAyNDkyMDAyNzMwMDMxNTE%253D&style=2", "_blank", + "noopener,noreferrer", ) } diff --git a/components/object/list.tsx b/components/object/list.tsx index 0cca3cc5..4d1bdcbb 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -375,6 +375,7 @@ export function ObjectList({ try { const url = await getSignedUrl(key) const response = await fetch(url) + if (!response.ok) throw new Error(t("Download Failed")) const filename = key.split("/").pop() ?? "" const headers: Record = { "content-type": getContentType(response.headers, filename), diff --git a/components/object/preview-modal.tsx b/components/object/preview-modal.tsx index 8a585347..c9ab16b7 100644 --- a/components/object/preview-modal.tsx +++ b/components/object/preview-modal.tsx @@ -160,13 +160,24 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview React.useEffect(() => { if (show && previewMode === "text" && previewUrl) { + const controller = new AbortController() setLoading(true) setIsFormatted(true) - fetch(previewUrl) - .then((r) => r.text()) + setTextContent("") + fetch(previewUrl, { signal: controller.signal }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return response.text() + }) .then(setTextContent) - .catch(() => setTextContent(t("Preview unavailable"))) - .finally(() => setLoading(false)) + .catch((error: unknown) => { + if ((error as Error)?.name !== "AbortError") setTextContent(t("Preview unavailable")) + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false) + }) + + return () => controller.abort() } else if (!show) { setTextContent("") setLoading(false) diff --git a/components/tasks/item.tsx b/components/tasks/item.tsx index a026e216..8a867ce0 100644 --- a/components/tasks/item.tsx +++ b/components/tasks/item.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useTranslation } from "react-i18next" -import { RiDeleteBinLine } from "@remixicon/react" +import { RiCloseCircleLine, RiDeleteBinLine } from "@remixicon/react" import { Button } from "@/components/ui/button" import { Progress } from "@/components/ui/progress" import { useTaskManager } from "@/contexts/task-context" @@ -17,7 +17,6 @@ const statusMap: Record = { running: "in progress", completed: "success", failed: "failed", - paused: "paused", canceled: "canceled", } @@ -27,20 +26,35 @@ export function TaskItem({ task }: TaskItemProps) { const statusKey = statusMap[task.status] ?? "in progress" const statusText = `${t(task.actionLabel)}${t(statusKey)}` + const isActive = task.status === "pending" || task.status === "running" + const isFinished = task.status === "completed" || task.status === "failed" || task.status === "canceled" return (
{task.displayName}
- + {isActive && ( + + )} + {isFinished && ( + + )}
diff --git a/components/tasks/panel.tsx b/components/tasks/panel.tsx index af41da79..add9a701 100644 --- a/components/tasks/panel.tsx +++ b/components/tasks/panel.tsx @@ -10,11 +10,11 @@ import { TaskItem } from "./item" import type { AnyTask } from "@/contexts/task-context" interface TaskPanelProps { - tasks: AnyTask[] - onClearTasks: () => void + tasks: readonly AnyTask[] + onClearFinishedTasks: () => void } -export function TaskPanel({ tasks, onClearTasks }: TaskPanelProps) { +export function TaskPanel({ tasks, onClearFinishedTasks }: TaskPanelProps) { const { t } = useTranslation() const [tab, setTab] = React.useState("pending") @@ -22,27 +22,30 @@ export function TaskPanel({ tasks, onClearTasks }: TaskPanelProps) { const processing = tasks.filter((task) => task.status === "running") const completed = tasks.filter((task) => task.status === "completed") const failed = tasks.filter((task) => task.status === "failed") + const canceled = tasks.filter((task) => task.status === "canceled") const total = tasks.length - const percentage = total === 0 ? 100 : Math.floor((completed.length / total) * 100) + const finished = completed.length + failed.length + canceled.length + const percentage = total === 0 ? 100 : Math.floor((finished / total) * 100) React.useEffect(() => { if (processing.length > 0) setTab("processing") else if (pending.length > 0) setTab("pending") else if (completed.length > 0) setTab("completed") + else if (failed.length > 0) setTab("failed") + else if (canceled.length > 0) setTab("canceled") else setTab("pending") - }, [processing.length, pending.length, completed.length]) - - const withCount = (key: string, count: number) => t(key).replace(/\{[^}]*\}/g, String(count)) + }, [processing.length, pending.length, completed.length, failed.length, canceled.length]) const tabs = [ - { value: "pending", label: withCount("Pending", pending.length) }, - { value: "processing", label: withCount("Processing (with count)", processing.length) }, - { value: "completed", label: withCount("Completed", completed.length) }, - { value: "failed", label: withCount("Failed", failed.length) }, + { value: "pending", label: t("Pending", { count: pending.length }) }, + { value: "processing", label: t("Processing (with count)", { count: processing.length }) }, + { value: "completed", label: t("Completed", { count: completed.length }) }, + { value: "failed", label: t("Failed", { count: failed.length }) }, + { value: "canceled", label: `${t("Canceled")} (${canceled.length})` }, ] - const renderList = (list: AnyTask[]) => ( + const renderList = (list: readonly AnyTask[]) => (
{list.length === 0 ? (

{t("No Data")}

@@ -69,11 +72,13 @@ export function TaskPanel({ tasks, onClearTasks }: TaskPanelProps) {
- {total - pending.length - processing.length}/{total} + {finished}/{total}
- + {finished > 0 && ( + + )}
@@ -100,6 +105,9 @@ export function TaskPanel({ tasks, onClearTasks }: TaskPanelProps) { {renderList(failed)} + + {renderList(canceled)} +
) diff --git a/components/tasks/stats-button.tsx b/components/tasks/stats-button.tsx index ac4195fa..e8aa8e7e 100644 --- a/components/tasks/stats-button.tsx +++ b/components/tasks/stats-button.tsx @@ -15,9 +15,11 @@ export function TaskStatsButton() { const taskManager = useTaskManager() const { isTaskPanelOpen, setTaskPanelOpen } = useTaskPanelOpen() + const pending = tasks.filter((task) => task.status === "pending") const processing = tasks.filter((task) => task.status === "running") const completed = tasks.filter((task) => task.status === "completed") const failed = tasks.filter((task) => task.status === "failed") + const active = pending.length + processing.length const total = tasks.length if (total === 0) return null @@ -26,7 +28,7 @@ export function TaskStatsButton() {