Browse Source

refactor(theme&project): 重构主题适配与项目列表功能

1. 新增路由感知的Antd主题切换,区分Canvas页面和普通页面的主题配置
2. 重构项目列表状态管理,拆分出全局项目列表hook并支持滚动加载
3. 优化项目操作弹窗样式与交互,新增去重逻辑与多语言适配
4. 重构项目页面切换逻辑,调整首页项目展示结构
feature/group
huangmin 1 month ago
parent
commit
42aa454218
  1. 122
      src/components/AntdThemeProvider.tsx
  2. 522
      src/components/HomeProjectsContent.tsx

122
src/components/AntdThemeProvider.tsx

@ -1,5 +1,6 @@
"use client"; "use client";
import { usePathname } from "next/navigation";
import { App as AntdApp, ConfigProvider, theme, type ThemeConfig } from "antd"; import { App as AntdApp, ConfigProvider, theme, type ThemeConfig } from "antd";
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
@ -58,6 +59,8 @@ function areTokensEqual(left: CanvasThemeTokens, right: CanvasThemeTokens): bool
} }
export function AntdThemeProvider({ children }: { children: ReactNode }) { export function AntdThemeProvider({ children }: { children: ReactNode }) {
const pathname = usePathname();
const isCanvasRoute = pathname?.startsWith("/canvas") ?? false;
const [canvasTokens, setCanvasTokens] = useState<CanvasThemeTokens>(FALLBACK_TOKENS); const [canvasTokens, setCanvasTokens] = useState<CanvasThemeTokens>(FALLBACK_TOKENS);
useEffect(() => { useEffect(() => {
@ -79,47 +82,90 @@ export function AntdThemeProvider({ children }: { children: ReactNode }) {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
const antdTheme = useMemo<ThemeConfig>(() => ({ const antdTheme = useMemo<ThemeConfig>(() => {
algorithm: theme.darkAlgorithm, if (!isCanvasRoute) {
token: { return {
colorPrimary: canvasTokens.accent, algorithm: theme.defaultAlgorithm,
colorInfo: canvasTokens.accent, token: {
colorBgBase: canvasTokens.background, colorPrimary: "#5c3bc7",
colorBgLayout: canvasTokens.background, colorInfo: "#5c3bc7",
colorBgContainer: canvasTokens.panel, colorBgBase: "#f9f9f9",
colorBgElevated: canvasTokens.panelElevated, colorBgLayout: "#f9f9f9",
colorBgMask: "rgba(0, 0, 0, 0.72)", colorBgContainer: "#ffffff",
colorBorder: canvasTokens.border, colorBgElevated: "#ffffff",
colorBorderSecondary: canvasTokens.border, colorBgMask: "rgba(0, 0, 0, 0.45)",
colorText: canvasTokens.text, colorBorder: "#e6e6e6",
colorTextBase: canvasTokens.text, colorBorderSecondary: "#f0f0f0",
colorTextHeading: canvasTokens.text, colorText: "#333333",
colorTextSecondary: canvasTokens.textSecondary, colorTextBase: "#333333",
colorIcon: canvasTokens.textSecondary, colorTextHeading: "#333333",
colorIconHover: canvasTokens.text, colorTextSecondary: "#666666",
borderRadius: 8, colorIcon: "#999999",
borderRadiusLG: 8, colorIconHover: "#666666",
wireframe: false, borderRadius: 8,
}, borderRadiusLG: 8,
components: { wireframe: false,
Button: { },
components: {
Button: {
colorPrimary: "#5c3bc7",
colorPrimaryHover: "#6b4bd4",
colorPrimaryActive: "#4d2fb3",
},
Modal: {
contentBg: "#ffffff",
headerBg: "#ffffff",
titleColor: "#333333",
},
Tooltip: {
colorBgSpotlight: "#333333",
},
},
};
}
return {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: canvasTokens.accent, colorPrimary: canvasTokens.accent,
colorPrimaryHover: "#60a5fa", colorInfo: canvasTokens.accent,
colorPrimaryActive: "#2563eb", colorBgBase: canvasTokens.background,
colorBgLayout: canvasTokens.background,
colorBgContainer: canvasTokens.panel,
colorBgElevated: canvasTokens.panelElevated,
colorBgMask: "rgba(0, 0, 0, 0.72)",
colorBorder: canvasTokens.border,
colorBorderSecondary: canvasTokens.border,
colorText: canvasTokens.text,
colorTextBase: canvasTokens.text,
colorTextHeading: canvasTokens.text,
colorTextSecondary: canvasTokens.textSecondary,
colorIcon: canvasTokens.textSecondary,
colorIconHover: canvasTokens.text,
borderRadius: 8,
borderRadiusLG: 8,
wireframe: false,
}, },
Modal: { components: {
contentBg: canvasTokens.panel, Button: {
headerBg: canvasTokens.panel, colorPrimary: canvasTokens.accent,
titleColor: canvasTokens.text, colorPrimaryHover: "#60a5fa",
colorPrimaryActive: "#2563eb",
},
Modal: {
contentBg: canvasTokens.panel,
headerBg: canvasTokens.panel,
titleColor: canvasTokens.text,
},
Select: {
optionSelectedBg: "#404040",
},
Tooltip: {
colorBgSpotlight: canvasTokens.panelElevated,
},
}, },
Select: { };
optionSelectedBg: "#404040", }, [canvasTokens, isCanvasRoute]);
},
Tooltip: {
colorBgSpotlight: canvasTokens.panelElevated,
},
},
}), [canvasTokens]);
useEffect(() => { useEffect(() => {
ConfigProvider.config({ ConfigProvider.config({

522
src/components/HomeProjectsContent.tsx

@ -1,13 +1,20 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { import {
ProjectActionsDropdown, ProjectActionsDropdown,
type ProjectActionKey, type ProjectActionKey,
} from "@/components/ProjectActionsDropdown"; } from "@/components/ProjectActionsDropdown";
import { import {
ArrowIcon, ArrowIcon,
CloseIcon,
LeftOutlinedIcon, LeftOutlinedIcon,
SearchIcon, SearchIcon,
StartCreation, StartCreation,
@ -83,6 +90,8 @@ const DEFAULT_PROJECT_PAGE_INFO: CanvasWorkflowPageInfo = {
total: 0, total: 0,
}; };
const ALL_PROJECTS_LOAD_MORE_OFFSET = 260;
function formatProjectTime(value?: string): string { function formatProjectTime(value?: string): string {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
@ -216,7 +225,10 @@ function ProjectSectionGrid({
const router = useRouter(); const router = useRouter();
const canGoPrev = !!pageInfo && pageInfo.page > 1; const canGoPrev = !!pageInfo && pageInfo.page > 1;
const canGoNext = !!pageInfo && pageInfo.page < pageInfo.pageCount; const canGoNext = !!pageInfo && pageInfo.page < pageInfo.pageCount;
const [firstProject, ...remainingProjects] = projects; const uniqueProjects = projects.filter(
(project, index, list) => list.findIndex((item) => item.id === project.id) === index
);
const [firstProject, ...remainingProjects] = uniqueProjects;
return ( return (
<div className="relative grid gap-5 md:grid-cols-2 xl:grid-cols-5"> <div className="relative grid gap-5 md:grid-cols-2 xl:grid-cols-5">
@ -307,46 +319,74 @@ function ProjectActionModals({
onCancelDelete: () => void; onCancelDelete: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
}) { }) {
const modalClassName =
"[&_.ant-modal-content]:!rounded-[9px] [&_.ant-modal-content]:!p-[30px] [&_.ant-modal-header]:!mb-0 [&_.ant-modal-body]:!pt-2 [&_.ant-modal-footer]:!mt-6 [&_.ant-modal-footer]:!flex [&_.ant-modal-footer]:!justify-end [&_.ant-modal-footer]:!gap-2.5 [&_.ant-modal-footer_.ant-btn+_.ant-btn]:!ml-0 [&_.ant-modal-close]:!right-2.5 [&_.ant-modal-close]:!top-2.5 [&_.ant-modal-close]:!h-5 [&_.ant-modal-close]:!w-5 [&_.ant-modal-close]:!text-[#999] hover:[&_.ant-modal-close]:!text-[#666]";
const modalTitleClassName = "text-[24px] font-medium leading-[22px] text-[#333]";
const cancelButtonClassName =
"!h-[37px] !w-[109px] !rounded-[24px] !border-0 !bg-[#f0f0f0] !p-0 !text-[16px] !font-medium !text-[#666] !shadow-none hover:!bg-[#e6e6e6] hover:!text-[#666]";
const confirmButtonClassName =
"!h-[37px] !w-[109px] !rounded-[24px] !border-0 !bg-[#5c3bc7] !p-0 !text-[16px] !font-medium !text-white !shadow-none hover:!bg-[#6b4bd4]";
const deleteConfirmButtonClassName =
"!h-[37px] !w-[109px] !rounded-[24px] !border-0 !bg-[#b7301e] !p-0 !text-[16px] !font-medium !text-white !shadow-none hover:!bg-[#a62b1b]";
return ( return (
<> <>
<Modal <Modal
title="重命名项目" title={<span className={modalTitleClassName}></span>}
open={renameOpen} open={renameOpen}
closeIcon={<CloseIcon />}
centered centered
width={491}
className={modalClassName}
confirmLoading={isActionLoading} confirmLoading={isActionLoading}
okText="确认" okText="确认"
cancelText="取消" cancelText="取消(esc)"
okButtonProps={{ disabled: !renameTitle.trim() }} okButtonProps={{
className: confirmButtonClassName,
disabled: !renameTitle.trim(),
}}
cancelButtonProps={{ className: cancelButtonClassName }}
onOk={onConfirmRename} onOk={onConfirmRename}
onCancel={onCancelRename} onCancel={onCancelRename}
destroyOnHidden destroyOnHidden
> >
<Form layout="vertical" preserve={false}> <Form layout="vertical" preserve={false} className="!mt-[7px]">
<Form.Item label="项目名称"> <Form.Item
label={<span className="text-[14px] leading-[22px] text-[#666]"></span>}
className="!mb-0"
>
<Input <Input
value={renameTitle} value={renameTitle}
onChange={(event) => onRenameTitleChange(event.target.value)} onChange={(event) => onRenameTitleChange(event.target.value)}
placeholder="请输入项目名称" placeholder="请输入项目名称"
onPressEnter={onConfirmRename} onPressEnter={onConfirmRename}
autoFocus autoFocus
className="!h-[37px] !rounded-[9px] !border-[#e6e6e6] !text-[14px] !shadow-none hover:!border-[#d6ceef] focus:!border-[#5c3bc7]"
/> />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal <Modal
title="删除项目" title={<span className={modalTitleClassName}></span>}
open={deleteTitle !== null} open={deleteTitle !== null}
closeIcon={<CloseIcon />}
centered centered
width={491}
className={modalClassName}
confirmLoading={isActionLoading} confirmLoading={isActionLoading}
okText="确认删除" okText="确认"
okButtonProps={{ danger: true }} okButtonProps={{
cancelText="取消" danger: true,
className: deleteConfirmButtonClassName,
}}
cancelText="取消(esc)"
cancelButtonProps={{ className: cancelButtonClassName }}
onOk={onConfirmDelete} onOk={onConfirmDelete}
onCancel={onCancelDelete} onCancel={onCancelDelete}
destroyOnHidden destroyOnHidden
> >
<p className="m-0 text-sm leading-6"> <p className="m-0 text-[14px] leading-[22px] text-[#666]">
"{deleteTitle ?? ""}"
</p> </p>
</Modal> </Modal>
</> </>
@ -428,18 +468,21 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
window.setTimeout(() => setProjectCoverProgress(id, undefined), 450); window.setTimeout(() => setProjectCoverProgress(id, undefined), 450);
}, [setProjectCoverProgress, stopCoverProgressTimer]); }, [setProjectCoverProgress, stopCoverProgressTimer]);
const loadProjects = useCallback(async (nextPage = page) => { const loadProjects = useCallback(async (nextPage: number, options?: { append?: boolean }) => {
setIsLoadingProjects(true); setIsLoadingProjects(true);
try { try {
const data = await listCanvasWorkflows({ page: nextPage, pageSize }); const data = await listCanvasWorkflows({ page: nextPage, pageSize });
setProjects( const nextProjects = data.list
data.list
.filter((item) => !item.deleted) .filter((item) => !item.deleted)
.map((item) => mapWorkflowToProject(item)) .map((item) => mapWorkflowToProject(item));
setProjects((currentProjects) =>
options?.append ? [...currentProjects, ...nextProjects] : nextProjects
); );
setPageInfo(data.pageInfo); setPageInfo(data.pageInfo);
} catch { } catch {
setProjects([]); if (!options?.append) {
setProjects([]);
}
setPageInfo({ setPageInfo({
...DEFAULT_PROJECT_PAGE_INFO, ...DEFAULT_PROJECT_PAGE_INFO,
page: nextPage, page: nextPage,
@ -448,44 +491,325 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
} finally { } finally {
setIsLoadingProjects(false); setIsLoadingProjects(false);
} }
}, [page, pageSize]); }, [pageSize]);
useEffect(() => { useEffect(() => {
let canceled = false; void loadProjects(page);
return undefined;
}, [loadProjects, page]);
async function loadPageProjects() { useEffect(() => {
setIsLoadingProjects(true); return () => {
try { stopCoverProgressTimer();
const data = await listCanvasWorkflows({ page, pageSize }); };
if (canceled) return; }, [stopCoverProgressTimer]);
setProjects(
data.list const handleStartCreation = async () => {
.filter((item) => !item.deleted) if (isCreating) {
.map((item) => mapWorkflowToProject(item)) message.warning("项目创建中,请稍后再试");
); return;
setPageInfo(data.pageInfo);
} catch {
if (!canceled) {
setProjects([]);
setPageInfo({
...DEFAULT_PROJECT_PAGE_INFO,
page,
pageSize,
});
}
} finally {
if (!canceled) {
setIsLoadingProjects(false);
}
}
} }
void loadPageProjects(); setIsCreating(true);
const title = "未命名项目";
try {
const result = await createCanvasWorkflow({
title,
desp: "",
properties: buildEmptyWorkflowProperties(title),
});
const workflowId = (result.data as any)?.id;
router.push(workflowId ? `/canvas?workflowId=${workflowId}` : "/canvas");
message.success("项目创建成功");
} catch (error) {
message.error(error instanceof Error ? error.message : "Failed to create workflow.");
setIsCreating(false);
}
};
return () => { const buildFullUpdatePayload = async (id: number) => {
canceled = true; const detail = await getCanvasWorkflowDetail(id);
return {
id,
title: detail.title ?? "",
desp: detail.desp ?? "",
properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "",
}; };
}, [page, pageSize]); };
const handleProjectAction = async (id: number, action: ProjectActionKey) => {
if (actionProjectId !== null) return;
setActionProjectId(id);
try {
if (action === "rename") {
const payload = await buildFullUpdatePayload(id);
setRenamePayload(payload);
setRenameTitle(payload.title);
return;
}
if (action === "change-cover") {
coverProjectIdRef.current = id;
setCoverProjectId(id);
coverInputRef.current?.click();
return;
}
if (action === "duplicate") {
const detail = await getCanvasWorkflowDetail(id);
const title = detail.title ? `${detail.title} 副本` : "未命名项目副本";
const result = await createCanvasWorkflow({
title,
desp: detail.desp ?? "",
properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "",
});
message.success("副本已创建");
await loadProjects(page);
// const workflowId = (result.data as any)?.id;
// if (workflowId) {
// router.push(`/canvas?workflowId=${workflowId}`);
// }
return;
}
if (action === "delete") {
const detail = await getCanvasWorkflowDetail(id);
setDeletePayload({
id,
title: detail.title ?? detail.code ?? `#${id}`,
});
}
} catch (error) {
message.error(error instanceof Error ? error.message : "Project action failed.");
} finally {
setActionProjectId(null);
}
};
const closeRenameModal = () => {
setRenamePayload(null);
setRenameTitle("");
};
const confirmRenameProject = async () => {
if (!renamePayload || actionProjectId !== null) return;
setActionProjectId(renamePayload.id);
try {
await updateCanvasWorkflow({
...renamePayload,
title: renameTitle,
});
message.success("项目已重命名");
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === renamePayload.id
? { ...project, title: renameTitle.trim() || project.title }
: project
)
);
closeRenameModal();
} catch (error) {
message.error(error instanceof Error ? error.message : "Project rename failed.");
} finally {
setActionProjectId(null);
}
};
const closeDeleteModal = () => {
setDeletePayload(null);
};
const closeCoverModal = () => {
coverProjectIdRef.current = null;
setCoverProjectId(null);
};
const handleCoverFileChange = async (file: File | undefined) => {
const selectedCoverProjectId = coverProjectIdRef.current ?? coverProjectId;
if (!file || selectedCoverProjectId === null || actionProjectId !== null) return;
if (!file.type.startsWith("image/")) {
message.error("请选择图片文件");
closeCoverModal();
return;
}
setActionProjectId(selectedCoverProjectId);
startFakeCoverProgress(selectedCoverProjectId);
try {
const coverPayload = await buildFullUpdatePayload(selectedCoverProjectId);
const uploadedCoverUrl = await uploadCanvasWorkflowCover(file);
await updateCanvasWorkflow({
...coverPayload,
coverUrl: uploadedCoverUrl,
});
message.success("封面已更新");
finishCoverProgress(selectedCoverProjectId, uploadedCoverUrl);
closeCoverModal();
} catch (error) {
stopCoverProgressTimer();
setProjectCoverProgress(selectedCoverProjectId, undefined);
message.error(error instanceof Error ? error.message : "Project cover update failed.");
} finally {
setActionProjectId(null);
if (coverInputRef.current) {
coverInputRef.current.value = "";
}
}
};
const confirmDeleteProject = async () => {
if (!deletePayload || actionProjectId !== null) return;
setActionProjectId(deletePayload.id);
try {
await deleteCanvasWorkflow(deletePayload.id);
message.success("项目已删除");
closeDeleteModal();
await loadProjects(page);
} catch (error) {
message.error(error instanceof Error ? error.message : "Project delete failed.");
} finally {
setActionProjectId(null);
}
};
return {
projects,
pageInfo,
isLoadingProjects,
isCreating,
isActionLoading: actionProjectId !== null,
renamePayload,
renameTitle,
setRenameTitle,
closeRenameModal,
confirmRenameProject,
coverInputRef,
closeCoverModal,
handleCoverFileChange,
deletePayload,
closeDeleteModal,
confirmDeleteProject,
handleStartCreation,
handleProjectAction,
goPrevPage: () => setPage((current) => Math.max(1, current - 1)),
goNextPage: () => setPage((current) => Math.min(pageInfo.pageCount, current + 1)),
};
}
function useAllProjectsState() {
const pageSize = 20;
const router = useRouter();
const coverInputRef = useRef<HTMLInputElement>(null);
const coverProjectIdRef = useRef<number | null>(null);
const coverProgressTimerRef = useRef<number | null>(null);
const loadingMorePageRef = useRef<number | null>(null);
const [projects, setProjects] = useState<ProjectData[]>([]);
const [page, setPage] = useState(1);
const [pageInfo, setPageInfo] = useState<CanvasWorkflowPageInfo>({
...DEFAULT_PROJECT_PAGE_INFO,
page: 1,
pageSize,
});
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [actionProjectId, setActionProjectId] = useState<number | null>(null);
const [renamePayload, setRenamePayload] = useState<{
id: number;
title: string;
desp: string;
properties: string;
coverUrl: string;
} | null>(null);
const [renameTitle, setRenameTitle] = useState("");
const [coverProjectId, setCoverProjectId] = useState<number | null>(null);
const [deletePayload, setDeletePayload] = useState<{
id: number;
title: string;
} | null>(null);
const stopCoverProgressTimer = useCallback(() => {
if (coverProgressTimerRef.current) {
window.clearInterval(coverProgressTimerRef.current);
coverProgressTimerRef.current = null;
}
}, []);
const setProjectCoverProgress = useCallback((id: number, progress?: number) => {
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === id ? { ...project, uploadProgress: progress } : project
)
);
}, []);
const startFakeCoverProgress = useCallback((id: number) => {
stopCoverProgressTimer();
setProjectCoverProgress(id, 1);
coverProgressTimerRef.current = window.setInterval(() => {
setProjects((currentProjects) =>
currentProjects.map((project) => {
if (project.id !== id || typeof project.uploadProgress !== "number") return project;
const nextProgress = Math.min(
90,
project.uploadProgress + Math.max(1, Math.round((90 - project.uploadProgress) * 0.18))
);
return { ...project, uploadProgress: nextProgress };
})
);
}, 300);
}, [setProjectCoverProgress, stopCoverProgressTimer]);
const finishCoverProgress = useCallback((id: number, coverUrl: string) => {
stopCoverProgressTimer();
setProjects((currentProjects) =>
currentProjects.map((project) =>
project.id === id ? { ...project, image: coverUrl, uploadProgress: 100 } : project
)
);
window.setTimeout(() => setProjectCoverProgress(id, undefined), 450);
}, [setProjectCoverProgress, stopCoverProgressTimer]);
const loadProjectsPage = useCallback(async (
nextPage: number,
options?: { append?: boolean }
) => {
setIsLoadingProjects(true);
try {
const data = await listCanvasWorkflows({ page: nextPage, pageSize });
const nextProjects = data.list
.filter((item) => !item.deleted)
.map((item) => mapWorkflowToProject(item));
setProjects((currentProjects) => {
if (!options?.append) return nextProjects;
const existingIds = new Set(currentProjects.map((project) => project.id));
const uniqueNextProjects = nextProjects.filter((project) => !existingIds.has(project.id));
return [...currentProjects, ...uniqueNextProjects];
});
setPageInfo(data.pageInfo);
setPage(nextPage);
} catch {
if (!options?.append) {
setProjects([]);
}
setPageInfo({
...DEFAULT_PROJECT_PAGE_INFO,
page: nextPage,
pageSize,
});
} finally {
setIsLoadingProjects(false);
}
}, []);
useEffect(() => {
void loadProjectsPage(1);
}, [loadProjectsPage]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -549,19 +873,14 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
if (action === "duplicate") { if (action === "duplicate") {
const detail = await getCanvasWorkflowDetail(id); const detail = await getCanvasWorkflowDetail(id);
const title = detail.title ? `${detail.title} 副本` : "未命名项目副本"; const title = detail.title ? `${detail.title} 副本` : "未命名项目副本";
const result = await createCanvasWorkflow({ await createCanvasWorkflow({
title, title,
desp: detail.desp ?? "", desp: detail.desp ?? "",
properties: detail.properties ?? "", properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "", coverUrl: detail.coverUrl ?? "",
}); });
message.success("副本已创建"); message.success("副本已创建");
await loadProjects(); await loadProjectsPage(1);
const workflowId = (result.data as any)?.id;
if (workflowId) {
router.push(`/canvas?workflowId=${workflowId}`);
}
return; return;
} }
@ -659,7 +978,7 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
await deleteCanvasWorkflow(deletePayload.id); await deleteCanvasWorkflow(deletePayload.id);
message.success("项目已删除"); message.success("项目已删除");
closeDeleteModal(); closeDeleteModal();
await loadProjects(); await loadProjectsPage(1);
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : "Project delete failed."); message.error(error instanceof Error ? error.message : "Project delete failed.");
} finally { } finally {
@ -667,6 +986,16 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
} }
}; };
const loadNextPage = useCallback(() => {
const nextPage = page + 1;
if (isLoadingProjects || nextPage > pageInfo.pageCount) return;
if (loadingMorePageRef.current === nextPage) return;
loadingMorePageRef.current = nextPage;
void loadProjectsPage(nextPage, { append: true }).finally(() => {
loadingMorePageRef.current = null;
});
}, [isLoadingProjects, loadProjectsPage, page, pageInfo.pageCount]);
return { return {
projects, projects,
pageInfo, pageInfo,
@ -686,8 +1015,7 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
confirmDeleteProject, confirmDeleteProject,
handleStartCreation, handleStartCreation,
handleProjectAction, handleProjectAction,
goPrevPage: () => setPage((current) => Math.max(1, current - 1)), loadNextPage,
goNextPage: () => setPage((current) => Math.min(pageInfo.pageCount, current + 1)),
}; };
} }
@ -779,8 +1107,10 @@ function RecentProjectsSection({
} }
function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) { function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
const [stickyTop, setStickyTop] = useState(90);
const { const {
projects, projects,
pageInfo,
isLoadingProjects, isLoadingProjects,
isCreating, isCreating,
isActionLoading, isActionLoading,
@ -797,11 +1127,46 @@ function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
confirmDeleteProject, confirmDeleteProject,
handleStartCreation, handleStartCreation,
handleProjectAction, handleProjectAction,
} = useProjectSectionState(20); loadNextPage,
} = useAllProjectsState();
const canLoadMoreProjects = pageInfo.page < pageInfo.pageCount;
useEffect(() => {
const updateStickyTop = () => {
const header = document.querySelector("header");
setStickyTop(header?.getBoundingClientRect().height ?? 0);
};
updateStickyTop();
window.addEventListener("resize", updateStickyTop);
return () => {
window.removeEventListener("resize", updateStickyTop);
};
}, []);
useEffect(() => {
const handleScroll = () => {
if (!canLoadMoreProjects || isLoadingProjects) return;
const distanceToBottom =
document.documentElement.scrollHeight - window.innerHeight - window.scrollY;
if (distanceToBottom <= ALL_PROJECTS_LOAD_MORE_OFFSET) {
loadNextPage();
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [canLoadMoreProjects, isLoadingProjects, loadNextPage]);
return ( return (
<section> <section>
<div className="mb-5 flex items-center justify-between"> <div
className="sticky z-20 -mx-5 mb-5 flex items-center justify-between border-b border-[#f0f0f0] bg-[#f9f9f9]/95 px-5 py-3 backdrop-blur"
style={{ top: stickyTop }}
>
<div className="flex items-center justify-center gap-x-1 overflow-hidden whitespace-nowrap"> <div className="flex items-center justify-center gap-x-1 overflow-hidden whitespace-nowrap">
<button <button
type="button" type="button"
@ -812,7 +1177,7 @@ function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
<span></span> <span></span>
</button> </button>
<h2 className="text-[20px] font-bold leading-none text-[#333]"> <h2 className="text-[20px] font-bold leading-none text-[#333]">
</h2> </h2>
</div> </div>
</div> </div>
@ -823,6 +1188,15 @@ function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
onStartCreation={handleStartCreation} onStartCreation={handleStartCreation}
onProjectAction={handleProjectAction} onProjectAction={handleProjectAction}
/> />
<div className="flex h-14 items-center justify-center text-[13px] font-medium text-[#999]">
{isLoadingProjects && projects.length > 0
? "加载中..."
: canLoadMoreProjects
? "继续下拉加载更多"
: projects.length > 0
? "没有更多项目"
: null}
</div>
<ProjectActionModals <ProjectActionModals
isActionLoading={isActionLoading} isActionLoading={isActionLoading}
renameOpen={!!renamePayload} renameOpen={!!renamePayload}
@ -1021,20 +1395,20 @@ export function HomeProjectsContent({
return ( return (
<div className="mx-auto w-full max-w-[1456px] px-5 pb-16 pt-[30px]"> <div className="mx-auto w-full max-w-[1456px] px-5 pb-16 pt-[30px]">
<div className={`${isShowAll ? "hidden" : ""}`}> {isShowAll ? (
{heroSection} <AllProjectsSection onBackClick={() => setIsShowAll(false)} />
) : (
<RecentProjectsSection <>
className="mt-[64px]" {heroSection}
onAllProjectsClick={() => setIsShowAll(true)}
/>
<ShowsSection /> <RecentProjectsSection
</div> className="mt-[64px]"
onAllProjectsClick={() => setIsShowAll(true)}
/>
<div className={`${!isShowAll ? "hidden" : ""}`}> <ShowsSection />
<AllProjectsSection onBackClick={() => setIsShowAll(false)} /> </>
</div> )}
</div> </div>
); );
} }

Loading…
Cancel
Save