合并最新 master 分支
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Failing after 58s
Project CI / Native shell tests (pull_request) Successful in 2m32s

带入 SpacetimeDB 2.7.0(hotfix3)工具链升级、后台灰度发布控制面恢复、
精选顺序分列瀑布流及后台素材预览统一。冲突仅三处文档:decision-log 与
pitfalls 双方条目并存,运维文档更新时间取新值。BgFilter 分支代码与部署
脚本零冲突;依赖升级后 bgfilter 46 测试全过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 07:02:17 +00:00
187 changed files with 11561 additions and 883 deletions
@@ -470,7 +470,7 @@ describe('CreationLandingView', () => {
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
it('likes a featured asset from the waterfall card action', async () => {
it('likes a featured asset from the list card action', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
@@ -684,7 +684,7 @@ describe('CreationLandingView', () => {
);
});
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
it('uses Taonier featured as the generated project resource list instead of creation entries', async () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
@@ -736,9 +736,13 @@ describe('CreationLandingView', () => {
expect(await screen.findByText('精选素材 A')).toBeTruthy();
expect(screen.getByText('精选素材 B')).toBeTruthy();
expect(screen.queryByText('上传素材')).toBeNull();
expect(screen.getByLabelText('用户素材瀑布流').className).toContain(
const showcaseList = screen.getByRole('list', {
name: '陶泥儿精选素材列表',
});
expect(showcaseList.className).toContain(
'creation-landing__asset-waterfall',
);
expect(within(showcaseList).getAllByRole('listitem')).toHaveLength(2);
const firstPreview = container.querySelector(
'.creation-landing__asset-preview',
) as HTMLElement | null;
@@ -751,6 +755,22 @@ describe('CreationLandingView', () => {
});
it('renders campaign card previews with centered contain media', async () => {
const campaignObjectKey =
'generated-character-drafts/editor/showcase-campaign/current/campaign.png';
const signedCampaignUrl = 'data:image/png;base64,Y2FtcGFpZ24=';
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
read: {
objectKey: campaignObjectKey,
signedUrl: signedCampaignUrl,
expiresAt: '2099-01-01T00:00:00Z',
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
listEditorProjectsMock.mockResolvedValue(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValue({
resources: [],
@@ -759,6 +779,7 @@ describe('CreationLandingView', () => {
enabled: true,
title: '活动精选',
imageSrc: '/campaign.png',
imageObjectKey: campaignObjectKey,
imageWidth: 900,
imageHeight: 1200,
prompt: '活动提示词',
@@ -777,9 +798,17 @@ describe('CreationLandingView', () => {
'creation-landing__asset-preview--campaign',
);
expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200');
const campaignImage = await screen.findByRole('img', {
name: '活动精选',
});
expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl);
expect(fetchMock).toHaveBeenCalledWith(
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
expect.objectContaining({ method: 'GET' }),
);
});
it('loads more featured resources when the waterfall reaches the end', async () => {
it('loads more featured resources when the list reaches the end', async () => {
const observers = installIntersectionObserverMock();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock
@@ -873,10 +902,10 @@ describe('CreationLandingView', () => {
await screen.findByRole('button', { name: '读取更多素材失败,点击重试' }),
).toBeTruthy();
expect(screen.getByText('精选素材 A')).toBeTruthy();
expect(screen.getByLabelText('用户素材瀑布流')).toBeTruthy();
expect(screen.getByLabelText('陶泥儿精选素材列表')).toBeTruthy();
});
it('keeps the featured waterfall empty when project resources are empty', async () => {
it('keeps the featured list empty when project resources are empty', async () => {
renderCreationLanding({
authValue: createAuthValue({
user: null,
@@ -885,7 +914,7 @@ describe('CreationLandingView', () => {
});
expect(await screen.findByText('暂无素材')).toBeTruthy();
expect(screen.queryByLabelText('用户素材瀑布流')).toBeNull();
expect(screen.queryByLabelText('陶泥儿精选素材列表')).toBeNull();
expect(screen.queryByText('精选入口')).toBeNull();
});
});
@@ -1,5 +1,12 @@
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { ApiClientError } from '../../services/apiClient';
@@ -30,6 +37,10 @@ import {
type ShowcaseAssetPreview,
type ShowcaseTabId,
} from './creationShowcaseModel';
import {
applySequentialShowcaseMasonry,
resetSequentialShowcaseMasonry,
} from './showcaseMasonryLayout';
type CreationLandingViewProps = {
onOpenProject: (
@@ -171,6 +182,59 @@ function getPreviewAspectRatio(preview: ShowcaseAssetPreview) {
: undefined;
}
function useSequentialShowcaseMasonry(
itemOrderKey: string,
expectedItemCount: number,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const view = container.ownerDocument.defaultView;
let animationFrame: number | null = null;
const layout = () => {
animationFrame = null;
applySequentialShowcaseMasonry(container, { expectedItemCount });
};
const scheduleLayout = () => {
if (animationFrame !== null) {
return;
}
if (view?.requestAnimationFrame) {
animationFrame = view.requestAnimationFrame(layout);
return;
}
layout();
};
layout();
const ResizeObserverConstructor = globalThis.ResizeObserver;
const resizeObserver = ResizeObserverConstructor
? new ResizeObserverConstructor(scheduleLayout)
: null;
resizeObserver?.observe(container);
for (const card of container.querySelectorAll<HTMLElement>(
':scope > .creation-landing__asset-card',
)) {
resizeObserver?.observe(card);
}
return () => {
if (animationFrame !== null && view?.cancelAnimationFrame) {
view.cancelAnimationFrame(animationFrame);
}
resizeObserver?.disconnect();
resetSequentialShowcaseMasonry(container);
};
}, [expectedItemCount, itemOrderKey]);
return containerRef;
}
function getPreviewIcon(preview: ShowcaseAssetPreview) {
if (preview.mediaType === 'audio') {
return <Music2 aria-hidden="true" />;
@@ -747,6 +811,13 @@ export function CreationLandingView({
),
);
}, [normalizedSearchKeyword, showcaseItems]);
const showcaseMasonryOrderKey = JSON.stringify(
visibleShowcaseItems.map((item) => item.id),
);
const showcaseMasonryRef = useSequentialShowcaseMasonry(
showcaseMasonryOrderKey,
visibleShowcaseItems.length,
);
const visibleRecentProjects = useMemo(() => {
if (!normalizedSearchKeyword) {
return recentProjects;
@@ -811,8 +882,10 @@ export function CreationLandingView({
return (
<>
<div
ref={showcaseMasonryRef}
className="creation-landing__asset-waterfall"
aria-label="用户素材瀑布流"
aria-label="陶泥儿精选素材列表"
role="list"
>
{visibleShowcaseItems.map((item) => {
const showcaseId = item.showcaseId?.trim();
@@ -823,7 +896,11 @@ export function CreationLandingView({
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
);
return (
<article key={item.id} className="creation-landing__asset-card">
<article
key={item.id}
className="creation-landing__asset-card"
role="listitem"
>
<button
type="button"
className="creation-landing__asset-card-open"
@@ -0,0 +1,153 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it } from 'vitest';
import {
applySequentialShowcaseMasonry,
createSequentialShowcaseMasonryLayout,
resetSequentialShowcaseMasonry,
resolveShowcaseMasonryColumnCount,
} from './showcaseMasonryLayout';
function createRect(width: number, height: number): DOMRect {
return {
bottom: height,
height,
left: 0,
right: width,
top: 0,
width,
x: 0,
y: 0,
toJSON: () => ({}),
};
}
describe('showcase masonry layout', () => {
afterEach(() => {
document.body.replaceChildren();
});
it('assigns every group to columns in stable round-robin order', () => {
const layout = createSequentialShowcaseMasonryLayout({
containerWidth: 1296,
gap: 16,
itemHeights: [600, 300, 450, 200, 400, 250],
});
expect(layout.columnCount).toBe(3);
expect(layout.items.map((item) => item.columnIndex)).toEqual([
0, 1, 2, 0, 1, 2,
]);
expect(layout.items.map((item) => item.top)).toEqual([
0, 0, 0, 616, 316, 466,
]);
expect(layout.columnHeights).toEqual([816, 716, 716]);
expect(layout.containerHeight).toBe(816);
});
it('derives three, two, or one column from the actual container width', () => {
expect(
resolveShowcaseMasonryColumnCount({
containerWidth: 590.71,
gap: 14.72,
}),
).toBe(1);
expect(
resolveShowcaseMasonryColumnCount({
containerWidth: 590.72,
gap: 14.72,
}),
).toBe(2);
expect(
resolveShowcaseMasonryColumnCount({
containerWidth: 893.43,
gap: 14.72,
}),
).toBe(2);
expect(
resolveShowcaseMasonryColumnCount({
containerWidth: 893.44,
gap: 14.72,
}),
).toBe(3);
expect(
resolveShowcaseMasonryColumnCount({ containerWidth: 1600, gap: 14.72 }),
).toBe(3);
});
it('applies and resets absolute card positions without changing DOM order', () => {
const container = document.createElement('div');
container.className = 'creation-landing__asset-waterfall';
container.style.columnGap = '16px';
container.getBoundingClientRect = () => createRect(900, 0);
const heights = [600, 300, 450, 200, 400, 250];
const cards = heights.map((height, index) => {
const card = document.createElement('article');
card.className = 'creation-landing__asset-card';
card.textContent = `card-${index + 1}`;
card.getBoundingClientRect = () =>
createRect(Number.parseFloat(card.style.width) || 0, height);
container.append(card);
return card;
});
document.body.append(container);
const layout = applySequentialShowcaseMasonry(container);
expect(layout?.columnCount).toBe(3);
expect(container.dataset.masonryColumns).toBe('3');
expect(cards.map((card) => card.dataset.masonryColumn)).toEqual([
'1',
'2',
'3',
'1',
'2',
'3',
]);
expect(cards.map((card) => card.style.top)).toEqual([
'0px',
'0px',
'0px',
'616px',
'316px',
'466px',
]);
expect(
Array.from(container.children).map((card) => card.textContent),
).toEqual(['card-1', 'card-2', 'card-3', 'card-4', 'card-5', 'card-6']);
resetSequentialShowcaseMasonry(container);
expect(container.dataset.masonryColumns).toBeUndefined();
expect(container.style.height).toBe('');
expect(cards.every((card) => card.style.position === '')).toBe(true);
expect(cards.every((card) => card.style.top === '')).toBe(true);
});
it('keeps the flow fallback until every expected card has a valid height', () => {
const container = document.createElement('div');
container.className = 'creation-landing__asset-waterfall';
container.getBoundingClientRect = () => createRect(900, 0);
const card = document.createElement('article');
card.className = 'creation-landing__asset-card';
card.getBoundingClientRect = () => createRect(288, 0);
container.append(card);
document.body.append(container);
expect(
applySequentialShowcaseMasonry(container, { expectedItemCount: 2 }),
).toBeNull();
expect(
container.classList.contains(
'creation-landing__asset-waterfall--masonry',
),
).toBe(false);
expect(
applySequentialShowcaseMasonry(container, { expectedItemCount: 1 }),
).toBeNull();
expect(container.style.height).toBe('');
expect(card.style.position).toBe('');
});
});
@@ -0,0 +1,190 @@
export const SHOWCASE_MASONRY_MAX_COLUMNS = 3;
export const SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX = 288;
export const SHOWCASE_MASONRY_FALLBACK_GAP_PX = 14.72;
export type ShowcaseMasonryItemLayout = {
columnIndex: number;
height: number;
left: number;
top: number;
width: number;
};
export type ShowcaseMasonryLayout = {
columnCount: number;
columnHeights: number[];
columnWidth: number;
containerHeight: number;
items: ShowcaseMasonryItemLayout[];
};
type CreateShowcaseMasonryLayoutOptions = {
containerWidth: number;
gap: number;
itemHeights: number[];
maxColumns?: number;
minColumnWidth?: number;
};
function normalizeFiniteNumber(value: number, fallback = 0) {
return Number.isFinite(value) ? value : fallback;
}
export function resolveShowcaseMasonryColumnCount({
containerWidth,
gap,
maxColumns = SHOWCASE_MASONRY_MAX_COLUMNS,
minColumnWidth = SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX,
}: Omit<CreateShowcaseMasonryLayoutOptions, 'itemHeights'>) {
const safeContainerWidth = Math.max(0, normalizeFiniteNumber(containerWidth));
const safeGap = Math.max(0, normalizeFiniteNumber(gap));
const safeMaxColumns = Math.max(
1,
Math.floor(normalizeFiniteNumber(maxColumns, 1)),
);
const safeMinColumnWidth = Math.max(
1,
normalizeFiniteNumber(minColumnWidth, 1),
);
const fittingColumns = Math.max(
1,
Math.floor((safeContainerWidth + safeGap) / (safeMinColumnWidth + safeGap)),
);
return Math.min(safeMaxColumns, fittingColumns);
}
export function createSequentialShowcaseMasonryLayout({
containerWidth,
gap,
itemHeights,
maxColumns = SHOWCASE_MASONRY_MAX_COLUMNS,
minColumnWidth = SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX,
}: CreateShowcaseMasonryLayoutOptions): ShowcaseMasonryLayout {
const safeContainerWidth = Math.max(0, normalizeFiniteNumber(containerWidth));
const safeGap = Math.max(0, normalizeFiniteNumber(gap));
const columnCount = resolveShowcaseMasonryColumnCount({
containerWidth: safeContainerWidth,
gap: safeGap,
maxColumns,
minColumnWidth,
});
const columnWidth = Math.max(
0,
(safeContainerWidth - safeGap * (columnCount - 1)) / columnCount,
);
const occupiedColumnHeights = Array.from<number>({
length: columnCount,
}).fill(0);
const items = itemHeights.map((rawHeight, index) => {
const height = Math.max(0, normalizeFiniteNumber(rawHeight));
const columnIndex = index % columnCount;
const top = occupiedColumnHeights[columnIndex] ?? 0;
const item = {
columnIndex,
height,
left: columnIndex * (columnWidth + safeGap),
top,
width: columnWidth,
};
occupiedColumnHeights[columnIndex] = top + height + safeGap;
return item;
});
const columnHeights = occupiedColumnHeights.map((height) =>
height > 0 ? height - safeGap : 0,
);
return {
columnCount,
columnHeights,
columnWidth,
containerHeight: Math.max(0, ...columnHeights),
items,
};
}
function resolveContainerGap(container: HTMLElement) {
const view = container.ownerDocument.defaultView;
const rawGap = view?.getComputedStyle(container).columnGap ?? '';
const gap = Number.parseFloat(rawGap);
return Number.isFinite(gap) ? gap : SHOWCASE_MASONRY_FALLBACK_GAP_PX;
}
function getMasonryCards(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
':scope > .creation-landing__asset-card',
),
);
}
export function resetSequentialShowcaseMasonry(container: HTMLElement) {
container.classList.remove('creation-landing__asset-waterfall--masonry');
container.style.removeProperty('height');
delete container.dataset.masonryColumns;
for (const card of getMasonryCards(container)) {
card.style.removeProperty('position');
card.style.removeProperty('width');
card.style.removeProperty('left');
card.style.removeProperty('top');
delete card.dataset.masonryColumn;
}
}
export function applySequentialShowcaseMasonry(
container: HTMLElement,
{ expectedItemCount }: { expectedItemCount?: number } = {},
) {
const containerWidth = container.getBoundingClientRect().width;
const cards = getMasonryCards(container);
if (
!Number.isFinite(containerWidth) ||
containerWidth <= 0 ||
!cards.length ||
(expectedItemCount !== undefined && cards.length !== expectedItemCount)
) {
resetSequentialShowcaseMasonry(container);
return null;
}
const gap = resolveContainerGap(container);
const columnCount = resolveShowcaseMasonryColumnCount({
containerWidth,
gap,
});
const columnWidth = Math.max(
0,
(containerWidth - gap * (columnCount - 1)) / columnCount,
);
container.classList.add('creation-landing__asset-waterfall--masonry');
cards.forEach((card, index) => {
const columnIndex = index % columnCount;
card.style.position = 'absolute';
card.style.width = `${columnWidth}px`;
card.style.left = `${columnIndex * (columnWidth + gap)}px`;
card.style.top = '0px';
});
const itemHeights = cards.map((card) => card.getBoundingClientRect().height);
if (itemHeights.some((height) => !Number.isFinite(height) || height <= 0)) {
resetSequentialShowcaseMasonry(container);
return null;
}
const layout = createSequentialShowcaseMasonryLayout({
containerWidth,
gap,
itemHeights,
});
cards.forEach((card, index) => {
const item = layout.items[index];
if (!item) {
return;
}
card.style.left = `${item.left}px`;
card.style.top = `${item.top}px`;
card.dataset.masonryColumn = String(item.columnIndex + 1);
});
container.style.height = `${layout.containerHeight}px`;
container.dataset.masonryColumns = String(layout.columnCount);
return layout;
}
+21 -7
View File
@@ -3289,20 +3289,34 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.creation-landing__asset-waterfall {
display: grid;
width: 100%;
min-width: 0;
column-count: 3;
column-gap: 0.92rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-flow: row;
gap: 0.92rem;
align-items: start;
}
.creation-landing__asset-waterfall--masonry {
position: relative;
display: block;
}
.creation-landing__asset-waterfall--masonry
> .creation-landing__asset-card {
position: absolute;
box-sizing: border-box;
}
.creation-landing__asset-card {
position: relative;
display: inline-block;
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
margin: 0 0 0.92rem;
break-inside: avoid;
margin: 0;
align-self: start;
border: 1px solid rgba(220, 189, 163, 0.78);
border-radius: 1rem;
background: rgba(255, 254, 251, 0.88);
@@ -3802,7 +3816,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.creation-landing__asset-waterfall {
column-count: 2;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.creation-landing__showcase-modal-panel {
@@ -3872,7 +3886,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.creation-landing__asset-waterfall {
column-count: 1;
grid-template-columns: 1fr;
}
.creation-landing__showcase-modal-overlay {
+32 -13
View File
@@ -38,13 +38,21 @@ function getCssBlock(source: string, selector: string) {
}
describe('index stylesheet unread dots', () => {
it('keeps creation home featured assets in a column waterfall layout', () => {
it('keeps creation home featured assets in a sequential responsive masonry layout', () => {
const css = readIndexCss();
const waterfallBlock = getCssBlock(
css,
'.creation-landing__asset-waterfall',
);
const cardBlock = getCssBlock(css, '.creation-landing__asset-card {');
const masonryBlock = getCssBlock(
css,
'.creation-landing__asset-waterfall--masonry',
);
const masonryCardBlock = getCssBlock(
css,
'.creation-landing__asset-waterfall--masonry\n > .creation-landing__asset-card',
);
const cardBlock = getCssBlock(css, '\n.creation-landing__asset-card {');
const tabletQueryIndex = css.indexOf('@media (max-width: 900px)');
const mobileQueryIndex = css.indexOf('@media (max-width: 560px)');
@@ -59,17 +67,28 @@ describe('index stylesheet unread dots', () => {
'.creation-landing__asset-waterfall',
);
expect(waterfallBlock).toContain('column-count: 3;');
expect(waterfallBlock).toContain('column-gap: 0.92rem;');
expect(waterfallBlock).not.toContain('display: grid;');
expect(waterfallBlock).not.toContain('grid-template-columns');
expect(cardBlock).toContain('display: inline-block;');
expect(cardBlock).toContain('margin: 0 0 0.92rem;');
expect(cardBlock).toContain('break-inside: avoid;');
expect(tabletWaterfallBlock).toContain('column-count: 2;');
expect(tabletWaterfallBlock).not.toContain('grid-template-columns');
expect(mobileWaterfallBlock).toContain('column-count: 1;');
expect(mobileWaterfallBlock).not.toContain('grid-template-columns');
expect(waterfallBlock).toContain('display: grid;');
expect(waterfallBlock).toContain(
'grid-template-columns: repeat(3, minmax(0, 1fr));',
);
expect(waterfallBlock).toContain('grid-auto-flow: row;');
expect(waterfallBlock).toContain('gap: 0.92rem;');
expect(waterfallBlock).toContain('align-items: start;');
expect(waterfallBlock).not.toContain('column-count');
expect(masonryBlock).toContain('position: relative;');
expect(masonryBlock).toContain('display: block;');
expect(masonryCardBlock).toContain('position: absolute;');
expect(masonryCardBlock).toContain('box-sizing: border-box;');
expect(cardBlock).toContain('display: block;');
expect(cardBlock).toContain('margin: 0;');
expect(cardBlock).toContain('align-self: start;');
expect(cardBlock).not.toContain('break-inside');
expect(tabletWaterfallBlock).toContain(
'grid-template-columns: repeat(2, minmax(0, 1fr));',
);
expect(tabletWaterfallBlock).not.toContain('column-count');
expect(mobileWaterfallBlock).toContain('grid-template-columns: 1fr;');
expect(mobileWaterfallBlock).not.toContain('column-count');
});
it('keeps icon generator decoration from overriding the shared generating animation', () => {