Browse Source

feat: 实现项目列表展示与新建画布工作流功能

1. 扩展CanvasWorkflowListItem接口,新增多个业务字段
2. 重构HomeProjectsContent组件,接入后端API加载项目列表
3. 新增项目创建逻辑,支持从空白和模板快速创建画布项目
4. 优化项目卡片样式,添加点击跳转和空状态占位图
5. 修复模板映射逻辑,完善加载状态与错误处理
feature/group
huangmin 1 month ago
parent
commit
6ac9507da6
  1. BIN
      public/empty_project.png
  2. 179
      src/components/HomeProjectsContent.tsx
  3. 6
      src/lib/canvasWorkflowApi.ts

BIN
public/empty_project.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

179
src/components/HomeProjectsContent.tsx

@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import { useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react"; import { useCallback, useEffect, useState, type ReactNode } from "react";
import { ProjectActionsDropdown } from "@/components/ProjectActionsDropdown"; import { ProjectActionsDropdown } from "@/components/ProjectActionsDropdown";
import { import {
ArrowIcon, ArrowIcon,
@ -10,8 +10,14 @@ import {
StartCreation, StartCreation,
} from "@/components/icons"; } from "@/components/icons";
import { authFetch } from "@/utils/authFetch"; import { authFetch } from "@/utils/authFetch";
import {
createCanvasWorkflow,
listCanvasWorkflows,
type CanvasWorkflowListItem,
} from "@/lib/canvasWorkflowApi";
import { message } from 'antd';
type ProjectData = { type ProjectData = {
id: number;
title: string; title: string;
date: string; date: string;
image: string; image: string;
@ -23,6 +29,7 @@ type ShowData = {
image: string; image: string;
avatar: string; avatar: string;
vip?: boolean; vip?: boolean;
data: string;
}; };
type TemplateCategoryData = { type TemplateCategoryData = {
@ -42,6 +49,7 @@ type CanvasTemplateItem = {
categoryId: number; categoryId: number;
type: number; type: number;
coverUrl: string; coverUrl: string;
properties: string;
name: string; name: string;
desp: string; desp: string;
deleted: boolean; deleted: boolean;
@ -59,17 +67,53 @@ type HomeProjectsContentProps = {
heroSection: ReactNode; heroSection: ReactNode;
}; };
function ProjectCard({ title, date, image }: ProjectData) { function formatProjectTime(value?: string): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
}
function mapWorkflowToProject(item: CanvasWorkflowListItem): ProjectData {
return {
id: item.id,
title: item.title?.trim() || item.code?.trim() || `#${item.id}`,
date: formatProjectTime(item.updateTime || item.createTime),
image: "",
};
}
function buildEmptyWorkflowProperties(title: string): string {
return JSON.stringify({
name: title,
nodes: [],
edges: [],
edgeStyle: "curved",
});
}
function ProjectCard({
title,
date,
image,
onOpen,
}: ProjectData & { onOpen: () => void }) {
return ( return (
<article className="min-w-0"> <article className="min-w-0">
<div className="h-[139px] overflow-hidden rounded-[24px] bg-[#ddd]"> <button
<img src={image} alt="" className="h-full w-full object-cover" /> type="button"
</div> onClick={onOpen}
className="block h-[139px] w-full overflow-hidden rounded-[24px] bg-[#ddd] text-left"
>
<img src={image || '/empty_project.png'} alt="" className="h-full w-full object-cover" />
</button>
<div className="mt-3 flex items-start justify-between gap-3"> <div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="truncate text-[16px] font-medium leading-tight text-[#333]"> <button type="button" onClick={onOpen} className="block max-w-full text-left">
{title} <h3 className="truncate text-[16px] font-medium leading-tight text-[#333]">
</h3> {title}
</h3>
</button>
<p className="mt-1 text-[13px] leading-tight text-[#666]">{date}</p> <p className="mt-1 text-[13px] leading-tight text-[#666]">{date}</p>
</div> </div>
<ProjectActionsDropdown title={title} label={"更多操作"} /> <ProjectActionsDropdown title={title} label={"更多操作"} />
@ -78,11 +122,11 @@ function ProjectCard({ title, date, image }: ProjectData) {
); );
} }
function ShowCard({ title, subtitle, image, avatar, vip }: ShowData) { function ShowCard({ title, subtitle, image, avatar, vip, data, onOpen }: ShowData & { onOpen: (data: ShowData) => void }) {
return ( return (
<article className="min-w-0"> <article className="min-w-0" onClick={() => onOpen({ title, subtitle, image, avatar, vip, data })}>
<div className="h-[189px] overflow-hidden rounded-[24px] bg-[#ddd]"> <div className="h-[189px] overflow-hidden rounded-[24px] bg-[#ddd]">
<img src={image} alt="" className="h-full w-full object-cover" /> <img src={image || '/empty_project.png'} alt="" className="h-full w-full object-cover" />
</div> </div>
<div className="mt-3"> <div className="mt-3">
<div className="flex items-center gap-[5px]"> <div className="flex items-center gap-[5px]">
@ -115,7 +159,67 @@ function ProjectsSection({
onBackClick?: () => void; onBackClick?: () => void;
onAllProjectsClick?: () => void; onAllProjectsClick?: () => void;
}) { }) {
const router = useRouter();
const [projects, setProjects] = useState<ProjectData[]>([]); const [projects, setProjects] = useState<ProjectData[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const pageSize = onAllProjectsClick ? 4 : 20;
useEffect(() => {
let canceled = false;
async function loadProjects() {
setIsLoadingProjects(true);
try {
const data = await listCanvasWorkflows({ page: 1, pageSize });
if (canceled) return;
setProjects(
data.list
.filter((item) => !item.deleted)
.map((item) => mapWorkflowToProject(item))
);
} catch {
if (!canceled) {
setProjects([]);
}
} finally {
if (!canceled) {
setIsLoadingProjects(false);
}
}
}
void loadProjects();
return () => {
canceled = true;
};
}, [pageSize]);
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);
}
};
return ( return (
<section className={`${className}`}> <section className={`${className}`}>
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
@ -145,17 +249,28 @@ function ProjectsSection({
)} )}
</div> </div>
<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">
<Link <button
href="/canvas" type="button"
className="flex h-[139px] flex-col items-center justify-center rounded-[24px] border border-[#d6ceef] bg-[linear-gradient(123deg,#f0f0f0_20%,#d6ceef_100%)] transition hover:border-[#5c3bc7]" onClick={handleStartCreation}
disabled={isCreating}
className="flex h-[139px] flex-col items-center justify-center rounded-[24px] border border-[#d6ceef] bg-[linear-gradient(123deg,#f0f0f0_20%,#d6ceef_100%)] transition hover:border-[#5c3bc7] disabled:cursor-not-allowed disabled:opacity-70"
> >
<StartCreation /> <StartCreation />
<span className="mt-2 text-[18px] font-medium leading-none text-[#999]"> <span className="mt-2 text-[18px] font-medium leading-none text-[#999]">
</span> </span>
</Link> </button>
{isLoadingProjects && projects.length === 0 ? (
<div className="flex h-[139px] items-center justify-center rounded-[24px] bg-white text-[14px] text-[#999]">
Loading...
</div>
) : null}
{projects.map((project) => ( {projects.map((project) => (
<ProjectCard key={project.title} {...project} /> <ProjectCard
key={project.id}
{...project}
onOpen={() => router.push(`/canvas?workflowId=${project.id}`)}
/>
))} ))}
{onAllProjectsClick && projects.length === 4 && ( {onAllProjectsClick && projects.length === 4 && (
<> <>
@ -176,13 +291,14 @@ function ProjectsSection({
} }
function mapTemplateToShow(item: CanvasTemplateItem, index: number): ShowData { function mapTemplateToShow(item: CanvasTemplateItem): ShowData {
return { return {
title: item.name || "未命名", title: item.name || "未命名",
subtitle: item.desp || item.categoryName || "画布模板", subtitle: item.desp || item.categoryName || "画布模板",
image: item.coverUrl || '', image: item.coverUrl || '',
avatar: item.categoryName?.slice(0, 1) || item.name?.slice(0, 1) || "画", avatar: item.categoryName?.slice(0, 1) || item.name?.slice(0, 1) || "画",
vip: item.type === 1, vip: item.type === 1,
data: item.properties || '',
}; };
} }
@ -194,6 +310,9 @@ function ShowsSection() {
const [dataList, setDataList] = useState<ShowData[]>([]); const [dataList, setDataList] = useState<ShowData[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const selectedCategoryId = categoryList[activeTab]?.id ?? null; const selectedCategoryId = categoryList[activeTab]?.id ?? null;
const [isCreating, setIsCreating] = useState(false);
const router = useRouter();
useEffect(() => { useEffect(() => {
let canceled = false; let canceled = false;
@ -257,7 +376,7 @@ function ShowsSection() {
setDataList( setDataList(
list list
.filter((item) => !item.deleted) .filter((item) => !item.deleted)
.map((item, index) => mapTemplateToShow(item, index)) .map((item) => mapTemplateToShow(item))
); );
} catch { } catch {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@ -272,6 +391,24 @@ function ShowsSection() {
}; };
}, [searchQuery, selectedCategoryId]); }, [searchQuery, selectedCategoryId]);
const onOpen = useCallback(async (data: ShowData) => {
if (isCreating) return;
setIsCreating(true);
const title = data.title || "未命名项目";
try {
const result = await createCanvasWorkflow({
title,
desp: data.subtitle || "",
properties: data.data || buildEmptyWorkflowProperties(title),
});
const workflowId = (result.data as any)?.id
router.push(workflowId ? `/canvas?workflowId=${workflowId}` : "/canvas");
} catch (error) {
window.alert(error instanceof Error ? error.message : "Failed to create workflow.");
setIsCreating(false);
}
}, []);
return ( return (
<section className="mt-[64px]"> <section className="mt-[64px]">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
@ -308,7 +445,7 @@ function ShowsSection() {
</div> </div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{dataList.map((show) => ( {dataList.map((show) => (
<ShowCard key={show.title} {...show} /> <ShowCard key={show.title} {...show} onOpen={onOpen} />
))} ))}
</div> </div>
</section> </section>

6
src/lib/canvasWorkflowApi.ts

@ -19,13 +19,19 @@ export interface CreateCanvasWorkflowResponse {
export interface CanvasWorkflowListItem { export interface CanvasWorkflowListItem {
id: number; id: number;
userId?: number;
gatewayUserId?: number;
title?: string; title?: string;
desp?: string; desp?: string;
properties?: string; properties?: string;
code?: string; code?: string;
apikey?: string;
apikeyId?: number;
status?: number; status?: number;
createTime?: string; createTime?: string;
updateTime?: string; updateTime?: string;
deleted?: boolean;
deleteTime?: string | null;
} }
export interface CanvasWorkflowPageInfo { export interface CanvasWorkflowPageInfo {

Loading…
Cancel
Save