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
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>
|
|
);
|
|
}
|
|
|