将统一创作契约泥点消耗改为数字字段并由前端格式化展示 将后台契约编辑从 JSON 文本改为结构化卡片与弹窗表单 隐藏玩法阶段等内部标识并按玩法默认映射自动带出 更新创作入口文档、团队记忆和回归测试
417 lines
18 KiB
TypeScript
417 lines
18 KiB
TypeScript
import { Coins, LockKeyhole, Trophy } from 'lucide-react';
|
|
import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
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[];
|
|
recentCreationTypeIds?: readonly PlatformCreationTypeId[];
|
|
recentWindowDays?: number;
|
|
onCreateType: (type: PlatformCreationTypeId) => void;
|
|
};
|
|
|
|
/** 创作入口公告卡兼容结构化和 HTML 两种后台配置。 */
|
|
type CreationEventBannerCard = CreationEntryEventBannerConfig;
|
|
const CREATION_ENTRY_BANNER_AUTOPLAY_MS = 4200;
|
|
const CREATION_ENTRY_RECENT_TAB_ID = '__recent_creation__';
|
|
|
|
/** 判断模板 badge 是否需要展示,普通可创建态不额外占用卡片空间。 */
|
|
function shouldShowCreationBadge(badge: string) {
|
|
const normalizedBadge = badge.trim();
|
|
return normalizedBadge !== '可创建' && normalizedBadge !== '可创作';
|
|
}
|
|
|
|
/** 从后端入口配置中解析创作入口公告位,保留旧单条字段兜底。 */
|
|
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,
|
|
recentCreationTypeIds = [],
|
|
recentWindowDays = 7,
|
|
onCreateType,
|
|
}: CustomWorldCreationStartCardProps) {
|
|
const creationTypeGroups = useMemo(
|
|
() => groupVisiblePlatformCreationTypes(creationTypes),
|
|
[creationTypes],
|
|
);
|
|
const recentCreationTypes = useMemo(() => {
|
|
const creationTypeById = new Map(
|
|
creationTypes
|
|
.filter((item) => !item.hidden)
|
|
.map((item) => [item.id, item] as const),
|
|
);
|
|
return [...new Set(recentCreationTypeIds)]
|
|
.map((id) => creationTypeById.get(id))
|
|
.filter((item): item is PlatformCreationTypeCard => Boolean(item));
|
|
}, [creationTypes, recentCreationTypeIds]);
|
|
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
|
const hasRecentCreationTypes = recentCreationTypes.length > 0;
|
|
const activeTabId =
|
|
activeCategoryId ??
|
|
(hasRecentCreationTypes
|
|
? CREATION_ENTRY_RECENT_TAB_ID
|
|
: (creationTypeGroups[0]?.id ?? null));
|
|
const isRecentTabActive =
|
|
hasRecentCreationTypes && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
|
|
const activeGroup = isRecentTabActive
|
|
? null
|
|
: (creationTypeGroups.find((group) => group.id === activeTabId) ??
|
|
creationTypeGroups[0] ??
|
|
null);
|
|
const visibleCreationTypes = isRecentTabActive
|
|
? recentCreationTypes
|
|
: (activeGroup?.items ?? []);
|
|
const eventBanners = useMemo(
|
|
() => resolveCreationEntryEventBanners(entryConfig),
|
|
[entryConfig],
|
|
);
|
|
const [activeBannerIndex, setActiveBannerIndex] = useState(0);
|
|
const bannerTrackRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
setActiveBannerIndex(0);
|
|
}, [eventBanners.length]);
|
|
|
|
useEffect(() => {
|
|
if (hasRecentCreationTypes) {
|
|
return;
|
|
}
|
|
|
|
setActiveCategoryId((currentId) =>
|
|
currentId === CREATION_ENTRY_RECENT_TAB_ID ? null : currentId,
|
|
);
|
|
}, [hasRecentCreationTypes]);
|
|
|
|
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) {
|
|
return;
|
|
}
|
|
|
|
const nextIndex = Math.max(
|
|
0,
|
|
Math.min(eventBanners.length - 1, Math.round(scrollLeft / clientWidth)),
|
|
);
|
|
setActiveBannerIndex((currentIndex) =>
|
|
currentIndex === nextIndex ? currentIndex : nextIndex,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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="创作公告横幅"
|
|
>
|
|
{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"
|
|
>
|
|
{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">
|
|
开始时间 {banner.startsAtText}
|
|
</span>
|
|
<span className="shrink-0 text-[#c99373]">|</span>
|
|
<span className="min-w-0 truncate">
|
|
结束时间 {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>
|
|
) : null}
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="creation-template-list -mx-1 px-1 sm:-mx-2 sm:px-2">
|
|
<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="创作入口页签"
|
|
>
|
|
{hasRecentCreationTypes ? (
|
|
<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 (
|
|
<button
|
|
key={group.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={selected}
|
|
onClick={() => setActiveCategoryId(group.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 ${
|
|
selected
|
|
? 'text-[#6f2f21]'
|
|
: 'text-[#7a6558] hover:text-[#6f2f21]'
|
|
}`}
|
|
>
|
|
<span>{group.label}</span>
|
|
{selected ? (
|
|
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{isRecentTabActive ? (
|
|
<div className="creation-template-list__recent-window mt-2 text-[11px] font-bold leading-4 text-[#8b6654] sm:text-xs">
|
|
仅显示最近{recentWindowDays}天内使用过的模板
|
|
</div>
|
|
) : null}
|
|
|
|
<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;
|
|
const lockedBadge = item.badge.trim() || '暂未开放';
|
|
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
disabled={disabled}
|
|
data-locked={item.locked ? 'true' : undefined}
|
|
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-[#d9ccc2] text-[#725b4d] shadow-[inset_0_0_0_1px_rgba(111,78,61,0.08)]'
|
|
: '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 ${
|
|
item.locked
|
|
? 'scale-[1.01] grayscale-[0.62] saturate-[0.55] brightness-[0.82]'
|
|
: ''
|
|
}`}
|
|
loading="lazy"
|
|
/>
|
|
{item.locked ? (
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(52,36,27,0.22)_0%,rgba(52,36,27,0.52)_100%)]" />
|
|
) : null}
|
|
{item.locked || shouldShowCreationBadge(item.badge) ? (
|
|
<span
|
|
className={`absolute left-2 top-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full px-2 py-0.5 text-xs font-black shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1 ${
|
|
item.locked
|
|
? 'bg-[#3f3129]/90 text-white'
|
|
: 'bg-[#b66a3e] text-white'
|
|
}`}
|
|
>
|
|
{item.locked ? <LockKeyhole className="h-3 w-3" /> : null}
|
|
{item.locked ? lockedBadge : item.badge}
|
|
</span>
|
|
) : null}
|
|
{item.locked ? (
|
|
<span className="absolute left-1/2 top-1/2 inline-flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white/88 text-[#5d4639] shadow-[0_12px_24px_rgba(46,31,23,0.22)]">
|
|
<LockKeyhole className="h-5 w-5" />
|
|
</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 px-2 py-1 text-[11px] font-black leading-4 shadow-[0_8px_18px_rgba(119,72,44,0.16)] ${
|
|
item.locked
|
|
? 'bg-[#3f3129]/88 text-white'
|
|
: 'bg-[#fff7ec]/92 text-[#b65f2c]'
|
|
}`}
|
|
>
|
|
{item.locked ? (
|
|
<LockKeyhole className="h-3 w-3 shrink-0" />
|
|
) : (
|
|
<Coins className="h-3 w-3 shrink-0" />
|
|
)}
|
|
<span className="truncate">
|
|
{item.locked ? '暂未开放' : item.mudPointCostLabel}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className={`creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col px-2.5 pb-2.5 pt-2.5 sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5 ${
|
|
item.locked
|
|
? 'bg-[#f3ece6] text-[#725b4d]'
|
|
: 'bg-white text-[#2f211b]'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`creation-template-card__title line-clamp-1 text-sm font-black leading-5 ${
|
|
item.locked ? 'text-[#5d4639]' : 'text-[#2f211b]'
|
|
}`}
|
|
>
|
|
{item.title}
|
|
</div>
|
|
<div
|
|
className={`creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 sm:leading-5 ${
|
|
item.locked ? 'text-[#8a766a]' : 'text-[#6f5a4c]'
|
|
}`}
|
|
>
|
|
{item.subtitle}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|