10 changed files with 738 additions and 215 deletions
|
After Width: | Height: | Size: 2.1 KiB |
@ -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", |
|||
}, |
|||
}); |
|||
} |
|||
@ -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", |
|||
}, |
|||
}); |
|||
} |
|||
@ -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", |
|||
}, |
|||
}); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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…
Reference in new issue