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. 173
      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

173
src/components/HomeProjectsContent.tsx

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useEffect, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { ProjectActionsDropdown } from "@/components/ProjectActionsDropdown";
import {
ArrowIcon,
@ -10,8 +10,14 @@ import {
StartCreation,
} from "@/components/icons";
import { authFetch } from "@/utils/authFetch";
import {
createCanvasWorkflow,
listCanvasWorkflows,
type CanvasWorkflowListItem,
} from "@/lib/canvasWorkflowApi";
import { message } from 'antd';
type ProjectData = {
id: number;
title: string;
date: string;
image: string;
@ -23,6 +29,7 @@ type ShowData = {
image: string;
avatar: string;
vip?: boolean;
data: string;
};
type TemplateCategoryData = {
@ -42,6 +49,7 @@ type CanvasTemplateItem = {
categoryId: number;
type: number;
coverUrl: string;
properties: string;
name: string;
desp: string;
deleted: boolean;
@ -59,17 +67,53 @@ type HomeProjectsContentProps = {
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 (
<article className="min-w-0">
<div className="h-[139px] overflow-hidden rounded-[24px] bg-[#ddd]">
<img src={image} alt="" className="h-full w-full object-cover" />
</div>
<button
type="button"
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="min-w-0">
<button type="button" onClick={onOpen} className="block max-w-full text-left">
<h3 className="truncate text-[16px] font-medium leading-tight text-[#333]">
{title}
</h3>
</button>
<p className="mt-1 text-[13px] leading-tight text-[#666]">{date}</p>
</div>
<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 (
<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]">
<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 className="mt-3">
<div className="flex items-center gap-[5px]">
@ -115,7 +159,67 @@ function ProjectsSection({
onBackClick?: () => void;
onAllProjectsClick?: () => void;
}) {
const router = useRouter();
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 (
<section className={`${className}`}>
<div className="mb-5 flex items-center justify-between">
@ -145,17 +249,28 @@ function ProjectsSection({
)}
</div>
<div className="relative grid gap-5 md:grid-cols-2 xl:grid-cols-5">
<Link
href="/canvas"
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]"
<button
type="button"
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 />
<span className="mt-2 text-[18px] font-medium leading-none text-[#999]">
</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) => (
<ProjectCard key={project.title} {...project} />
<ProjectCard
key={project.id}
{...project}
onOpen={() => router.push(`/canvas?workflowId=${project.id}`)}
/>
))}
{onAllProjectsClick && projects.length === 4 && (
<>
@ -176,13 +291,14 @@ function ProjectsSection({
}
function mapTemplateToShow(item: CanvasTemplateItem, index: number): ShowData {
function mapTemplateToShow(item: CanvasTemplateItem): ShowData {
return {
title: item.name || "未命名",
subtitle: item.desp || item.categoryName || "画布模板",
image: item.coverUrl || '',
avatar: item.categoryName?.slice(0, 1) || item.name?.slice(0, 1) || "画",
vip: item.type === 1,
data: item.properties || '',
};
}
@ -194,6 +310,9 @@ function ShowsSection() {
const [dataList, setDataList] = useState<ShowData[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const selectedCategoryId = categoryList[activeTab]?.id ?? null;
const [isCreating, setIsCreating] = useState(false);
const router = useRouter();
useEffect(() => {
let canceled = false;
@ -257,7 +376,7 @@ function ShowsSection() {
setDataList(
list
.filter((item) => !item.deleted)
.map((item, index) => mapTemplateToShow(item, index))
.map((item) => mapTemplateToShow(item))
);
} catch {
if (!controller.signal.aborted) {
@ -272,6 +391,24 @@ function ShowsSection() {
};
}, [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 (
<section className="mt-[64px]">
<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 className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{dataList.map((show) => (
<ShowCard key={show.title} {...show} />
<ShowCard key={show.title} {...show} onOpen={onOpen} />
))}
</div>
</section>

6
src/lib/canvasWorkflowApi.ts

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

Loading…
Cancel
Save