feat: 支持创作入口公告配置

This commit is contained in:
2026-06-03 03:31:45 +08:00
parent 1cb11bc1dd
commit 70ff18ad90
52 changed files with 3045 additions and 504 deletions

View File

@@ -1,64 +1,156 @@
import { Coins, Trophy } from 'lucide-react';
import { type UIEvent,useMemo, useState } from 'react';
import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type {
CreationEntryConfig,
CreationEntryEventBannerConfig,
} from '../../services/creationEntryConfigService';
import {
groupVisiblePlatformCreationTypes,
type PlatformCreationTypeCard,
type PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
/** 底部加号创作入口页的渲染参数,最近创作只接受作品架真实摘要。 */
type CustomWorldCreationStartCardProps = {
busy?: boolean;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
recentWorks?: readonly CreationEntryRecentWorkCard[];
onCreateType: (type: PlatformCreationTypeId) => void;
onOpenRecentWork?: (index: number) => void;
};
type CreationEventBannerCard = CreationEntryConfig['eventBanner'];
/** 创作入口公告卡兼容结构化和 HTML 两种后台配置。 */
type CreationEventBannerCard = CreationEntryEventBannerConfig;
const CREATION_ENTRY_BANNER_AUTOPLAY_MS = 4200;
const CREATION_ENTRY_RECENT_TAB_ID = '__recent_creation__';
/** 底部加号创作入口页最近创作页签的展示数据,只来自后端作品架摘要。 */
export type CreationEntryRecentWorkCard = {
id: string;
title: string;
summary: string;
statusLabel: string;
};
/** 判断模板 badge 是否需要展示,普通可创建态不额外占用卡片空间。 */
function shouldShowCreationBadge(badge: string) {
const normalizedBadge = badge.trim();
return normalizedBadge !== '可创建' && normalizedBadge !== '可创作';
}
/** 从后端入口配置中解析创作入口公告位,保留旧单条字段兜底。 */
export function resolveCreationEntryEventBanners(
entryConfig: CreationEntryConfig,
): CreationEventBannerCard[] {
const configuredBanners = Array.isArray(entryConfig.eventBanners)
? entryConfig.eventBanners.filter((banner) => banner.title.trim())
: [];
return configuredBanners.length > 0
? configuredBanners
: [entryConfig.eventBanner];
}
/** 渲染创作入口公告位当前页指示点。 */
function CreationEntryBannerPager({
banners,
activeBannerIndex,
}: {
banners: readonly CreationEventBannerCard[];
activeBannerIndex: number;
}) {
return (
<div
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
aria-hidden="true"
>
{banners.map((dotBanner, dotIndex) => (
<span
key={`${dotBanner.title}:dot:${dotIndex}`}
className={
dotIndex === activeBannerIndex
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
}
/>
))}
</div>
);
}
/** 渲染底部加号进入的创作入口页,包括后台公告位和模板分类。 */
export function CustomWorldCreationStartCard({
busy = false,
entryConfig,
creationTypes,
recentWorks = [],
onCreateType,
onOpenRecentWork,
}: CustomWorldCreationStartCardProps) {
const creationTypeGroups = useMemo(
() => groupVisiblePlatformCreationTypes(creationTypes),
[creationTypes],
);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const activeGroup =
creationTypeGroups.find((group) => group.id === activeCategoryId) ??
creationTypeGroups[0] ??
null;
const hasRecentWorks = recentWorks.length > 0;
const activeTabId =
activeCategoryId ??
(hasRecentWorks
? CREATION_ENTRY_RECENT_TAB_ID
: creationTypeGroups[0]?.id ?? null);
const isRecentTabActive =
hasRecentWorks && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
const activeGroup = isRecentTabActive
? null
: creationTypeGroups.find((group) => group.id === activeTabId) ??
creationTypeGroups[0] ??
null;
const visibleCreationTypes = activeGroup?.items ?? [];
const eventBanners = useMemo<CreationEventBannerCard[]>(
() => [
{
...entryConfig.eventBanner,
title: '拼图主题创作赛',
description: '用拼图关卡接住本周主题。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
},
{
...entryConfig.eventBanner,
title: '抓大鹅主题创作赛',
description: '把抓大鹅关卡做成主题挑战。',
coverImageSrc: '/creation-type-references/match3d.webp',
prizePoolMudPoints: 1000,
},
],
[entryConfig.eventBanner],
const eventBanners = useMemo(
() => resolveCreationEntryEventBanners(entryConfig),
[entryConfig],
);
const [activeBannerIndex, setActiveBannerIndex] = useState(0);
const bannerTrackRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setActiveBannerIndex(0);
}, [eventBanners.length]);
useEffect(() => {
if (hasRecentWorks) {
return;
}
setActiveCategoryId((currentId) =>
currentId === CREATION_ENTRY_RECENT_TAB_ID ? null : currentId,
);
}, [hasRecentWorks]);
useEffect(() => {
if (eventBanners.length <= 1) {
return undefined;
}
const intervalId = window.setInterval(() => {
setActiveBannerIndex((currentIndex) => {
const nextIndex = (currentIndex + 1) % eventBanners.length;
const track = bannerTrackRef.current;
if (track && typeof track.scrollTo === 'function') {
track.scrollTo({
left: track.clientWidth * nextIndex,
behavior: 'smooth',
});
}
return nextIndex;
});
}, CREATION_ENTRY_BANNER_AUTOPLAY_MS);
return () => window.clearInterval(intervalId);
}, [eventBanners.length]);
/** 同步手势滑动后的 banner 页码,避免自动轮播和手动滑动状态错位。 */
function handleBannerScroll(event: UIEvent<HTMLDivElement>) {
const { clientWidth, scrollLeft } = event.currentTarget;
if (clientWidth <= 0) {
@@ -78,74 +170,87 @@ export function CustomWorldCreationStartCard({
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:gap-5">
<section className="creation-event-banner relative overflow-hidden rounded-[1.35rem] border border-[#f0d8ca] bg-[#fff8f3] shadow-[0_16px_36px_rgba(174,111,73,0.12)] sm:rounded-[1.65rem]">
<div
ref={bannerTrackRef}
className="creation-event-banner__track flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain touch-pan-x scrollbar-hide"
onScroll={handleBannerScroll}
aria-label="创作赛事横幅"
aria-label="创作公告横幅"
>
{eventBanners.map((banner, index) => {
const prizePoolText =
banner.prizePoolMudPoints.toLocaleString('zh-CN');
const shouldRenderHtmlBanner =
banner.renderMode === 'html' && Boolean(banner.htmlCode?.trim());
return (
<article
key={`${banner.title}:${index}`}
className="creation-event-banner__slide relative w-full shrink-0 snap-center overflow-hidden"
>
<img
src={banner.coverImageSrc}
alt=""
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
loading={index === 0 ? 'eager' : 'lazy'}
/>
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
<div className="w-[68%] min-w-0 sm:w-[56%]">
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
<Trophy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{banner.title}</span>
</div>
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
{banner.description}
</div>
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
<Coins className="h-3 w-3" />
</span>
<span className="shrink-0"></span>
<span className="text-sm font-black text-[#d36b2f]">
{prizePoolText}
</span>
<span className="shrink-0"></span>
</div>
{shouldRenderHtmlBanner ? (
<div className="relative min-h-[12rem] sm:min-h-[15rem]">
<iframe
title={banner.title}
sandbox=""
srcDoc={banner.htmlCode ?? ''}
className="absolute inset-0 h-full w-full border-0 bg-transparent"
/>
</div>
) : (
<>
<img
src={banner.coverImageSrc}
alt=""
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
loading={index === 0 ? 'eager' : 'lazy'}
/>
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
<div className="w-[68%] min-w-0 sm:w-[56%]">
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
<Trophy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{banner.title}</span>
</div>
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
{banner.description}
</div>
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
<Coins className="h-3 w-3" />
</span>
<span className="shrink-0"></span>
<span className="text-sm font-black text-[#d36b2f]">
{prizePoolText}
</span>
<span className="shrink-0"></span>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.startsAtText}
</span>
<span className="shrink-0 text-[#c99373]">|</span>
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.endsAtText}
</span>
</div>
<div
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
aria-hidden="true"
>
{eventBanners.map((dotBanner, dotIndex) => (
<span
key={`${dotBanner.title}:dot:${dotIndex}`}
className={
dotIndex === activeBannerIndex
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
}
<div className="mt-4 flex flex-col gap-2">
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.startsAtText}
</span>
<span className="shrink-0 text-[#c99373]">|</span>
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.endsAtText}
</span>
</div>
<CreationEntryBannerPager
banners={eventBanners}
activeBannerIndex={activeBannerIndex}
/>
))}
</div>
</div>
</>
)}
{shouldRenderHtmlBanner ? (
<div className="absolute inset-x-0 bottom-3 z-10">
<CreationEntryBannerPager
banners={eventBanners}
activeBannerIndex={activeBannerIndex}
/>
</div>
</div>
) : null}
</article>
);
})}
@@ -156,8 +261,26 @@ export function CustomWorldCreationStartCard({
<div
className="-mx-0.5 flex snap-x items-center gap-2 overflow-x-auto px-0.5 pb-1 scrollbar-hide scroll-px-2 sm:gap-3"
role="tablist"
aria-label="玩法模板分类"
aria-label="创作入口页签"
>
{hasRecentWorks ? (
<button
type="button"
role="tab"
aria-selected={isRecentTabActive}
onClick={() => setActiveCategoryId(CREATION_ENTRY_RECENT_TAB_ID)}
className={`relative min-h-8 shrink-0 rounded-full px-2.5 text-xs font-black transition sm:min-h-9 sm:px-3.5 sm:text-sm ${
isRecentTabActive
? 'text-[#6f2f21]'
: 'text-[#7a6558] hover:text-[#6f2f21]'
}`}
>
<span></span>
{isRecentTabActive ? (
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
) : null}
</button>
) : null}
{creationTypeGroups.map((group) => {
const selected = group.id === activeGroup?.id;
return (
@@ -182,55 +305,78 @@ export function CustomWorldCreationStartCard({
})}
</div>
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
{visibleCreationTypes.map((item) => {
const disabled = item.locked || busy;
return (
{isRecentTabActive ? (
<div className="creation-recent-work-grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
{recentWorks.map((item, index) => (
<button
key={item.id}
type="button"
disabled={disabled}
onClick={() => {
onCreateType(item.id);
}}
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
item.locked
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
aria-label={`打开最近创作 ${index + 1}`}
className="creation-recent-work-card min-h-[7.5rem] rounded-[1rem] border border-[#eadbd3] bg-white p-3 text-left shadow-[0_10px_22px_rgba(174,111,73,0.1)]"
onClick={() => onOpenRecentWork?.(index)}
>
<div className="creation-template-card__media relative aspect-[1.32/1] w-full overflow-hidden bg-[#f7ebe3]">
<img
src={item.imageSrc}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
{shouldShowCreationBadge(item.badge) ? (
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
{item.badge}
</span>
) : null}
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
<Coins className="h-3 w-3 shrink-0" />
<span className="truncate">10-20</span>
</span>
<div className="line-clamp-1 text-sm font-black text-[#2f211b]">
{item.title}
</div>
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
{item.title}
</div>
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
{item.subtitle}
</div>
<div className="mt-1 line-clamp-3 text-xs font-semibold leading-4 text-[#6f5a4c]">
{item.summary}
</div>
<div className="mt-2 text-[11px] font-bold text-[#b65f2c]">
{item.statusLabel}
</div>
</button>
);
})}
</div>
))}
</div>
) : (
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
{visibleCreationTypes.map((item) => {
const disabled = item.locked || busy;
return (
<button
key={item.id}
type="button"
disabled={disabled}
onClick={() => {
onCreateType(item.id);
}}
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
item.locked
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="creation-template-card__media relative aspect-[1.32/1] w-full overflow-hidden bg-[#f7ebe3]">
<img
src={item.imageSrc}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
{shouldShowCreationBadge(item.badge) ? (
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
{item.badge}
</span>
) : null}
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
<Coins className="h-3 w-3 shrink-0" />
<span className="truncate">10-20</span>
</span>
</div>
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
{item.title}
</div>
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
{item.subtitle}
</div>
</div>
</button>
);
})}
</div>
)}
</section>
</div>
);