diff --git a/src/hooks/UseInitialLoaded.test.tsx b/src/hooks/UseInitialLoaded.test.tsx new file mode 100644 index 0000000..238fd19 --- /dev/null +++ b/src/hooks/UseInitialLoaded.test.tsx @@ -0,0 +1,30 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { useInitialLoaded } from "./UseInitialLoaded.ts"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("useInitialLoaded", () => { + it("stays false until the first load finishes, then remains true on refreshes", async () => { + let result = false; + function Host({ loading }: { loading: boolean }) { + result = useInitialLoaded(loading); + return null; + } + + const root = createRoot(document.createElement("div")); + try { + await act(async () => root.render()); + expect(result).toBe(false); + + await act(async () => root.render()); + expect(result).toBe(true); + + await act(async () => root.render()); + expect(result).toBe(true); + } finally { + await act(async () => root.unmount()); + } + }); +}); diff --git a/src/hooks/UseInitialLoaded.ts b/src/hooks/UseInitialLoaded.ts new file mode 100644 index 0000000..2cbeb5e --- /dev/null +++ b/src/hooks/UseInitialLoaded.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +/** + * 区分「首次加载」与「筛选/翻页等后续刷新」。 + * + * 首次 loading 结束后返回 true,之后保持 true,页面可据此只在首次加载时 + * 展示整页 Loading,后续刷新只更新列表区域。 + */ +export function useInitialLoaded(loading: boolean): boolean { + const [initialLoaded, setInitialLoaded] = useState(!loading); + + useEffect(() => { + if (!loading) { + setInitialLoaded(true); + } + }, [loading]); + + return initialLoaded; +} diff --git a/src/hooks/UseRdList.test.tsx b/src/hooks/UseRdList.test.tsx index e4af902..5806d13 100644 --- a/src/hooks/UseRdList.test.tsx +++ b/src/hooks/UseRdList.test.tsx @@ -22,6 +22,7 @@ describe("R&D list request lifecycle", () => { const root = createRoot(document.createElement("div")); try { await act(async () => root.render()); + expect(state.initialLoaded).toBe(false); await act(async () => root.render()); expect(load).toHaveBeenLastCalledWith({ search: "", page: 2, pageSize: 10, organizationIds: ["7"] }); await act(async () => pending[1].resolve({ data: ["new"], total: 11 })); @@ -29,6 +30,7 @@ describe("R&D list request lifecycle", () => { expect(state.data).toEqual(["new"]); expect(state.total).toBe(11); expect(state.loading).toBe(false); + expect(state.initialLoaded).toBe(true); await act(async () => { void state.refresh(); }); diff --git a/src/hooks/UseRdList.ts b/src/hooks/UseRdList.ts index b9541b8..a3a6de8 100644 --- a/src/hooks/UseRdList.ts +++ b/src/hooks/UseRdList.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import message from "../components/message/Message"; import i18n from "@/i18n"; +import { useInitialLoaded } from "./UseInitialLoaded"; interface PageQuery { page: number; @@ -19,6 +20,7 @@ export function useRdList( const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); + const initialLoaded = useInitialLoaded(loading); const requestId = useRef(0); const refresh = useCallback(async () => { @@ -49,5 +51,5 @@ export function useRdList( }; }, [refresh]); - return { data, total, loading, refresh }; + return { data, total, loading, initialLoaded, refresh }; } diff --git a/src/pages/admin/ArticleManagement.tsx b/src/pages/admin/ArticleManagement.tsx index a34f394..c46d871 100644 --- a/src/pages/admin/ArticleManagement.tsx +++ b/src/pages/admin/ArticleManagement.tsx @@ -35,6 +35,7 @@ import { confirm } from "@/components/confirm/Confirm"; import type { SelectOption } from "@/types"; import { PlatformRoleByKey, type PlatformRole } from "@/types/Roles.ts"; import { useEnumLabels } from "@/i18n/UseEnumLabels"; +import { useInitialLoaded } from "@/hooks/UseInitialLoaded"; import styles from "./ArticleManagement.module.css"; import ArticleService, { type ListAdminArticlesResponse, type ArticleStatsResponse } from "@/services/ArticleService.ts"; import { CategoryService } from "@/services/CategoryService.ts"; @@ -123,6 +124,7 @@ const ArticleManagement: React.FC = () => { }); const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(true); + const initialLoaded = useInitialLoaded(loading); const [categoryOptions, setCategoryOptions] = useState([ { id: "", name: t("articles.filter.allCategories"), color: "#6c757d" }, ]); @@ -572,7 +574,7 @@ const ArticleManagement: React.FC = () => { return pages; }; - if (loading) { + if (loading && !initialLoaded) { return (
@@ -734,7 +736,13 @@ const ArticleManagement: React.FC = () => { - {currentArticles.length > 0 ? ( + {loading ? ( + + + + + + ) : currentArticles.length > 0 ? ( currentArticles.map((article) => ( diff --git a/src/pages/admin/AssignmentManagement.tsx b/src/pages/admin/AssignmentManagement.tsx index a6b5ac4..b934b2c 100644 --- a/src/pages/admin/AssignmentManagement.tsx +++ b/src/pages/admin/AssignmentManagement.tsx @@ -28,6 +28,7 @@ import Modal from "@/components/modal/Modal"; import styles from "./AssignmentManagement.module.css"; import AssignmentService, { type AssignmentStatsResponse } from "@/services/AssignmentService.ts"; import { AssignmentStatus, AssignmentPriority } from "@/types/Enums.ts"; +import { useInitialLoaded } from "@/hooks/UseInitialLoaded"; // 模拟任务数据接口 interface Assignment { @@ -76,6 +77,7 @@ const PRIORITY_NUMBER_MAP_STR: Record = { const AssignmentManagement: React.FC = () => { const { t } = useTranslation("admin"); const [loading, setLoading] = useState(true); + const initialLoaded = useInitialLoaded(loading); const [assignments, setAssignments] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); @@ -370,7 +372,7 @@ const AssignmentManagement: React.FC = () => { } }; - if (loading) { + if (loading && !initialLoaded) { return ; } @@ -492,7 +494,13 @@ const AssignmentManagement: React.FC = () => { - {currentData.length > 0 ? ( + {loading ? ( + + + + + + ) : currentData.length > 0 ? ( currentData.map((item) => ( diff --git a/src/pages/admin/CommentManagement.tsx b/src/pages/admin/CommentManagement.tsx index e8dee58..010aa3e 100644 --- a/src/pages/admin/CommentManagement.tsx +++ b/src/pages/admin/CommentManagement.tsx @@ -28,6 +28,7 @@ import type { AdminComment } from "@/types/Comment.ts"; import type { SelectOption } from "@/types"; import type { CommentStatus } from "@/types/Enums.ts"; import { useEnumLabels } from "@/i18n/UseEnumLabels"; +import { useInitialLoaded } from "@/hooks/UseInitialLoaded"; import styles from "./CommentManagement.module.css"; interface FilterOptions { @@ -54,6 +55,7 @@ const CommentManagement: React.FC = () => { }); const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(true); + const initialLoaded = useInitialLoaded(loading); const [selectedComments, setSelectedComments] = useState([]); const [showReplies, setShowReplies] = useState([]); @@ -273,7 +275,7 @@ const CommentManagement: React.FC = () => { return pages; }; - if (loading) { + if (loading && !initialLoaded) { return (
@@ -393,7 +395,11 @@ const CommentManagement: React.FC = () => { {/* 评论列表 */}
- {comments.length > 0 ? ( + {loading ? ( +
+ +
+ ) : comments.length > 0 ? ( comments.map((comment) => { const replies = getReplies(comment.id); const hasReplies = replies.length > 0; diff --git a/src/pages/admin/OrganizationManagement.tsx b/src/pages/admin/OrganizationManagement.tsx index 0ffe0e5..7b1975a 100644 --- a/src/pages/admin/OrganizationManagement.tsx +++ b/src/pages/admin/OrganizationManagement.tsx @@ -25,6 +25,7 @@ import Modal from "@/components/modal/Modal"; import CustomSelect from "@/components/customSelect/CustomSelect"; import styles from "./OrganizationManagement.module.css"; import OrganizationService, { type OrganizationStatsResponse, type ApplyItem } from "@/services/OrganizationService.ts"; +import { useInitialLoaded } from "@/hooks/UseInitialLoaded"; interface Organization { id: string; @@ -39,6 +40,7 @@ interface Organization { const OrganizationManagement: React.FC = () => { const { t } = useTranslation("admin"); const [loading, setLoading] = useState(true); + const initialLoaded = useInitialLoaded(loading); const [organizations, setOrganizations] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); @@ -326,7 +328,7 @@ const OrganizationManagement: React.FC = () => { } }; - if (loading && activeTab === "orgs") { + if (loading && !initialLoaded && activeTab === "orgs") { return ; } @@ -488,7 +490,13 @@ const OrganizationManagement: React.FC = () => { - {currentData.length > 0 ? ( + {loading ? ( + + + + + + ) : currentData.length > 0 ? ( currentData.map((item) => ( diff --git a/src/pages/admin/UserManagement.tsx b/src/pages/admin/UserManagement.tsx index 9ece91d..0672a63 100644 --- a/src/pages/admin/UserManagement.tsx +++ b/src/pages/admin/UserManagement.tsx @@ -23,6 +23,7 @@ import message from "@/components/message/Message"; import type { SelectOption } from "@/types"; import { PlatformRoleCSS, PlatformRoleByKey, type PlatformRole } from "@/types/Roles.ts"; import { useEnumLabels } from "@/i18n/UseEnumLabels"; +import { useInitialLoaded } from "@/hooks/UseInitialLoaded"; import styles from "./UserManagement.module.css"; import { AuthService } from "@/services/AuthService.ts"; import { formatToChinaTime } from "@/utils/Utils.ts"; @@ -76,6 +77,7 @@ const UserManagement: React.FC = () => { }); const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(true); + const initialLoaded = useInitialLoaded(loading); const [totalUsers, setTotalUsers] = useState(0); const [isModalVisible, setIsModalVisible] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -422,7 +424,7 @@ const UserManagement: React.FC = () => { return pages; }; - if (loading) { + if (loading && !initialLoaded) { return (
@@ -566,7 +568,13 @@ const UserManagement: React.FC = () => { - {currentUsers.length > 0 ? ( + {loading ? ( + + + + + + ) : currentUsers.length > 0 ? ( currentUsers.map((user) => ( diff --git a/src/pages/rd-platform/BugList.tsx b/src/pages/rd-platform/BugList.tsx index 18e5f95..211ecb6 100644 --- a/src/pages/rd-platform/BugList.tsx +++ b/src/pages/rd-platform/BugList.tsx @@ -135,6 +135,7 @@ const BugList: React.FC = () => { data: bugs, total, loading, + initialLoaded, refresh: fetchData, } = useRdList(RdAPI.getBugs, filters, currentPage, selectedOrgId, PAGE_SIZE); @@ -274,7 +275,7 @@ const BugList: React.FC = () => { return pages; }; - if (loading && bugs.length === 0) { + if (loading && !initialLoaded) { return ; } @@ -367,66 +368,76 @@ const BugList: React.FC = () => { - {bugs.map((b) => ( - - navigate(`/rd/bugs/${encodeId(b.id, "bug")}`)} - > - {b.title} - - - - {severityText[b.severity]} - - - - - {priorityText[b.priority]} - - - - {statusText[b.status]} - - - + {loading && ( + + + - {b.module || "-"} - {b.creator} - {formatDateTime(b.createdAt)} - -
- - {OrgPermission.canEdit(maxOrgRole) && ( - - )} - {OrgPermission.canDelete(maxOrgRole) && ( + + )} + {!loading && + bugs.map((b) => ( + + navigate(`/rd/bugs/${encodeId(b.id, "bug")}`)} + > + {b.title} + + + + {severityText[b.severity]} + + + + + {priorityText[b.priority]} + + + + + {statusText[b.status]} + + + + + + {b.module || "-"} + {b.creator} + {formatDateTime(b.createdAt)} + +
- )} -
- - - ))} - {bugs.length === 0 && ( + {OrgPermission.canEdit(maxOrgRole) && ( + + )} + {OrgPermission.canDelete(maxOrgRole) && ( + + )} +
+ + + ))} + {!loading && bugs.length === 0 && (
diff --git a/src/pages/rd-platform/RequirementList.tsx b/src/pages/rd-platform/RequirementList.tsx index 75c1488..6712dca 100644 --- a/src/pages/rd-platform/RequirementList.tsx +++ b/src/pages/rd-platform/RequirementList.tsx @@ -101,6 +101,7 @@ const RequirementList: React.FC = () => { data: requirements, total, loading, + initialLoaded, refresh: fetchData, } = useRdList(RdAPI.getRequirements, filters, currentPage, selectedOrgId, PAGE_SIZE); @@ -246,7 +247,7 @@ const RequirementList: React.FC = () => { return pages; }; - if (loading && requirements.length === 0) { + if (loading && !initialLoaded) { return ; } @@ -331,63 +332,71 @@ const RequirementList: React.FC = () => { - {requirements.map((r) => ( - - navigate(`/rd/requirements/${encodeId(r.id, "requirement")}`)} - > - {r.title} - - - - {labels.rdPriority[r.priority]} - - - - - {labels.reqStatus[r.status]} - - - - + {loading && ( + + + - {r.iteration || "-"} - {r.creator} - {formatDateTime(r.createdAt)} - -
- - {OrgPermission.canEdit(maxOrgRole) && ( - - )} - {OrgPermission.canDelete(maxOrgRole) && ( + + )} + {!loading && + requirements.map((r) => ( + + navigate(`/rd/requirements/${encodeId(r.id, "requirement")}`)} + > + {r.title} + + + + {labels.rdPriority[r.priority]} + + + + + {labels.reqStatus[r.status]} + + + + + + {r.iteration || "-"} + {r.creator} + {formatDateTime(r.createdAt)} + +
- )} -
- - - ))} - {requirements.length === 0 && ( + {OrgPermission.canEdit(maxOrgRole) && ( + + )} + {OrgPermission.canDelete(maxOrgRole) && ( + + )} +
+ + + ))} + {!loading && requirements.length === 0 && (
diff --git a/src/pages/rd-platform/TaskList.tsx b/src/pages/rd-platform/TaskList.tsx index dd3fd0d..0f3200a 100644 --- a/src/pages/rd-platform/TaskList.tsx +++ b/src/pages/rd-platform/TaskList.tsx @@ -83,6 +83,7 @@ const TaskList: React.FC = () => { data: tasks, total, loading, + initialLoaded, refresh: fetchData, } = useRdList(RdAPI.getTasks, filters, currentPage, selectedOrgId, PAGE_SIZE); @@ -211,7 +212,7 @@ const TaskList: React.FC = () => { return pages; }; - if (loading && tasks.length === 0) { + if (loading && !initialLoaded) { return ; } @@ -302,64 +303,74 @@ const TaskList: React.FC = () => { - {tasks.map((task) => ( - - navigate(`/rd/tasks/${encodeId(task.id, "task")}`)} - > - {task.title} - - - - {enums.rdTaskStatus[task.status]} - - - - - {enums.rdPriority[task.priority]} - - - - - - {task.deadline ? formatDateTime(task.deadline) : "-"} - - {task.estimatedHours ? `${task.estimatedHours}h` : "-"} + {loading && ( + + + - -
- - {OrgPermission.canEdit(maxOrgRole) && ( - - )} - {OrgPermission.canDelete(maxOrgRole) && ( + + )} + {!loading && + tasks.map((task) => ( + + navigate(`/rd/tasks/${encodeId(task.id, "task")}`)} + > + {task.title} + + + + {enums.rdTaskStatus[task.status]} + + + + + {enums.rdPriority[task.priority]} + + + + + + + {task.deadline ? formatDateTime(task.deadline) : "-"} + + + {task.estimatedHours ? `${task.estimatedHours}h` : "-"} + + +
- )} -
- - - ))} - {tasks.length === 0 && ( + {OrgPermission.canEdit(maxOrgRole) && ( + + )} + {OrgPermission.canDelete(maxOrgRole) && ( + + )} +
+ + + ))} + {!loading && tasks.length === 0 && (
diff --git a/src/pages/rd-platform/TrendAnalysis.module.css b/src/pages/rd-platform/TrendAnalysis.module.css index a9d9e6d..554d0c3 100644 --- a/src/pages/rd-platform/TrendAnalysis.module.css +++ b/src/pages/rd-platform/TrendAnalysis.module.css @@ -30,6 +30,12 @@ flex-shrink: 0; } +.refreshing { + color: var(--text-tertiary); + font-size: 12px; + white-space: nowrap; +} + .periodSwitch { display: inline-flex; align-items: center; diff --git a/src/pages/rd-platform/TrendAnalysis.tsx b/src/pages/rd-platform/TrendAnalysis.tsx index 5ae3448..a614b91 100644 --- a/src/pages/rd-platform/TrendAnalysis.tsx +++ b/src/pages/rd-platform/TrendAnalysis.tsx @@ -241,14 +241,13 @@ const TrendAnalysis: React.FC = () => { return (
- {loading && } -

{t("trend.pageTitle")}

{t("trend.pageDescription", { days: periodDays })}

+ {loading && {t("trend.refreshing")}}