Browse Source

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

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

50
src/components/AntdThemeProvider.tsx

@ -1,5 +1,6 @@
"use client";
import { usePathname } from "next/navigation";
import { App as AntdApp, ConfigProvider, theme, type ThemeConfig } from "antd";
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 }) {
const pathname = usePathname();
const isCanvasRoute = pathname?.startsWith("/canvas") ?? false;
const [canvasTokens, setCanvasTokens] = useState<CanvasThemeTokens>(FALLBACK_TOKENS);
useEffect(() => {
@ -79,7 +82,49 @@ export function AntdThemeProvider({ children }: { children: ReactNode }) {
return () => observer.disconnect();
}, []);
const antdTheme = useMemo<ThemeConfig>(() => ({
const antdTheme = useMemo<ThemeConfig>(() => {
if (!isCanvasRoute) {
return {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#5c3bc7",
colorInfo: "#5c3bc7",
colorBgBase: "#f9f9f9",
colorBgLayout: "#f9f9f9",
colorBgContainer: "#ffffff",
colorBgElevated: "#ffffff",
colorBgMask: "rgba(0, 0, 0, 0.45)",
colorBorder: "#e6e6e6",
colorBorderSecondary: "#f0f0f0",
colorText: "#333333",
colorTextBase: "#333333",
colorTextHeading: "#333333",
colorTextSecondary: "#666666",
colorIcon: "#999999",
colorIconHover: "#666666",
borderRadius: 8,
borderRadiusLG: 8,
wireframe: false,
},
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,
@ -119,7 +164,8 @@ export function AntdThemeProvider({ children }: { children: ReactNode }) {
colorBgSpotlight: canvasTokens.panelElevated,
},
},
}), [canvasTokens]);
};
}, [canvasTokens, isCanvasRoute]);
useEffect(() => {
ConfigProvider.config({

488
src/components/HomeProjectsContent.tsx

@ -1,13 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
ProjectActionsDropdown,
type ProjectActionKey,
} from "@/components/ProjectActionsDropdown";
import {
ArrowIcon,
CloseIcon,
LeftOutlinedIcon,
SearchIcon,
StartCreation,
@ -83,6 +90,8 @@ const DEFAULT_PROJECT_PAGE_INFO: CanvasWorkflowPageInfo = {
total: 0,
};
const ALL_PROJECTS_LOAD_MORE_OFFSET = 260;
function formatProjectTime(value?: string): string {
if (!value) return "-";
const date = new Date(value);
@ -216,7 +225,10 @@ function ProjectSectionGrid({
const router = useRouter();
const canGoPrev = !!pageInfo && pageInfo.page > 1;
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 (
<div className="relative grid gap-5 md:grid-cols-2 xl:grid-cols-5">
@ -307,46 +319,74 @@ function ProjectActionModals({
onCancelDelete: () => 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 (
<>
<Modal
title="重命名项目"
title={<span className={modalTitleClassName}></span>}
open={renameOpen}
closeIcon={<CloseIcon />}
centered
width={491}
className={modalClassName}
confirmLoading={isActionLoading}
okText="确认"
cancelText="取消"
okButtonProps={{ disabled: !renameTitle.trim() }}
cancelText="取消(esc)"
okButtonProps={{
className: confirmButtonClassName,
disabled: !renameTitle.trim(),
}}
cancelButtonProps={{ className: cancelButtonClassName }}
onOk={onConfirmRename}
onCancel={onCancelRename}
destroyOnHidden
>
<Form layout="vertical" preserve={false}>
<Form.Item label="项目名称">
<Form layout="vertical" preserve={false} className="!mt-[7px]">
<Form.Item
label={<span className="text-[14px] leading-[22px] text-[#666]"></span>}
className="!mb-0"
>
<Input
value={renameTitle}
onChange={(event) => onRenameTitleChange(event.target.value)}
placeholder="请输入项目名称"
onPressEnter={onConfirmRename}
autoFocus
className="!h-[37px] !rounded-[9px] !border-[#e6e6e6] !text-[14px] !shadow-none hover:!border-[#d6ceef] focus:!border-[#5c3bc7]"
/>
</Form.Item>
</Form>
</Modal>
<Modal
title="删除项目"
title={<span className={modalTitleClassName}></span>}
open={deleteTitle !== null}
closeIcon={<CloseIcon />}
centered
width={491}
className={modalClassName}
confirmLoading={isActionLoading}
okText="确认删除"
okButtonProps={{ danger: true }}
cancelText="取消"
okText="确认"
okButtonProps={{
danger: true,
className: deleteConfirmButtonClassName,
}}
cancelText="取消(esc)"
cancelButtonProps={{ className: cancelButtonClassName }}
onOk={onConfirmDelete}
onCancel={onCancelDelete}
destroyOnHidden
>
<p className="m-0 text-sm leading-6">
"{deleteTitle ?? ""}"
<p className="m-0 text-[14px] leading-[22px] text-[#666]">
</p>
</Modal>
</>
@ -428,18 +468,21 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
window.setTimeout(() => setProjectCoverProgress(id, undefined), 450);
}, [setProjectCoverProgress, stopCoverProgressTimer]);
const loadProjects = useCallback(async (nextPage = page) => {
const loadProjects = useCallback(async (nextPage: number, options?: { append?: boolean }) => {
setIsLoadingProjects(true);
try {
const data = await listCanvasWorkflows({ page: nextPage, pageSize });
setProjects(
data.list
const nextProjects = data.list
.filter((item) => !item.deleted)
.map((item) => mapWorkflowToProject(item))
.map((item) => mapWorkflowToProject(item));
setProjects((currentProjects) =>
options?.append ? [...currentProjects, ...nextProjects] : nextProjects
);
setPageInfo(data.pageInfo);
} catch {
if (!options?.append) {
setProjects([]);
}
setPageInfo({
...DEFAULT_PROJECT_PAGE_INFO,
page: nextPage,
@ -448,44 +491,325 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
} finally {
setIsLoadingProjects(false);
}
}, [page, pageSize]);
}, [pageSize]);
useEffect(() => {
let canceled = false;
void loadProjects(page);
return undefined;
}, [loadProjects, page]);
useEffect(() => {
return () => {
stopCoverProgressTimer();
};
}, [stopCoverProgressTimer]);
const handleStartCreation = async () => {
if (isCreating) {
message.warning("项目创建中,请稍后再试");
return;
}
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);
}
};
const buildFullUpdatePayload = async (id: number) => {
const detail = await getCanvasWorkflowDetail(id);
return {
id,
title: detail.title ?? "",
desp: detail.desp ?? "",
properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "",
};
};
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)),
};
}
async function loadPageProjects() {
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, pageSize });
if (canceled) return;
setProjects(
data.list
const data = await listCanvasWorkflows({ page: nextPage, pageSize });
const nextProjects = data.list
.filter((item) => !item.deleted)
.map((item) => mapWorkflowToProject(item))
);
.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 (!canceled) {
if (!options?.append) {
setProjects([]);
}
setPageInfo({
...DEFAULT_PROJECT_PAGE_INFO,
page,
page: nextPage,
pageSize,
});
}
} finally {
if (!canceled) {
setIsLoadingProjects(false);
}
}
}
void loadPageProjects();
}, []);
return () => {
canceled = true;
};
}, [page, pageSize]);
useEffect(() => {
void loadProjectsPage(1);
}, [loadProjectsPage]);
useEffect(() => {
return () => {
@ -549,19 +873,14 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
if (action === "duplicate") {
const detail = await getCanvasWorkflowDetail(id);
const title = detail.title ? `${detail.title} 副本` : "未命名项目副本";
const result = await createCanvasWorkflow({
await createCanvasWorkflow({
title,
desp: detail.desp ?? "",
properties: detail.properties ?? "",
coverUrl: detail.coverUrl ?? "",
});
message.success("副本已创建");
await loadProjects();
const workflowId = (result.data as any)?.id;
if (workflowId) {
router.push(`/canvas?workflowId=${workflowId}`);
}
await loadProjectsPage(1);
return;
}
@ -659,7 +978,7 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
await deleteCanvasWorkflow(deletePayload.id);
message.success("项目已删除");
closeDeleteModal();
await loadProjects();
await loadProjectsPage(1);
} catch (error) {
message.error(error instanceof Error ? error.message : "Project delete failed.");
} 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 {
projects,
pageInfo,
@ -686,8 +1015,7 @@ function useProjectSectionState(pageSize: number, initialPage = 1) {
confirmDeleteProject,
handleStartCreation,
handleProjectAction,
goPrevPage: () => setPage((current) => Math.max(1, current - 1)),
goNextPage: () => setPage((current) => Math.min(pageInfo.pageCount, current + 1)),
loadNextPage,
};
}
@ -779,8 +1107,10 @@ function RecentProjectsSection({
}
function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
const [stickyTop, setStickyTop] = useState(90);
const {
projects,
pageInfo,
isLoadingProjects,
isCreating,
isActionLoading,
@ -797,11 +1127,46 @@ function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
confirmDeleteProject,
handleStartCreation,
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 (
<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">
<button
type="button"
@ -812,7 +1177,7 @@ function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
<span></span>
</button>
<h2 className="text-[20px] font-bold leading-none text-[#333]">
</h2>
</div>
</div>
@ -823,6 +1188,15 @@ function AllProjectsSection({ onBackClick }: { onBackClick: () => void }) {
onStartCreation={handleStartCreation}
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
isActionLoading={isActionLoading}
renameOpen={!!renamePayload}
@ -1021,7 +1395,10 @@ export function HomeProjectsContent({
return (
<div className="mx-auto w-full max-w-[1456px] px-5 pb-16 pt-[30px]">
<div className={`${isShowAll ? "hidden" : ""}`}>
{isShowAll ? (
<AllProjectsSection onBackClick={() => setIsShowAll(false)} />
) : (
<>
{heroSection}
<RecentProjectsSection
@ -1030,11 +1407,8 @@ export function HomeProjectsContent({
/>
<ShowsSection />
</div>
<div className={`${!isShowAll ? "hidden" : ""}`}>
<AllProjectsSection onBackClick={() => setIsShowAll(false)} />
</div>
</>
)}
</div>
);
}

Loading…
Cancel
Save