Browse Source

Merge branch 'feature/interaction' of https://git.yuanzoo.cn/weitao/popiart-node-canvas into feature/interaction

feature/group
TianYun 1 month ago
parent
commit
873e55df11
  1. BIN
      public/empty_project.png
  2. 41
      src/app/api/canvas-templates/categories/route.ts
  3. 51
      src/app/api/canvas-templates/route.ts
  4. 42
      src/app/api/recommend-banners/route.ts
  5. 2
      src/app/layout.tsx
  6. 305
      src/app/page.tsx
  7. 480
      src/components/HomeProjectsContent.tsx
  8. 14
      src/components/icons/index.tsx
  9. 6
      src/lib/canvasWorkflowApi.ts
  10. 12
      src/utils/getToken.ts

BIN
public/empty_project.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

41
src/app/api/canvas-templates/categories/route.ts

@ -0,0 +1,41 @@
import { requireLogin } from "@/app/api/_auth";
import { NextRequest, NextResponse } from "next/server";
const UPSTREAM_CATEGORY_LIST_PATH = "/api_client/canvas/template/category/list";
function getPopiBaseUrl() {
return process.env.POPIART_API_BASE_URL?.replace(/\/+$/, "") || "";
}
export async function GET(request: NextRequest) {
const baseUrl = getPopiBaseUrl();
if (!baseUrl) {
return NextResponse.json(
{ status: "5000", message: "POPIART_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const upstreamUrl = new URL(UPSTREAM_CATEGORY_LIST_PATH, baseUrl);
upstreamUrl.searchParams.set(
"keyword",
request.nextUrl.searchParams.get("keyword") || "",
);
const { token } = requireLogin(request);
const response = await fetch(upstreamUrl, {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
},
});
const body = await response.text();
return new NextResponse(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("Content-Type") || "application/json",
},
});
}

51
src/app/api/canvas-templates/route.ts

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
const UPSTREAM_TEMPLATE_LIST_PATH = "/api_client/canvas/template/list";
import { requireLogin } from "@/app/api/_auth";
function getPopiBaseUrl() {
return process.env.POPIART_API_BASE_URL?.replace(/\/+$/, "") || "";
}
export async function GET(request: NextRequest) {
const baseUrl = getPopiBaseUrl();
if (!baseUrl) {
return NextResponse.json(
{ status: "5000", message: "POPIART_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const upstreamUrl = new URL(UPSTREAM_TEMPLATE_LIST_PATH, baseUrl);
upstreamUrl.searchParams.set(
"keyword",
request.nextUrl.searchParams.get("keyword") || "",
);
upstreamUrl.searchParams.set(
"categoryId",
request.nextUrl.searchParams.get("categoryId") || "",
);
upstreamUrl.searchParams.set(
"type",
request.nextUrl.searchParams.get("type") || "",
);
upstreamUrl.searchParams.set(
"userId",
request.nextUrl.searchParams.get("userId") || "",
);
const { token } = requireLogin(request); // Ensure the user is logged in before making the upstream request
const response = await fetch(upstreamUrl, {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
},
});
const body = await response.text();
return new NextResponse(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("Content-Type") || "application/json",
},
});
}

42
src/app/api/recommend-banners/route.ts

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { requireLogin } from "@/app/api/_auth";
const UPSTREAM_RECOMMEND_BANNER_LIST_PATH =
"/api_client/anime/recommendBanner/list";
function getPopiBaseUrl() {
return process.env.POPIART_API_BASE_URL?.replace(/\/+$/, "") || "";
}
export async function GET(request: NextRequest) {
const baseUrl = getPopiBaseUrl();
console.log("request", request);
if (!baseUrl) {
return NextResponse.json(
{ status: "5000", message: "POPIART_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { token } = requireLogin(request);
console.log("token", token);
const upstreamUrl = new URL(UPSTREAM_RECOMMEND_BANNER_LIST_PATH, baseUrl);
upstreamUrl.searchParams.set(
"categoryCode",
request.nextUrl.searchParams.get("categoryCode") || "",
);
const response = await fetch(upstreamUrl, {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
},
});
const body = await response.text();
console.log("Upstream response body:", body);
return new NextResponse(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("Content-Type") || "application/json",
},
});
}

2
src/app/layout.tsx

@ -16,7 +16,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body className="antialiased">
<AntdRegistry>
<AntdThemeProvider>

305
src/app/page.tsx

@ -1,15 +1,14 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { ProjectActionsDropdown } from "@/components/ProjectActionsDropdown";
import { useEffect, useState, type ReactNode } from "react";
import { HomeProjectsContent } from "@/components/HomeProjectsContent";
import { authFetch } from "@/utils/authFetch";
import {
HelpIcon,
GlobeIcon,
CloseIcon,
SearchIcon,
ArrowIcon,
WeChatIcon,
SparklingIcon,
StartCreation
} from "@/components/icons";
const assets = {
@ -45,67 +44,68 @@ const text = {
help: "帮助",
language: "语言",
search: "搜索",
moreActions: "更多操作",
};
const heroCards = [
{ title: "Seedance 2.0", subtitle: "火热上线", image: assets.heroSeedance },
{
title: "做一条VLOG分享生活",
subtitle: "精选案例账号",
image: assets.heroVlog,
},
{
title: "Popi.TV 测试上线",
subtitle: "帮助人类更好的表达",
image: assets.heroPopi,
},
];
type HeroCardData = {
title: string;
subtitle: string;
image: string;
};
type RecommendBannerItem = {
id: number;
cover: string;
title: string;
prompt: string;
sort: number;
status: number;
deleted: boolean;
};
type RecommendBannerListResponse = {
data?: {
list?: RecommendBannerItem[];
};
status?: string;
};
function isVideoUrl(url: string) {
try {
const pathname = new URL(url, "http://localhost").pathname.toLowerCase();
return /\.(mp4|webm|ogg|ogv|mov|m4v)$/i.test(pathname);
} catch {
return /\.(mp4|webm|ogg|ogv|mov|m4v)(\?|#|$)/i.test(url.toLowerCase());
}
}
async function getHeroCards(): Promise<HeroCardData[]> {
try {
const params = new URLSearchParams({ categoryCode: "" });
const response = await authFetch(`/api/recommend-banners?${params.toString()}`, {
method: "GET",
});
if (!response.ok) return [];
const projects = [
{ title: "爱丽丝初稿", date: "2026/06/01", image: assets.projectAlice },
{ title: "喝咖啡Vlog", date: "2026/05/30", image: assets.projectCoffee },
{ title: "赏樱花测试", date: "2026/05/29", image: assets.projectSakura },
{ title: "未命名", date: "2026/05/28", image: assets.projectUntitled },
];
const result = (await response.json()) as RecommendBannerListResponse;
const list = result.data?.list;
const tabs = [
"全部",
"精选画布",
"生活Vlog",
"短剧漫剧",
"专业影视",
"商业广告",
];
if (!Array.isArray(list) || list.length === 0) return [];
const shows = [
{
title: "PopiTV",
subtitle: "和爱丽丝一起郊游-Vlog",
image: assets.showPopi,
avatar: "👾",
vip: true,
},
{
title: "爱丽丝大神",
subtitle: "和爱丽丝一起郊游-Vlog",
image: assets.showAlice,
avatar: "🌸",
vip: true,
},
{
title: "叮叮频道",
subtitle: "和爱丽丝一起郊游-Vlog",
image: assets.showDing,
avatar: "🌊",
},
{
title: "Aido",
subtitle: "和爱丽丝一起郊游-Vlog",
image: assets.showAido,
avatar: "🌿",
},
];
const cards = list
.filter((item) => item.status === 1 && !item.deleted)
.sort((a, b) => a.sort - b.sort)
.slice(0, 3)
.map((item, index) => ({
title: item.title || "",
subtitle: item.prompt || "",
image: item.cover || "",
}));
return cards;
} catch {
return [];
}
}
function DiamondLogo({ label }: { label: string }) {
return (
@ -237,13 +237,26 @@ function HeroCard({
subtitle: string;
image: string;
}) {
const isVideo = isVideoUrl(image);
return (
<article className="group relative h-[277px] overflow-hidden rounded-[24px] bg-[#ddd]">
<img
src={image}
alt=""
className="h-full w-full object-cover transition duration-500 group-hover:scale-105"
/>
{isVideo ? (
<video
src={image}
className="h-full w-full object-cover transition duration-500 group-hover:scale-105"
autoPlay
muted
loop
playsInline
/>
) : (
<img
src={image}
alt=""
className="h-full w-full object-cover transition duration-500 group-hover:scale-105"
/>
)}
<div className="absolute inset-0 bg-gradient-to-b from-transparent from-[56%] to-black/70" />
<div className="absolute bottom-[30px] left-7 text-white">
<h2 className="text-[30px] font-bold leading-none tracking-[0]">
@ -255,160 +268,24 @@ function HeroCard({
);
}
function ProjectCard({
title,
date,
image,
}: {
title: string;
date: string;
image: string;
}) {
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>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-[16px] font-medium leading-tight text-[#333]">
{title}
</h3>
<p className="mt-1 text-[13px] leading-tight text-[#666]">{date}</p>
</div>
<ProjectActionsDropdown title={title} label={text.moreActions} />
</div>
</article>
);
}
export default function Home() {
const [heroCards, setHeroCards] = useState<HeroCardData[]>([]);
useEffect(() => {
getHeroCards().then(setHeroCards);
}, []);
function ShowCard({
title,
subtitle,
image,
avatar,
vip,
}: {
title: string;
subtitle: string;
image: string;
avatar: string;
vip?: boolean;
}) {
return (
<article className="min-w-0">
<div className="h-[189px] overflow-hidden rounded-[24px] bg-[#ddd]">
<img src={image} 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>
const heroSection = (
<section className="grid gap-5 lg:grid-cols-3">
{heroCards.map((card) => (
<HeroCard key={card.title} {...card} />
))}
</section>
);
}
export default function Home() {
return (
<main className="min-h-screen bg-[#f9f9f9] text-[#333]">
<Header />
<div className="mx-auto w-full max-w-[1456px] px-5 pb-16">
<section className="grid gap-5 lg:grid-cols-3">
{heroCards.map((card) => (
<HeroCard key={card.title} {...card} />
))}
</section>
<section className="mt-[70px]">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-[20px] font-bold leading-none text-[#333]">
</h2>
<Link
href="/canvas"
className="flex items-center gap-1 text-[16px] leading-none !text-[#666] hover:text-[#5c3bc7]"
>
<ArrowIcon />
</Link>
</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]"
>
<StartCreation />
<span className="mt-2 text-[18px] font-medium leading-none text-[#999]">
</span>
</Link>
{projects.map((project) => (
<ProjectCard key={project.title} {...project} />
))}
<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>
<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]">
{tabs.map((tab, index) => (
<button
key={tab}
type="button"
className={
index === 0
? "rounded-[9px] bg-[#f0f0f0] px-[11px] py-[5px] font-medium text-[#333]"
: "rounded-[9px] bg-[#f9f9f9] px-[15px] py-[5px] text-[#666] transition hover:bg-[#f0f0f0] hover:text-[#333]"
}
>
{tab}
</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
aria-label={text.search}
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">
{shows.map((show) => (
<ShowCard key={show.title} {...show} />
))}
</div>
</section>
</div>
<HomeProjectsContent heroSection={heroSection} />
</main>
);
}

480
src/components/HomeProjectsContent.tsx

@ -0,0 +1,480 @@
"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>
);
}

14
src/components/icons/index.tsx

@ -167,3 +167,17 @@ export const StartCreation = () => (
<path d="M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z"></path>
</svg>
);
export const LeftOutlinedIcon = () => (
<svg
viewBox="64 64 896 896"
focusable="false"
data-icon="left"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
>
<path d="M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 000 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z"></path>
</svg>
);

6
src/lib/canvasWorkflowApi.ts

@ -23,13 +23,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 {

12
src/utils/getToken.ts

@ -0,0 +1,12 @@
import { NextRequest } from "next/server";
export const getToken = (request: NextRequest): string | null => {
const tokenHeader = request.headers.get("token");
if (tokenHeader?.trim()) {
return tokenHeader;
}
const authorization = request.headers.get("authorization");
const bearerToken = authorization?.match(/^Bearer\s+(.+)$/i)?.[1];
return bearerToken?.trim() || null;
};
Loading…
Cancel
Save