You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

480 lines
14 KiB

"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { ProjectActionsDropdown } from "@/components/ProjectActionsDropdown";
import {
ArrowIcon,
LeftOutlinedIcon,
SearchIcon,
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;
};
type ShowData = {
title: string;
subtitle: string;
image: string;
avatar: string;
vip?: boolean;
data: string;
};
type TemplateCategoryData = {
id: number | null;
name: string;
};
type CanvasTemplateCategoryItem = {
id: number;
name: string;
sort: number;
deleted: boolean;
};
type CanvasTemplateItem = {
id: number;
categoryId: number;
type: number;
coverUrl: string;
properties: string;
name: string;
desp: string;
deleted: boolean;
categoryName: string;
};
type ListResponse<T> = {
data?: {
list?: T[];
};
status?: string;
};
type HomeProjectsContentProps = {
heroSection: ReactNode;
};
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">
<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={"更多操作"} />
</div>
</article>
);
}
function ShowCard({ title, subtitle, image, avatar, vip, data, onOpen }: ShowData & { onOpen: (data: ShowData) => void }) {
return (
<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 || '/empty_project.png'} alt="" className="h-full w-full object-cover" />
</div>
<div className="mt-3">
<div className="flex items-center gap-[5px]">
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#d6ceef] text-[11px]">
{avatar}
</span>
<h3 className="truncate text-[16px] font-medium leading-tight text-[#333]">
{title}
</h3>
{vip ? (
<span className="rounded-full bg-[linear-gradient(90deg,#d6ceef_45%,#f0f0f0_100%)] px-2.5 py-0.5 text-[12px] font-medium leading-none text-[#5c3bc7]">
VIP
</span>
) : null}
</div>
<p className="mt-2 truncate text-[13px] leading-tight text-[#666]">
{subtitle}
</p>
</div>
</article>
);
}
function ProjectsSection({
className = "",
onAllProjectsClick,
onBackClick,
}: {
className?: string;
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">
<div className="flex items-center justify-center gap-x-1 overflow-hidden whitespace-nowrap">
{onBackClick && (
<div
onClick={onBackClick}
className="flex h-8 items-center justify-center gap-1 rounded-[8px] px-2 text-[16px] leading-none text-[#666] transition hover:bg-[#f0f0f0] hover:text-[#333]"
>
<LeftOutlinedIcon />
<span></span>
</div>
)}
<h2 className="text-[20px] font-bold leading-none text-[#333]">
</h2>
</div>
{!!onAllProjectsClick && (
<button
type="button"
onClick={onAllProjectsClick}
className="flex h-8 cursor-pointer items-center justify-center gap-1 rounded-[8px] px-2 text-[16px] leading-none !text-[#666] transition hover:bg-[#f0f0f0] hover:text-[#333]"
>
<span></span>
<ArrowIcon />
</button>
)}
</div>
<div className="relative grid gap-5 md:grid-cols-2 xl:grid-cols-5">
<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>
</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.id}
{...project}
onOpen={() => router.push(`/canvas?workflowId=${project.id}`)}
/>
))}
{onAllProjectsClick && projects.length === 4 && (
<>
<div className="pointer-events-none absolute bottom-0 right-0 hidden h-[139px] w-[105px] bg-gradient-to-r from-[#f9f9f9]/0 to-[#f9f9f9] xl:block" />
<button
type="button"
aria-label="下一个项目"
title="下一个项目"
className="absolute right-[-25px] top-[52px] hidden h-[39px] w-[39px] items-center justify-center rounded-full bg-white text-[#666] shadow-[0_2px_5px_rgba(0,0,0,0.1)] xl:flex"
>
<ArrowIcon />
</button>
</>
)}
</div>
</section>
);
}
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 || '',
};
}
function ShowsSection() {
const [activeTab, setActiveTab] = useState(0);
const [categoryList, setCategoryList] = useState<TemplateCategoryData[]>([
{ id: null, name: "全部" },
]);
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;
async function loadCategories() {
try {
const params = new URLSearchParams({ keyword: "" });
const response = await authFetch(`/api/canvas-templates/categories?${params.toString()}`);
if (!response.ok) return;
const result = (await response.json()) as ListResponse<CanvasTemplateCategoryItem>;
const list = result.data?.list;
if (!Array.isArray(list)) return;
const categories = list
.filter((item) => !item.deleted)
.sort((a, b) => a.sort - b.sort)
.map((item) => ({ id: item.id, name: item.name }))
.filter((item) => item.name);
if (!canceled && categories.length > 0) {
setCategoryList([{ id: null, name: "全部" }, ...categories]);
}
} catch {
// Keep fallback category.
}
}
loadCategories();
return () => {
canceled = true;
};
}, []);
useEffect(() => {
const controller = new AbortController();
const timeout = window.setTimeout(async () => {
try {
const params = new URLSearchParams({
keyword: searchQuery,
categoryId: selectedCategoryId ? String(selectedCategoryId) : "",
type: "",
userId: "",
});
const response = await authFetch(`/api/canvas-templates?${params.toString()}`, {
signal: controller.signal,
});
if (!response.ok) {
setDataList([]);
return;
}
const result = (await response.json()) as ListResponse<CanvasTemplateItem>;
const list = result.data?.list;
if (!Array.isArray(list)) {
setDataList([]);
return;
}
setDataList(
list
.filter((item) => !item.deleted)
.map((item) => mapTemplateToShow(item))
);
} catch {
if (!controller.signal.aborted) {
setDataList([]);
}
}
}, 250);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [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">
<div>
<h2 className="text-[20px] font-bold leading-none text-[#333]">
TV show
</h2>
<div className="mt-5 flex flex-wrap items-center gap-1 text-[16px]">
{categoryList.map((tab, index) => (
<button
key={`${tab.id ?? "all"}-${tab.name}`}
type="button"
onClick={() => setActiveTab(index)}
className={
index === activeTab
? "min-w-[54px] rounded-[9px] bg-[#f0f0f0] px-[15px] py-[5px] text-center text-[#333] transition hover:bg-[#f0f0f0]"
: "min-w-[54px] rounded-[9px] bg-[#f9f9f9] px-[15px] py-[5px] text-center text-[#666] transition hover:bg-[#f0f0f0] hover:text-[#333]"
}
>
{tab.name}
</button>
))}
</div>
</div>
<div className="flex h-[35px] w-full items-center justify-between rounded-full bg-white px-[15px] text-[#999] shadow-[0_2px_5px_rgba(0,0,0,0.1)] lg:w-[295px]">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="请输入搜索的内容"
className="min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-[#999]"
/>
<SearchIcon />
</div>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{dataList.map((show) => (
<ShowCard key={show.title} {...show} onOpen={onOpen} />
))}
</div>
</section>
);
}
export function HomeProjectsContent({
heroSection,
}: HomeProjectsContentProps) {
const [isShowAll, setIsShowAll] = useState(false);
return (
<div className="mx-auto w-full max-w-[1456px] px-5 pb-16 pt-[30px]">
<div className={`${isShowAll ? "hidden" : ""}`}>
{heroSection}
<ProjectsSection
className="mt-[64px]"
onAllProjectsClick={() => setIsShowAll(true)}
/>
<ShowsSection />
</div>
<div className={`${!isShowAll ? "hidden" : ""}`}>
<ProjectsSection
onBackClick={() => setIsShowAll(false)}
/>
</div>
</div>
);
}