Merge branch 'master' into editor-agent-improve-interaction
Project CI / Frontend tests (pull_request) Successful in 40s
Project CI / Repository checks (pull_request) Successful in 53s
Project CI / Native shell tests (pull_request) Successful in 3m36s
Project CI / Backend tests (pull_request) Successful in 3m37s

This commit is contained in:
2026-07-23 10:11:58 +08:00
25 changed files with 1806 additions and 637 deletions
+93 -16
View File
@@ -7,6 +7,7 @@ import {
listAdminRechargeOrders,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage,
} from './adminApiClient';
afterEach(() => {
@@ -16,7 +17,7 @@ afterEach(() => {
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({account: {accountId: 'member-1'}}), {
new Response(JSON.stringify({ account: { accountId: 'member-1' } }), {
status: 200,
}),
),
@@ -40,7 +41,7 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
@@ -56,6 +57,86 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
);
});
test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => {
const closeBitmap = vi.fn();
vi.stubGlobal(
'createImageBitmap',
vi
.fn()
.mockResolvedValue({ width: 1024, height: 1536, close: closeBitmap }),
);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
upload: {
bucket: 'genarrative-release',
host: 'https://genarrative-release.oss.example.com',
objectKey:
'generated-character-drafts/editor/showcase-campaign/current/card.png',
legacyPublicPath:
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
contentType: 'image/png',
formFields: { key: 'campaign-key', policy: 'signed-policy' },
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
)
.mockResolvedValueOnce(new Response('', { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({ assetObject: { assetObjectId: 'assetobj-1' } }),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
);
vi.stubGlobal('fetch', fetchMock);
const file = new File(['image-bytes'], 'card.png', { type: 'image/png' });
const uploaded = await uploadAdminEditorShowcaseCampaignImage(
'admin-token',
file,
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/editor-showcase/campaign/image-upload-ticket',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'https://genarrative-release.oss.example.com',
);
expect(fetchMock.mock.calls[2]?.[0]).toBe(
'/admin/api/editor-showcase/campaign/image-upload-confirm',
);
expect(fetchMock.mock.calls[2]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
body: JSON.stringify({
bucket: 'genarrative-release',
objectKey:
'generated-character-drafts/editor/showcase-campaign/current/card.png',
contentType: 'image/png',
contentLength: file.size,
}),
}),
);
expect(uploaded).toEqual({
imageSrc:
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
imageObjectKey:
'generated-character-drafts/editor/showcase-campaign/current/card.png',
imageWidth: 1024,
imageHeight: 1536,
legacyPublicPath:
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
});
expect(closeBitmap).toHaveBeenCalledOnce();
});
test('充值订单查询按后台契约序列化筛选参数', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ entries: [] }), {
@@ -110,13 +191,11 @@ test('用户详情只发送实际提供的用户定位字段', async () => {
});
test('退款执行使用独立 execute 管理员路由', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await executeAdminRechargeRefund('token-1', {
@@ -143,13 +222,11 @@ test('退款执行使用独立 execute 管理员路由', async () => {
});
test('退款人工复核使用独立 resolve 管理员路由', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await resolveAdminRechargeRefundManualReview('token-1', {
+14
View File
@@ -1,5 +1,6 @@
import type {
AdminAccountListResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
@@ -388,6 +389,19 @@ export async function uploadAdminEditorShowcaseCampaignImage(
);
await postAdminDirectUploadFile(response.upload, file);
const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, '');
await request<unknown>(
'/admin/api/editor-showcase/campaign/image-upload-confirm',
{
method: 'POST',
token,
body: {
bucket: response.upload.bucket,
objectKey,
contentType,
contentLength: file.size,
} satisfies AdminConfirmEditorShowcaseCampaignImageUploadRequest,
},
);
return {
imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath,
imageObjectKey: objectKey,
+8
View File
@@ -372,6 +372,7 @@ export interface AdminEditorShowcaseAssetPayload {
taskId?: string | null;
assetKind?: string | null;
generationInputs?: Record<string, unknown> | null;
thumbnailSrc?: string | null;
generationCostMudPoints: number;
refundMudPoints: number;
reviewStatus: 'pending' | 'approved' | 'rejected' | string;
@@ -459,6 +460,13 @@ export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
upload: AdminDirectUploadTicketPayload;
}
export interface AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: string;
objectKey: string;
contentType: string;
contentLength: number;
}
export interface AdminUploadedEditorShowcaseCampaignImage {
imageSrc: string;
imageObjectKey: string;
@@ -0,0 +1,436 @@
import { X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient';
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
let adminAssetReadDispatchTail = Promise.resolve();
export interface AdminPreviewableEditorAsset {
assetId: string;
label: string;
imageSrc: string;
objectKey?: string | null;
assetKind?: string | null;
thumbnailSrc?: string | null;
}
export function AdminEditorAssetThumbnail({
entry,
token,
altPrefix = '素材',
}: {
entry: AdminPreviewableEditorAsset;
token: string;
altPrefix?: string;
}) {
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
const imageSrc = useAdminResolvedAssetUrl(
token,
thumbnailSource.src,
thumbnailSource.objectKey,
shouldLoad,
);
const alt = `${altPrefix}${entry.label || entry.assetId}`;
return imageSrc ? (
<img
ref={observeElement}
alt={alt}
className="admin-asset-query-thumb"
src={imageSrc}
/>
) : (
<div
ref={observeElement}
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
/>
);
}
export function AdminEditorAssetPreviewDialog({
entry,
token,
onClose,
}: {
entry: AdminPreviewableEditorAsset;
token: string;
onClose: () => void;
}) {
return (
<div className="admin-confirm-backdrop" role="presentation">
<section
aria-label="素材预览"
className="admin-detail-panel admin-asset-query-preview-dialog"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h3>{entry.label || entry.assetId}</h3>
<span>{entry.assetId}</span>
</div>
<button
aria-label="关闭素材预览"
className="admin-ghost-button"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
</section>
</div>
);
}
function AdminEditorAssetPreviewMedia({
entry,
token,
}: {
entry: AdminPreviewableEditorAsset;
token: string;
}) {
const mediaKind = resolveAdminAssetMediaKind(entry);
const isAudio = mediaKind === 'audio';
const isVideo = mediaKind === 'video';
const mediaSrc = useAdminResolvedAssetUrl(
token,
entry.imageSrc,
entry.objectKey,
);
const posterSrc = useAdminResolvedAssetUrl(
token,
isVideo ? (entry.thumbnailSrc ?? '') : '',
null,
);
const label = entry.label || entry.assetId;
if (isAudio) {
return (
<div className="admin-asset-query-preview-audio">
<img
alt={`音频封面:${label}`}
className="admin-asset-query-preview-cover"
src={AUDIO_ASSET_COVER_SRC}
/>
{mediaSrc ? (
<audio
aria-label={`音频预览:${label}`}
className="admin-asset-query-preview-player"
controls
preload="metadata"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
)}
</div>
);
}
if (isVideo) {
return mediaSrc ? (
<video
aria-label={`视频预览:${label}`}
className="admin-asset-query-preview-media"
controls
playsInline
poster={posterSrc || undefined}
preload="metadata"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
);
}
return mediaSrc ? (
<img
alt={`图片预览:${label}`}
className="admin-asset-query-preview-media"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
);
}
function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) {
const mediaKind = resolveAdminAssetMediaKind(entry);
if (mediaKind === 'audio') {
return { src: AUDIO_ASSET_COVER_SRC, objectKey: null };
}
if (mediaKind === 'video') {
return { src: entry.thumbnailSrc || '', objectKey: null };
}
if (entry.thumbnailSrc?.trim()) {
return {
src: entry.thumbnailSrc,
objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc)
? entry.objectKey
: null,
};
}
return {
src: entry.imageSrc,
objectKey: entry.objectKey,
};
}
function useAdminAssetThumbnailVisibility() {
const [element, setElement] = useState<HTMLElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(false);
const observeElement = useCallback((nextElement: HTMLElement | null) => {
setElement(nextElement);
}, []);
useEffect(() => {
if (shouldLoad || !element) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
setShouldLoad(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShouldLoad(true);
observer.disconnect();
}
},
{ rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN },
);
observer.observe(element);
return () => observer.disconnect();
}, [element, shouldLoad]);
return { observeElement, shouldLoad };
}
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
function resolveAdminAssetMediaKind(
entry: AdminPreviewableEditorAsset,
): AdminAssetMediaKind {
const pathMediaKind =
resolveAdminAssetMediaKindFromPath(entry.imageSrc) ??
resolveAdminAssetMediaKindFromPath(entry.objectKey ?? '');
if (pathMediaKind) {
return pathMediaKind;
}
const assetKind = entry.assetKind?.trim() ?? '';
if (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio'
) {
return 'audio';
}
if (
assetKind === 'video' ||
assetKind === 'editor_video' ||
assetKind === 'editor-video' ||
assetKind === 'editor_uploaded_video'
) {
return 'video';
}
return 'image';
}
function resolveAdminAssetMediaKindFromPath(
value: string,
): AdminAssetMediaKind | null {
const normalizedValue = value.trim();
if (/^data:image\//iu.test(normalizedValue)) {
return 'image';
}
if (/^data:audio\//iu.test(normalizedValue)) {
return 'audio';
}
if (/^data:video\//iu.test(normalizedValue)) {
return 'video';
}
if (
/\.(?:avif|bmp|gif|jpe?g|png|svg|webp)(?:$|[?#])/iu.test(normalizedValue)
) {
return 'image';
}
if (/\.(?:aac|flac|m4a|mp3|ogg|opus|wav)(?:$|[?#])/iu.test(normalizedValue)) {
return 'audio';
}
if (/\.(?:m4v|mov|mp4|ogv|webm)(?:$|[?#])/iu.test(normalizedValue)) {
return 'video';
}
return null;
}
function useAdminResolvedAssetUrl(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
enabled = true,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
? normalizedImageSrc
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
const shouldResolve =
Boolean(normalizedObjectKey) || Boolean(normalizedLegacyPublicPath);
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
useEffect(() => {
if (!normalizedImageSrc && !normalizedObjectKey) {
setResolvedImageSrc('');
return;
}
if (!shouldResolve) {
setResolvedImageSrc(normalizedImageSrc);
return;
}
if (!enabled) {
setResolvedImageSrc('');
return;
}
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryIndex = 0;
const dispatchController = new AbortController();
setResolvedImageSrc('');
const resolveReadUrl = async () => {
try {
await waitForAdminAssetReadDispatch(dispatchController.signal);
if (cancelled) {
return;
}
const response = await getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
);
if (!cancelled) {
setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response));
}
} catch (error: unknown) {
if (cancelled) {
return;
}
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
if (
isAdminApiError(error) &&
error.status === 429 &&
typeof retryDelay === 'number'
) {
retryIndex += 1;
retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay);
return;
}
setResolvedImageSrc('');
}
};
void resolveReadUrl();
return () => {
cancelled = true;
dispatchController.abort();
if (retryTimer !== null) {
clearTimeout(retryTimer);
}
};
}, [
enabled,
normalizedImageSrc,
normalizedLegacyPublicPath,
normalizedObjectKey,
shouldResolve,
token,
]);
return resolvedImageSrc;
}
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
const dispatch = adminAssetReadDispatchTail.then(
() => waitForAdminAssetReadDispatchSpacing(signal),
() => waitForAdminAssetReadDispatchSpacing(signal),
);
adminAssetReadDispatchTail = dispatch.catch(() => undefined);
await dispatch;
}
async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', handleAbort);
resolve();
}, ADMIN_ASSET_READ_DISPATCH_SPACING_MS);
function handleAbort() {
clearTimeout(timer);
reject(new DOMException('The operation was aborted.', 'AbortError'));
}
signal.addEventListener('abort', handleAbort, { once: true });
});
}
function normalizeAdminObjectKey(value: string | null | undefined) {
return value?.trim().replace(/^\/+/u, '') ?? '';
}
function adminAssetPathsMatch(left: string, right: string) {
return (
left.trim().replace(/^\/+|[?#].*$/gu, '') ===
right.trim().replace(/^\/+|[?#].*$/gu, '')
);
}
function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function resolveAdminGeneratedLegacyPathFromUrl(value: string) {
try {
const parsedUrl = new URL(value);
if (
parsedUrl.protocol !== 'https:' ||
!/^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(parsedUrl.hostname)
) {
return '';
}
const legacyPublicPath = decodeURIComponent(parsedUrl.pathname);
return isGeneratedLegacyPath(legacyPublicPath) ? legacyPublicPath : '';
} catch {
return '';
}
}
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
const read = response.read ?? response;
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
}
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,14 @@
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
getAdminAssetReadUrl,
@@ -23,6 +24,13 @@ import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
getAdminEditorShowcaseCampaign: vi.fn(),
listAdminEditorShowcaseAssets: vi.fn(),
reviewAdminEditorShowcaseAsset: vi.fn(),
@@ -31,8 +39,87 @@ vi.mock('../api/adminApiClient', () => ({
upsertAdminEditorShowcaseCampaign: vi.fn(),
}));
interface MockIntersectionObserverController {
enter: (target: Element) => void;
isObserved: (target: Element) => boolean;
}
function installIntersectionObserverMock(): MockIntersectionObserverController {
const observed = new Map<
Element,
{
callback: IntersectionObserverCallback;
observer: IntersectionObserver;
}
>();
class MockIntersectionObserver implements IntersectionObserver {
readonly root = null;
readonly rootMargin: string;
readonly thresholds = [0];
private readonly targets = new Set<Element>();
constructor(
private readonly callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {},
) {
this.rootMargin = options.rootMargin ?? '0px';
}
observe(target: Element) {
this.targets.add(target);
observed.set(target, {
callback: this.callback,
observer: this as unknown as IntersectionObserver,
});
}
unobserve(target: Element) {
this.targets.delete(target);
observed.delete(target);
}
disconnect() {
this.targets.forEach((target) => observed.delete(target));
this.targets.clear();
}
takeRecords() {
return [];
}
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
return {
enter(target) {
const record = observed.get(target);
if (!record) {
throw new Error('目标精选缩略图尚未进入 IntersectionObserver');
}
act(() => {
record.callback(
[
{
isIntersecting: true,
target,
} as IntersectionObserverEntry,
],
record.observer,
);
});
},
isObserved(target) {
return observed.has(target);
},
};
}
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
AdminUserReferenceButton: ({
userId,
publicUserCode,
}: {
userId?: string;
publicUserCode?: string | null;
}) => (
@@ -160,6 +247,10 @@ beforeEach(() => {
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('后台精选审核展示待审核素材和活动卡配置', async () => {
render(
<AdminEditorShowcaseReviewPage
@@ -184,10 +275,114 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
submittedBefore: null,
limit: 80,
});
await waitFor(() => {
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
expireSeconds: 300,
});
});
});
test('后台精选审核缩略图进入视口后换签并可打开图片预览', async () => {
const observer = installIntersectionObserverMock();
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
const previewButton = screen.getByTitle('预览素材');
const thumbnail = previewButton.querySelector('.admin-asset-query-thumb');
expect(thumbnail).not.toBeNull();
await waitFor(() => expect(observer.isObserved(thumbnail!)).toBe(true));
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
observer.enter(thumbnail!);
const image = await screen.findByRole('img', {
name: '精选素材:角色形象 1',
});
expect(image.getAttribute('src')).toBe('https://signed.example.com/spec.png');
fireEvent.click(previewButton);
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
const previewImage = await within(dialog).findByRole('img', {
name: '图片预览:角色形象 1',
});
await waitFor(() => {
expect(previewImage.getAttribute('src')).toBe(
'https://signed.example.com/spec.png',
);
});
expect(screen.queryByRole('dialog', { name: '精选素材详情' })).toBeNull();
});
test('后台精选审核将无 objectKey 的绝对 OSS 图片地址换签后预览', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
imageSrc:
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-character-drafts/editor/absolute.png?x-oss-process=image/resize,w_320',
objectKey: null,
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
read: {
objectKey: 'generated-character-drafts/editor/absolute.png',
signedUrl: 'https://signed.example.com/absolute.png',
expiresAt: '2026-07-04T11:00:00Z',
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
const image = await screen.findByRole('img', {
name: '精选素材:角色形象 1',
});
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/absolute.png',
);
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
legacyPublicPath: '/generated-character-drafts/editor/absolute.png',
expireSeconds: 300,
});
fireEvent.click(screen.getByTitle('预览素材'));
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
expect(
await within(dialog).findByRole('img', {
name: '图片预览:角色形象 1',
}),
).toHaveProperty('src', 'https://signed.example.com/absolute.png');
});
test('后台精选审核详情中的缩略图也可打开素材预览', async () => {
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
const detail = await screen.findByRole('dialog', { name: '精选素材详情' });
fireEvent.click(within(detail).getByTitle('预览素材'));
const preview = await screen.findByRole('dialog', { name: '素材预览' });
expect(
await within(preview).findByRole('img', {
name: '图片预览:角色形象 1',
}),
).toBeTruthy();
});
test('后台精选审核格式化微秒时间并显示素材名', async () => {
@@ -2,9 +2,7 @@ import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import {
getAdminAssetReadUrl,
getAdminEditorShowcaseCampaign,
listAdminEditorShowcaseAssets,
reviewAdminEditorShowcaseAsset,
@@ -17,6 +15,10 @@ import type {
AdminEditorShowcaseCampaignPayload,
AdminEditorShowcaseListQuery,
} from '../api/adminApiTypes';
import {
AdminEditorAssetPreviewDialog,
AdminEditorAssetThumbnail,
} from '../components/AdminEditorAssetMedia';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
@@ -25,9 +27,6 @@ interface AdminEditorShowcaseReviewPageProps {
onUnauthorized: (message?: string) => void;
}
const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300;
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
const showcaseCategoryOptions = [
{ value: 'characters', label: '角色' },
{ value: 'ui', label: 'UI' },
@@ -58,6 +57,8 @@ export function AdminEditorShowcaseReviewPage({
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
const [detailEntry, setDetailEntry] =
useState<AdminEditorShowcaseAssetPayload | null>(null);
const [previewEntry, setPreviewEntry] =
useState<AdminEditorShowcaseAssetPayload | null>(null);
const [promptPreview, setPromptPreview] = useState<{
title: string;
prompt: string;
@@ -342,11 +343,15 @@ export function AdminEditorShowcaseReviewPage({
<td>
<button
className="admin-asset-query-thumb-button"
title="查看详情"
title="预览素材"
type="button"
onClick={() => setDetailEntry(entry)}
onClick={() => setPreviewEntry(entry)}
>
<AdminShowcaseThumbnail entry={entry} token={token} />
<AdminEditorAssetThumbnail
entry={entry}
token={token}
altPrefix="精选素材"
/>
</button>
<small>{entry.label || '-'}</small>
</td>
@@ -355,7 +360,9 @@ export function AdminEditorShowcaseReviewPage({
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
@@ -611,6 +618,7 @@ export function AdminEditorShowcaseReviewPage({
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPreview={(entry) => setPreviewEntry(entry)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
title: entry.label || entry.showcaseId,
@@ -620,6 +628,14 @@ export function AdminEditorShowcaseReviewPage({
/>
) : null}
{previewEntry ? (
<AdminEditorAssetPreviewDialog
entry={previewEntry}
token={token}
onClose={() => setPreviewEntry(null)}
/>
) : null}
{promptPreview ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
@@ -652,48 +668,18 @@ export function AdminEditorShowcaseReviewPage({
);
}
function AdminShowcaseThumbnail({
entry,
token,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
}) {
const isAudio = isAdminShowcaseAudioAsset(entry);
const imageSrc = useAdminResolvedAssetImageSrc(
token,
isAudio ? AUDIO_ASSET_COVER_SRC : entry.imageSrc,
isAudio ? null : entry.objectKey,
);
const alt = `精选素材:${entry.label || entry.showcaseId}`;
return imageSrc ? (
<img alt={alt} className="admin-asset-query-thumb" src={imageSrc} />
) : (
<div className="admin-asset-query-thumb admin-asset-query-thumb-placeholder" />
);
}
function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) {
const assetKind = entry.assetKind?.trim() ?? '';
return (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio' ||
/\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim())
);
}
function AdminShowcaseDetailDialog({
entry,
token,
onClose,
onPreview,
onPromptPreview,
onUnauthorized,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
onClose: () => void;
onPreview: (entry: AdminEditorShowcaseAssetPayload) => void;
onUnauthorized: (message?: string) => void;
onPromptPreview: (
entry: AdminEditorShowcaseAssetPayload,
@@ -723,7 +709,18 @@ function AdminShowcaseDetailDialog({
</button>
</div>
<div className="admin-asset-query-detail-layout">
<AdminShowcaseThumbnail entry={entry} token={token} />
<button
className="admin-asset-query-thumb-button admin-asset-query-detail-thumb-button"
title="预览素材"
type="button"
onClick={() => onPreview(entry)}
>
<AdminEditorAssetThumbnail
entry={entry}
token={token}
altPrefix="精选素材"
/>
</button>
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
<div className="admin-inline-identity">
@@ -815,77 +812,6 @@ function AdminInfoItem({
);
}
function useAdminResolvedAssetImageSrc(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const shouldResolve =
Boolean(normalizedObjectKey) || isGeneratedLegacyPath(normalizedImageSrc);
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
useEffect(() => {
if (!normalizedImageSrc && !normalizedObjectKey) {
setResolvedImageSrc('');
return;
}
if (!shouldResolve) {
setResolvedImageSrc(normalizedImageSrc);
return;
}
let cancelled = false;
setResolvedImageSrc('');
void getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_SHOWCASE_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedImageSrc,
expireSeconds: ADMIN_SHOWCASE_READ_EXPIRE_SECONDS,
},
)
.then(resolveAdminAssetReadSignedUrl)
.then((signedUrl) => {
if (!cancelled) {
setResolvedImageSrc(signedUrl);
}
})
.catch(() => {
if (!cancelled) {
setResolvedImageSrc('');
}
});
return () => {
cancelled = true;
};
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve, token]);
return resolvedImageSrc;
}
function normalizeAdminObjectKey(value: string | null | undefined) {
return value?.trim().replace(/^\/+/u, '') ?? '';
}
function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
const read = response.read ?? response;
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
}
function mergeShowcaseEntries(
current: AdminEditorShowcaseAssetPayload[],
incoming: AdminEditorShowcaseAssetPayload[],
@@ -4389,3 +4389,12 @@
- 运维与回滚:用 `scripts/gitea-ci-job-image.sh build|verify|export|load-runner` 管理镜像,按 `build/verify -> export 仓库外镜像归档和 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份 config -> 增加或替换 label -> docker restart --timeout 660` 切换。config 与镜像归档只放仓库外受控位置;共享文档只记录通用备份规则,不记录宿主绝对路径、注册信息或 token。重启后先验证真实 CI 再清理旧镜像;回滚先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。
- 影响范围:`.gitea/workflows/project-ci.yml``deploy/container/gitea-ci-job.Dockerfile``scripts/gitea-ci-job-image.sh``scripts/check-gitea-ci-job-image.sh``scripts/check-gitea-ci-job-runtime.sh`、runner label/config 和 Gitea CI 运维文档。
- 验证方式:构建脚本校验宿主与 runner 内层 Image ID 一致;环境脚本校验 Node、Rust、`rustfmt`、Chrome、bwrap、原生命令与 pkg-config 依赖;runtime 脚本执行完整 bwrap 和 Chrome headless canary;真实 PR 的四个 job 全部通过,同时复核 `Privileged=false``Binds=[]``MaskedPaths=[]``ReadonlyPaths=[]` 和独立网络。
## 2026-07-22 陶泥儿精选改为顺序循环分列 Masonry
- 背景:CSS multi-column 会按纵向高度平衡卡片,少量素材或活动卡与普通素材高度差较大时,桌面首行会只放一到两张,后续卡片提前从左侧下一段开始,无法满足“每行填满三张再换行”。
- 决策:`/creation` 陶泥儿精选保持平面 DOM 顺序,按当前列数将第 `index` 张循环分配到 `index % columns` 列。三列下第 1/2/3 张分别进入第 1/2/3 列,第 4/5/6 张再分别接续三列;不采用最短列贪心排序,避免同一组多张连续进入同一列。
- 宽度与高度边界:列数由精选容器实际宽度、0.92rem computed gap 和 288px 首选最小列宽共同决定,最多三列。卡片先获得目标列宽,再按真实 preview aspect ratio 和内容测量高度;同列紧凑堆叠,不拉伸、裁切或等待其它列高卡。循环分列只消除列内空洞,较短列在整个容器底部仍可有尾部高度差。
- 动态与可用性边界:`useLayoutEffect` 首次同步测量,`ResizeObserver + requestAnimationFrame` 在容器变宽、卡高变化、筛选重排和 cursor 追加后全量重排。只有当宽度、卡数和所有高度完整时才进入 absolute ready;否则保留 Grid fallback,防止卡片重叠和分页 sentinel 提前触发。DOM/Tab/读屏顺序始终不变,容器与卡片显式为 list/listitem。
- 兼容边界:保留现有 `.creation-landing__asset-waterfall` 类名、筛选、排序、cursor 分页、预览与点赞链路;只替换布局算法。该决策覆盖 2026-07-07 multi-column 及本日早先 row-major Grid 的布局部分,不改变精选仍是动态素材流的产品定位。
- 验证方式:纯函数测试锁定容器临界宽度、循环列序、列内 top 和容器高度;`src/index.test.ts` 锁定 Grid fallback 与 Masonry ready。Playwright 在同一 viewport 中变更容器宽度,核对 3/2/1 列、每列 gap、容器高度、DOM 顺序、无重叠/横溢出和 console/page error。
+30 -13
View File
@@ -63,6 +63,14 @@
- 验证:运行触达文件的定向 `vitest`,必要时追加 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联:`docs/technical/【前端测试】React组件测试准则-2026-06-26.md``src/components/image-editor/useCanvasGenerationDialogs.test.tsx``src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`
## 带 objectKey 的画布图片测试要等待换签后可见
- 现象:测试点击“添加素材”后,图层状态已经写入,但立即用 `getByAltText('画布图片:...')` 偶发或稳定找不到图片;前一张图可能通过,紧接着添加的第二张失败。
- 原因:带 `objectKey` 的画布图片通过 `useResolvedAssetReadUrl` 异步获取签名 URL`resolvedUrl` 就绪前不会渲染带 `alt``<img>``user.click` 只等待点击交互完成,不等待 effect 内的换签 Promise;前一张图在后续操作期间出现只是调度时机,不是同步契约。
- 处理:每次点击添加后分别用 `await screen.findByAltText(...)` 等待对应图片可见,再执行依赖该图层的下一步操作;不要用固定 sleep,也不要只等待最后一张图而让前面的断言依赖偶然调度。
- 验证:先精确运行目标用例并连续重复,再运行所在测试文件和完整前端测试;删除场景仍要保留 A/B 都消失、两个删除调用和撤销不恢复已删除素材的断言。
- 关联:`src/hooks/useResolvedAssetReadUrl.ts``src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`
## 图片画布素材库删除要匹配 sourceResourceId
- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。
@@ -87,13 +95,13 @@
- 验证:API 测试覆盖陶泥号组合条件、未知陶泥号、可信来源归组、`seconds.microsZ` / 极值游标和真实 / 归组 Task IDSpacetimeDB 测试覆盖资源删除后稳定归组、部分失败批次不抢占根任务、重复拆分有界无丢失和 owner 索引分支;后台页面测试覆盖逐字输入防抖、请求取消、刷新失败保留结果、筛选请求乱序、旧分页响应失效和“用户 ID / 陶泥号”请求。再运行 `cargo test -p api-server admin_editor_asset --manifest-path server-rs/Cargo.toml``cargo test -p spacetime-module admin_editor_asset --manifest-path server-rs/Cargo.toml``npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``server-rs/crates/api-server/src/admin.rs``server-rs/crates/spacetime-module/src/editor_project_storage.rs`
## 后台素材缩略图不要在首次挂载时全量换签
## 后台素材查询与精选审核缩略图不要在首次挂载时全量换签
- 现象:后台“素材查询”首批缩略图正常,继续向下滚动读取更多后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`
- 现象:后台“素材查询”或“精选审核”首批缩略图正常,继续向下滚动读取更多或一次加载较多审核项后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`
- 原因:列表一次挂载 80 条私有素材时,每个缩略图同时换签,会在同秒突发请求。production Nginx 的 `genarrative_admin_rps``30r/s burst=16`,超出部分在进入 api-server 前已返回 `429`,因此仅查 api-server 日志会漏掉失败请求。
- 处理:缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
- 验证:前端定向测试覆盖首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`
- 处理:素材查询与精选审核共用缩略图和预览组件;缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。`objectKey` 的绝对 OSS generated 地址先提取 legacy path 再换签。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
- 验证:前端定向测试覆盖两页共用换签组件、首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试、绝对 OSS 地址换签和点击缩略图打开媒体预览;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
- 关联:`apps/admin-web/src/components/AdminEditorAssetMedia.tsx``apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx` 及对应测试
## 陶泥儿精选重复先查同源同媒体画布副本
@@ -111,13 +119,14 @@
- 验证:`creationShowcaseModel.test.ts` 覆盖展示名优先、陶泥号兜底和内部 owner/user id 不展示;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留公开作者字段;若改动 SpacetimeDB read model,再运行 `npm run spacetime:generate``cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-client/src/mapper/editor_project.rs``server-rs/crates/api-server/src/editor_project.rs``src/components/creation-home/creationShowcaseModel.ts`
## 陶泥儿精选瀑布流变宽先查 multi-column 容器宽度
## 陶泥儿精选顺序分列不要用 multi-column 或共享 Grid 行高
- 现象:release `/creation` 桌面端精选卡片明显变宽,第三列被裁到屏幕外,页面内部可横向滑动;dev 看起来正常
- 原因:精选瀑布流使用 `column-count`,当它作为 grid item 时如果没有显式 `width: 100%` / `min-width: 0`Chrome 会用多列内容的 intrinsic width 反向撑开 grid track。线上实测 1920 视口下 section 为 `1296px`waterfall 被撑到约 `2048px`,单卡宽约 `672px`
- 处理:保留 multi-column 瀑布流时,`.creation-landing__asset-waterfall` 必须显式约束 `width: 100%``min-width: 0`;不要只看单张图片天然尺寸或改卡片宽度
- 验证:Playwright / CSSOM 检查 `.creation-landing__section``.creation-landing__asset-waterfall`、首张 `.creation-landing__asset-card``getBoundingClientRect()`waterfall 宽度应等于 section 宽度
- 关联:`src/index.css``src/components/creation-home/CreationLandingView.tsx`
- 现象:`/creation` 桌面端主内容区明明能放下三张卡,首行却只出现一到两张,后续素材提前回到左侧下一段;活动卡与普通素材高度差较大时尤其明显
- 原因:`column-count` 按纵向文章列流入并平衡,三张卡可排成 `2 + 1 + 0` 列;标准 CSS Grid 虽会横向先填三张,但整行共用最高卡行轨,短卡下会留下高度差。`align-items: start` 只是不拉伸短卡,不能消除行轨空白;`grid-auto-flow: dense` 也不能填单个网格项内的剩余高度
- 处理:保留平面 DOM 和 `.creation-landing__asset-waterfall` 旧类名,按当前列数将第 `index` 张显式放入 `index % columns` 列;三列时第 1/2/3 张分别进入三列,第 4/5/6 张再分别接到三列下方。列数以容器实际宽度和 288px 首选最小列宽计算,不以 viewport 硬切;先设目标卡宽再测真实卡高,列内用 computed gap 紧凑堆叠
- 动态边界:只有当容器宽度、卡数和所有卡高有效时才进入 absolute Masonry ready;否则保留 Grid fallback。`ResizeObserver + requestAnimationFrame` 在容器变宽、图片/字体/文本改变高度、筛选重排和 cursor 追加后全量重算;cleanup 必须兼容 StrictMode,避免分页 sentinel 因容器短暂零高提前触发
- 验证:纯函数测试锁定容器宽度临界值、`index % columns` 和最高列容器高;样式契约同时锁定 Grid fallback 与 Masonry ready。Playwright 需在同一 viewport 内改容器宽度验证 3/2/1 列,检查每列相邻卡间距等于 gap、容器高等于最高列底、DOM 顺序不变且无重叠/横向溢出
- 关联:`src/index.css``src/index.test.ts``src/components/creation-home/CreationLandingView.tsx``src/components/creation-home/showcaseMasonryLayout.ts`
## 画板外部生成排队超时不是失败
@@ -147,10 +156,18 @@
- 现象:主站或 External API 只要拿到另一个账号的 generated `objectKey` 就能换签,已登记的私有对象因为 key 同时命中 legacy 前缀而被匿名读取,或者 `/api/assets/read-url` 已拒绝但 `/api/assets/read-bytes` 仍能读出原始字节;后台资源预览为解决跨账号读取又误把主站入口整体放开。
- 原因:`legacyPublicPath``objectKey` 代表两种不同信任边界。前者仅用于未登记历史公开作品兼容,后者是正式对象引用;只检查 generated 前缀、或在查询 `asset_object` metadata 前直接接受 legacy 白名单,都不能证明对象公开或属于调用方。签名 URL 和 bytes proxy 如果各写一套判断也容易漂移。
- 处理:`read-url``read-bytes` 必须共用 `authorize_asset_read_target`,先按配置 bucket / 精确 key 查询 `asset_object`metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 `PublicRead` / owner ACL。只有 metadata 不存在且显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` 时才允许匿名兼容;任意 `objectKey` 必须登记。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 `admin_asset_read_url` 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。
- 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记 objectKey、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 `read-url``read-bytes` 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。
- 处理:`read-url``read-bytes` 必须共用 `authorize_asset_read_target`,先按配置 bucket / 精确 key 查询 `asset_object`metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 `PublicRead` / owner ACL。只有 metadata 不存在且显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` 时才允许匿名兼容;普通 `objectKey` 必须登记。唯一窄例外是历史精选活动卡:`global` 配置已启用、请求 key 位于 `generated-character-drafts/editor/showcase-campaign/` 且与当前 `image_object_key` 精确匹配时,可在 metadata 缺失期间派生公开读取,禁用或换图后旧 key 立即失效;新上传活动卡仍必须 confirm。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 `admin_asset_read_url` 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。
- 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记普通 objectKey、活动卡当前/禁用/替换/越目录 exact key、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 `read-url``read-bytes` 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。
- 关联:`server-rs/crates/api-server/src/assets.rs``server-rs/crates/api-server/src/external_assets_api.rs``server-rs/crates/api-server/src/admin.rs``server-rs/crates/api-server/src/modules/admin.rs`
## 精选活动卡上传成功但网站空图先查对象确认和 exact grant
- 现象:后台精选活动卡能保存标题、作者、尺寸和图片地址,`GET /api/editor/showcase/resources` 也返回已启用 campaign,但网站卡片只有占位区域,没有 `<img>`Network 中活动卡的 `/api/assets/read-url?objectKey=...` 返回 `404 资源不存在或无权访问`
- 原因:活动卡旧上传链路只完成 signed POST 并保存 `imageSrc + imageObjectKey`,没有调用 object confirm;同时公开授权只扫描普通 `editor_showcase_asset`,没有识别当前活动卡。前端见到 `imageObjectKey` 后会优先走正式 objectKey 换签,失败时按安全规则保持空 src,不回退裸 private 路径。
- 处理:后台上传必须按 ticket -> OSS POST -> `/admin/api/editor-showcase/campaign/image-upload-confirm` -> 写回表单执行,confirm 复用统一 OSS HEAD、bucket/长度校验和 `asset_object` upsert,并由管理员会话绑定 owner、强制 private/固定 asset kind/活动卡专用目录。读取 procedure 在同一事务中对当前 enabled global campaign 的专用目录 exact key 派生授权,使历史未登记当前卡无需重新上传即可恢复;api-server 仅接受 procedure 明确返回的这一 grant,其他未登记 objectKey 继续 404。
- 验证:SpacetimeDB 测试覆盖 current key、disabled、missing key、replaced old key、越目录 key 和普通精选 grantapi-server 测试覆盖 metadata 缺失时 exact grant 可读、无 grant 仍 404、confirm 路径/MIME/大小/固定 private 约束;admin-web 测试锁定 ticket -> OSS -> confirm 顺序。真实浏览器应看到活动卡图片,Network 中 objectKey 换签返回 200,禁用或换图后旧 key 返回 404。
- 关联:`apps/admin-web/src/api/adminApiClient.ts``server-rs/crates/api-server/src/admin.rs``server-rs/crates/api-server/src/assets.rs``server-rs/crates/spacetime-module/src/asset_metadata/objects.rs``server-rs/crates/spacetime-module/src/editor_project_storage.rs`
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
File diff suppressed because one or more lines are too long
@@ -75,11 +75,11 @@
### 陶泥儿精选
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容
`陶泥儿精选` 是页面底部的全站公开画布生成素材流,不承载玩法入口列表。列表按 DOM 顺序做循环分列:当前为三列时,第 1 / 2 / 3 张分别进入第 1 / 2 / 3 列,第 4 / 5 / 6 张再分别接到这三列下方,后续持续循环;不用最短列贪心分配改变每组的列归属。列数根据精选容器真实可用宽度自动在三、二、一列之间切换,不以整个 viewport 宽度硬判断。卡片按真实素材宽高设置预览比例,每张紧贴本列上一张且保留正常 gap,不拉伸、裁成等高卡,也不使用 multi-column 的纵向平衡或标准 Grid 的共享行高
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等;批准状态、钱包返还流水和 `refund_completed_at` 必须在同一个 SpacetimeDB 事务中完成,任一步失败都保持待审核,历史已通过但未返还记录重复批准时按同一流水补齐且不得重复入账。已通过、已开启展示且返还完成的精选快照必须按同 owner 的精确 `assetObjectId``objectKey` 派生匿名读取授权,不能放开整个 `generated-*` 前缀。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选
精选素材流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有列表,而不是固定只展示首屏数量。追加、筛选、分类和容器/卡片尺寸变化都必须按当前完整 DOM 顺序重算循环分列;位置未完整时保留正常文档流 fallback,不能让分页 sentinel 因容器短暂零高提前触发。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选;活动卡作为第一张进入第一列,普通素材从第二张继续同一循环列序。活动卡图片继续保持 private:后台直传 OSS 后必须先确认正式 `asset_object`,公开读取只由已启用 `global` 配置对活动卡专用目录中的当前 exact `imageObjectKey` 派生,禁用或替换图片后旧 key 不再可读;历史缺 metadata 的当前活动卡可通过同一 exact 配置授权恢复,但新上传不能跳过 confirm
Tab
@@ -122,6 +122,7 @@ Tab
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。素材不可提交或后端拒绝提交时,菜单必须显示具体原因,例如非生成素材、素材尚未保存、没有媒体内容或审核未通过不能重复提交。
- 后台新增纯素材查询页,读取账号级 `editor_asset``sourceType="generated"` 的素材,用于查看所有用户生成素材。该页只提供时间、用户 ID 和关键词查询,以及缩略图详情、提示词全文、生成成本、作者展示名和陶泥号查看;不提供分类筛选,也不承载审核、返还、展示开关或活动卡配置操作。
- 后台“素材查询”和“精选审核”必须共用同一套素材缩略图、媒体类型判断、私有地址换签与放大预览弹窗:缩略图只在进入视口附近后错峰换签,遇到管理端限流时执行有上限的退避重试;点击缩略图直接打开图片、音频或视频预览,详情继续由独立“详情”按钮进入。无 `objectKey` 的历史绝对 OSS 地址也必须先归一为 generated legacy path 再换签,不能把私有裸地址直接交给浏览器。
- 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。
- 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。
@@ -163,7 +164,7 @@ Tab
### 创作主页
- 新增 `CreationLandingView`
- 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选素材瀑布流。
- 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选顺序循环分列素材流。
- 九大功能区卡片必须是可点击按钮;已开放卡片复用 `createEditorProject` 和画布现有生成器,不新增入口系统;暂未开放卡片复用移动端欢迎弹窗样式。
- 桌面端复用现有平台壳层,保留上方栏和左侧导航栏。
- 主页整体采用浅色布局,避免外站品牌文案。
@@ -222,8 +223,8 @@ git diff --check
- 移动端进入 `/` 首页时显示 `欢迎` 弹窗,正文包含移动端仅支持作品展示和电脑端访问提示,按钮为 `好`
- 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`
- “我的”页没有项目快捷入口。
- 陶泥儿精选素材瀑布流不出现账号级非项目素材、公开作品补充、上传素材、mock 素材、假作者或假泥点成本。
- 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格
- 陶泥儿精选素材流不出现账号级非项目素材、公开作品补充、上传素材、mock 素材、假作者或假泥点成本。
- 陶泥儿精选按 DOM 索引循环分列,每组卡片依次占用当前所有列,列内不因其它列的高卡留空;卡片按真实素材宽高呈现,不强制固定等高。列数必须根据精选容器实际宽度自动变为三/二/一列,不以 viewport 断点代替容器宽度
- 陶泥儿精选不出现玩法入口卡或公开作品图片补充展示。
## 非目标
+163 -13
View File
@@ -21,7 +21,7 @@ use reqwest::Client;
use serde::Deserialize;
use serde_json::{Map, Value};
use shared_contracts::admin::{
ADMIN_TAB_PERMISSIONS, AdminAccountRole,
ADMIN_TAB_PERMISSIONS, AdminAccountRole, AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminDashboardBreakdownRowPayload,
AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload,
@@ -50,7 +50,8 @@ use shared_contracts::admin::{
AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse,
};
use shared_contracts::assets::{
CreateDirectUploadTicketRequest, DirectUploadObjectAccess, GetReadUrlQuery,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, CreateDirectUploadTicketRequest,
DirectUploadObjectAccess, GetReadUrlQuery,
};
#[cfg(any())]
use shared_contracts::creation_entry_config::{
@@ -71,8 +72,8 @@ use crate::{
admin_accounts::normalize_admin_account_username,
api_response::json_success_body,
assets::{
AssetReadAuthorization, create_direct_upload_ticket_for_owner,
get_asset_read_url_with_query,
AssetReadAuthorization, confirm_asset_object_for_owner,
create_direct_upload_ticket_for_owner, get_asset_read_url_with_query,
},
editor_generation_config::EditorGenerationPricingConfig,
editor_project::current_utc_micros,
@@ -113,6 +114,9 @@ const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366;
const ADMIN_EDITOR_ASSET_DEFAULT_LIMIT: u32 = 80;
const ADMIN_EDITOR_ASSET_MAX_LIMIT: u32 = 200;
const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024;
const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX: &str =
"generated-character-drafts/editor/showcase-campaign/";
const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_ASSET_KIND: &str = "editor_showcase_campaign_image";
#[derive(Clone, Debug)]
pub struct AuthenticatedAdmin {
@@ -903,6 +907,69 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_ticket(
))
}
pub async fn admin_confirm_editor_showcase_campaign_image_upload(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminConfirmEditorShowcaseCampaignImageUploadRequest>,
) -> Result<Json<Value>, AppError> {
let confirm_request = build_admin_editor_showcase_campaign_image_confirm_request(payload)?;
confirm_asset_object_for_owner(
&state,
&request_context,
admin.session().subject.as_str(),
confirm_request,
)
.await
}
fn build_admin_editor_showcase_campaign_image_confirm_request(
payload: AdminConfirmEditorShowcaseCampaignImageUploadRequest,
) -> Result<ConfirmAssetObjectRequest, AppError> {
let bucket = payload.bucket.trim();
if bucket.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("活动卡图片 bucket 不能为空"));
}
let object_key = payload.object_key.trim().trim_start_matches('/');
if !object_key.starts_with(ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX)
|| object_key.len() == ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX.len()
{
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("活动卡图片 Object Key 不属于后台活动卡目录"));
}
let content_type = payload.content_type.trim();
if !content_type.starts_with("image/") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡只能确认图片对象")
);
}
if payload.content_length == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能为空")
);
}
if payload.content_length > ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能超过 20MB")
);
}
Ok(ConfirmAssetObjectRequest {
bucket: Some(bucket.to_string()),
object_key: object_key.to_string(),
content_type: Some(content_type.to_string()),
content_length: Some(payload.content_length),
content_hash: None,
asset_kind: ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_ASSET_KIND.to_string(),
access_policy: Some(ConfirmAssetObjectAccessPolicy::Private),
source_job_id: None,
owner_user_id: None,
profile_id: None,
entity_id: None,
})
}
#[cfg(any())]
fn build_admin_creation_entry_config_response(
config: shared_contracts::creation_entry_config::CreationEntryConfigResponse,
@@ -989,6 +1056,7 @@ fn admin_editor_showcase_asset_payload_from_record(
task_id: record.task_id,
asset_kind: record.asset_kind,
generation_inputs: record.generation_inputs,
thumbnail_src: record.thumbnail_src,
generation_cost_mud_points: record.generation_cost_mud_points,
refund_mud_points: record.refund_mud_points,
review_status: record.review_status,
@@ -3940,9 +4008,9 @@ mod tests {
admin_editor_asset_group_payload, admin_editor_showcase_asset_payload_from_record,
append_spacetime_sql_response_chunk, apply_admin_database_table_filters,
build_admin_asset_read_url_audit, build_admin_dashboard_chart,
build_admin_database_table_row, build_admin_tracking_event_keys_sql,
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
build_spacetime_schema_url, clamp_admin_database_table_limit,
build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request,
build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview,
build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit,
clamp_admin_tracking_event_limit, enforce_admin_request_permission,
finalize_admin_database_table_rows_response, group_admin_editor_asset_records,
hash_admin_password, is_admin_account_not_found, is_safe_spacetime_table_name,
@@ -3961,9 +4029,10 @@ mod tests {
};
use serde_json::json;
use shared_contracts::admin::{
AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminDashboardQuery,
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
};
use shared_contracts::assets::GetReadUrlQuery;
use shared_contracts::assets::{ConfirmAssetObjectAccessPolicy, GetReadUrlQuery};
use spacetime_client::{
AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord,
AdminDashboardPaymentConversionRecord, AdminDashboardRetentionRecord,
@@ -4070,6 +4139,83 @@ mod tests {
assert!(draft.metadata.get("signedUrl").is_none());
}
#[test]
fn campaign_image_confirm_is_scoped_to_private_activity_card_objects() {
let request = build_admin_editor_showcase_campaign_image_confirm_request(
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: " genarrative-release ".to_string(),
object_key:
" /generated-character-drafts/editor/showcase-campaign/current/card.png "
.to_string(),
content_type: " image/png ".to_string(),
content_length: 1024,
},
)
.expect("valid activity card upload should build a confirm request");
assert_eq!(request.bucket.as_deref(), Some("genarrative-release"));
assert_eq!(
request.object_key,
"generated-character-drafts/editor/showcase-campaign/current/card.png"
);
assert_eq!(request.content_type.as_deref(), Some("image/png"));
assert_eq!(request.content_length, Some(1024));
assert_eq!(request.asset_kind, "editor_showcase_campaign_image");
assert_eq!(
request.access_policy,
Some(ConfirmAssetObjectAccessPolicy::Private)
);
assert!(request.owner_user_id.is_none());
}
#[test]
fn campaign_image_confirm_rejects_objects_outside_activity_card_directory() {
let error = build_admin_editor_showcase_campaign_image_confirm_request(
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key: "generated-character-drafts/editor/asset-library/private.png"
.to_string(),
content_type: "image/png".to_string(),
content_length: 1024,
},
)
.expect_err("unrelated editor objects must not use the activity card confirm route");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
}
#[test]
fn campaign_image_confirm_rejects_invalid_media_and_size() {
for payload in [
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key: "generated-character-drafts/editor/showcase-campaign/current/card.txt"
.to_string(),
content_type: "text/plain".to_string(),
content_length: 1024,
},
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key: "generated-character-drafts/editor/showcase-campaign/current/empty.png"
.to_string(),
content_type: "image/png".to_string(),
content_length: 0,
},
AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key:
"generated-character-drafts/editor/showcase-campaign/current/oversized.png"
.to_string(),
content_type: "image/png".to_string(),
content_length: super::ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES + 1,
},
] {
let error = build_admin_editor_showcase_campaign_image_confirm_request(payload)
.expect_err("invalid activity card uploads must fail before OSS HEAD");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
}
}
#[test]
fn admin_editor_showcase_asset_payload_resolves_author_profile() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -4078,10 +4224,10 @@ mod tests {
.ensure_orphan_work_owner_user("user-author-1", "author-1", "作者昵称", "SY-00000042")
.expect("author should be inserted");
let payload = admin_editor_showcase_asset_payload_from_record(
&state,
test_admin_editor_showcase_asset_record("user-author-1"),
);
let mut record = test_admin_editor_showcase_asset_record("user-author-1");
record.thumbnail_src =
Some("/generated-character-drafts/editor/spec-thumb.png".to_string());
let payload = admin_editor_showcase_asset_payload_from_record(&state, record);
assert_eq!(payload.owner_user_id, "user-author-1");
assert_eq!(payload.author_display_name.as_deref(), Some("作者昵称"));
@@ -4089,6 +4235,10 @@ mod tests {
payload.author_public_user_code.as_deref(),
Some("SY-00000042")
);
assert_eq!(
payload.thumbnail_src.as_deref(),
Some("/generated-character-drafts/editor/spec-thumb.png")
);
}
#[test]
+55 -1
View File
@@ -686,7 +686,23 @@ async fn authorize_asset_read_target(
})
.await
.map_err(map_asset_read_authorization_error)?;
if let Some(asset_object) = asset_object.as_ref() {
resolve_asset_read_access(
configured_bucket,
target,
authorization,
asset_object.as_ref(),
public_work_granted,
)
}
fn resolve_asset_read_access(
configured_bucket: &str,
target: &AssetReadTarget,
authorization: &AssetReadAuthorization,
asset_object: Option<&module_assets::AssetObjectRecord>,
public_work_granted: bool,
) -> Result<AssetReadAccessScope, AppError> {
if let Some(asset_object) = asset_object {
return require_asset_object_read_access(
asset_object,
configured_bucket,
@@ -696,6 +712,12 @@ async fn authorize_asset_read_target(
);
}
// 当前启用的活动卡可能来自历史上传,尚无 asset_object metadata
// SpacetimeDB 只会对配置中的 exact object key 派生该授权,禁用或换图后立即失效。
if public_work_granted {
return Ok(AssetReadAccessScope::Public);
}
// 已登记对象始终服从 metadata ACL;只有没有 metadata 的历史资源才走公开前缀兼容。
if target.is_legacy_public_path && is_supported_legacy_public_object_key(&target.object_key) {
return Ok(AssetReadAccessScope::Public);
@@ -1120,6 +1142,38 @@ mod tests {
);
}
#[test]
fn current_campaign_grant_allows_exact_object_key_without_legacy_metadata() {
let target = super::AssetReadTarget {
object_key: "generated-character-drafts/editor/showcase-campaign/current/image.png"
.to_string(),
is_legacy_public_path: false,
};
assert!(matches!(
super::resolve_asset_read_access(
"genarrative-assets",
&target,
&super::AssetReadAuthorization::Anonymous,
None,
true,
),
Ok(super::AssetReadAccessScope::Public)
));
assert_eq!(
super::resolve_asset_read_access(
"genarrative-assets",
&target,
&super::AssetReadAuthorization::Anonymous,
None,
false,
)
.expect_err("unregistered object key without an exact grant should stay hidden")
.status_code(),
StatusCode::NOT_FOUND
);
}
#[test]
fn public_asset_read_url_expiry_is_capped_but_privileged_expiry_is_preserved() {
assert_eq!(
@@ -5,6 +5,7 @@ use axum::{
use crate::{
admin::{
admin_confirm_editor_showcase_campaign_image_upload,
admin_create_editor_showcase_campaign_image_upload_ticket, admin_dashboard,
admin_debug_http, admin_get_asset_read_url, admin_get_editor_generation_pricing,
admin_get_editor_showcase_campaign, admin_get_feature_gate_config,
@@ -174,6 +175,12 @@ pub fn router(state: AppState) -> Router<AppState> {
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/editor-showcase/campaign/image-upload-confirm",
post(admin_confirm_editor_showcase_campaign_image_upload).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/profile/redeem-codes",
get(admin_list_profile_redeem_codes)
+40 -1
View File
@@ -278,6 +278,7 @@ pub struct AdminEditorShowcaseAssetPayload {
pub task_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs: Option<Value>,
pub thumbnail_src: Option<String>,
pub generation_cost_mud_points: u64,
pub refund_mud_points: u64,
pub review_status: String,
@@ -374,6 +375,15 @@ pub struct AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
pub upload: AdminDirectUploadTicketPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminConfirmEditorShowcaseCampaignImageUploadRequest {
pub bucket: String,
pub object_key: String,
pub content_type: String,
pub content_length: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDirectUploadTicketPayload {
@@ -989,7 +999,10 @@ pub struct AdminWalletRestrictionResponse {
mod tests {
use serde_json::json;
use super::{AdminEditorShowcaseAssetPayload, AdminRechargeRefundManualReviewResolveRequest};
use super::{
AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminEditorShowcaseAssetPayload,
AdminRechargeRefundManualReviewResolveRequest,
};
#[test]
fn refund_manual_review_resolution_request_uses_camel_case_contract() {
@@ -1008,6 +1021,26 @@ mod tests {
assert!(value.get("out_refund_no").is_none());
}
#[test]
fn editor_showcase_campaign_image_confirm_uses_camel_case_contract() {
let value = serde_json::to_value(AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: "genarrative-release".to_string(),
object_key: "generated-character-drafts/editor/showcase-campaign/current/card.png"
.to_string(),
content_type: "image/png".to_string(),
content_length: 1024,
})
.expect("request should serialize");
assert_eq!(
value["objectKey"],
json!("generated-character-drafts/editor/showcase-campaign/current/card.png")
);
assert_eq!(value["contentType"], json!("image/png"));
assert_eq!(value["contentLength"], json!(1024));
assert!(value.get("object_key").is_none());
}
#[test]
fn editor_showcase_asset_payload_serializes_author_fields_as_camel_case() {
let payload = AdminEditorShowcaseAssetPayload {
@@ -1028,6 +1061,7 @@ mod tests {
task_id: None,
asset_kind: Some("character".to_string()),
generation_inputs: None,
thumbnail_src: Some("/generated-character-drafts/editor/spec-thumb.png".to_string()),
generation_cost_mud_points: 12,
refund_mud_points: 6,
review_status: "pending".to_string(),
@@ -1050,7 +1084,12 @@ mod tests {
assert_eq!(value["authorDisplayName"], json!("作者昵称"));
assert_eq!(value["authorPublicUserCode"], json!("SY-00000042"));
assert_eq!(
value["thumbnailSrc"],
json!("/generated-character-drafts/editor/spec-thumb.png")
);
assert!(value.get("author_display_name").is_none());
assert!(value.get("author_public_user_code").is_none());
assert!(value.get("thumbnail_src").is_none());
}
}
@@ -134,12 +134,12 @@ pub fn get_asset_read_access_by_location_and_return(
tx, caller,
)?;
let record = find_asset_object_by_location(tx, &input)?;
let public_work_granted = record.as_ref().is_some_and(|asset_object| {
crate::editor_project_storage::asset_object_has_public_showcase_read_grant(
let public_work_granted =
crate::editor_project_storage::asset_location_has_public_showcase_read_grant(
tx,
asset_object,
)
});
input.object_key.as_str(),
record.as_ref(),
);
Ok((record, public_work_granted))
}) {
Ok((record, public_work_granted)) => AssetObjectReadAccessProcedureResult {
@@ -36,6 +36,8 @@ const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending";
const EDITOR_SHOWCASE_STATUS_APPROVED: &str = "approved";
const EDITOR_SHOWCASE_STATUS_REJECTED: &str = "rejected";
const EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID: &str = "global";
const EDITOR_SHOWCASE_CAMPAIGN_OBJECT_KEY_PREFIX: &str =
"generated-character-drafts/editor/showcase-campaign/";
const EDITOR_GENERATION_PRICING_CONFIG_ID: &str = "global";
const EDITOR_SHOWCASE_CATEGORIES: [&str; 4] = ["characters", "ui", "music", "marketing"];
const EDITOR_GENERATION_PRICING_UNIT_PER_GENERATION: &str = "perGeneration";
@@ -2926,6 +2928,58 @@ pub(crate) fn asset_object_has_public_showcase_read_grant(
})
}
pub(crate) fn asset_location_has_public_showcase_read_grant(
ctx: &ReducerContext,
object_key: &str,
asset_object: Option<&module_assets::AssetObjectUpsertSnapshot>,
) -> bool {
let asset_object_granted = asset_object
.is_some_and(|asset_object| asset_object_has_public_showcase_read_grant(ctx, asset_object));
let campaign_config = ctx
.db
.editor_showcase_campaign_config()
.config_id()
.find(&EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID.to_string());
location_has_public_showcase_read_grant(
campaign_config.as_ref(),
object_key,
asset_object_granted,
)
}
fn location_has_public_showcase_read_grant(
campaign_config: Option<&EditorShowcaseCampaignConfig>,
object_key: &str,
asset_object_granted: bool,
) -> bool {
if asset_object_granted {
return true;
}
let Some(object_key) = normalized_showcase_object_key(Some(object_key)) else {
return false;
};
if !object_key
.strip_prefix(EDITOR_SHOWCASE_CAMPAIGN_OBJECT_KEY_PREFIX)
.is_some_and(|suffix| !suffix.is_empty())
{
return false;
}
campaign_config.is_some_and(|config| {
config.enabled
&& normalized_showcase_object_key(config.image_object_key.as_deref())
== Some(object_key)
})
}
fn normalized_showcase_object_key(value: Option<&str>) -> Option<&str> {
value
.map(str::trim)
.map(|value| value.trim_start_matches('/'))
.filter(|value| !value.is_empty())
}
fn is_public_showcase_asset(asset: &EditorShowcaseAsset) -> bool {
asset.review_status == EDITOR_SHOWCASE_STATUS_APPROVED
&& asset.display_enabled
@@ -9093,6 +9147,27 @@ mod tests {
}
}
fn showcase_campaign_config(
enabled: bool,
image_object_key: Option<&str>,
) -> EditorShowcaseCampaignConfig {
EditorShowcaseCampaignConfig {
config_id: EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID.to_string(),
enabled,
title: "活动卡".to_string(),
image_src: "/generated-character-drafts/editor/showcase-campaign/current.png"
.to_string(),
prompt: String::new(),
author: String::new(),
cost_text: String::new(),
updated_by_admin_user_id: Some("admin".to_string()),
updated_at: Timestamp::from_micros_since_unix_epoch(1_000_000),
image_object_key: image_object_key.map(str::to_string),
image_width: Some(1200),
image_height: Some(800),
}
}
fn tiered_pricing(model: &str, unit: &str, tiers: &[&str]) -> EditorGenerationModelPricing {
EditorGenerationModelPricing {
model: model.to_string(),
@@ -9522,6 +9597,91 @@ mod tests {
}));
}
#[test]
fn campaign_read_grant_matches_only_enabled_current_object_key_without_metadata() {
let campaign = showcase_campaign_config(
true,
Some(" /generated-character-drafts/editor/showcase-campaign/current.png "),
);
assert!(location_has_public_showcase_read_grant(
Some(&campaign),
"generated-character-drafts/editor/showcase-campaign/current.png",
false,
));
assert!(!location_has_public_showcase_read_grant(
Some(&campaign),
"generated-character-drafts/editor/showcase-campaign/previous.png",
false,
));
assert!(!location_has_public_showcase_read_grant(
Some(&campaign),
"generated-character-drafts/editor/showcase-campaign/current.png.backup",
false,
));
assert!(!location_has_public_showcase_read_grant(
Some(&campaign),
"generated-character-drafts/editor/asset-library/current.png",
false,
));
}
#[test]
fn campaign_read_grant_is_revoked_when_disabled_or_key_is_missing() {
let disabled = showcase_campaign_config(
false,
Some("generated-character-drafts/editor/showcase-campaign/current.png"),
);
let missing_key = showcase_campaign_config(true, None);
assert!(!location_has_public_showcase_read_grant(
Some(&disabled),
"generated-character-drafts/editor/showcase-campaign/current.png",
false,
));
assert!(!location_has_public_showcase_read_grant(
Some(&missing_key),
"generated-character-drafts/editor/showcase-campaign/current.png",
false,
));
}
#[test]
fn campaign_key_replacement_revokes_the_previous_key() {
let current = showcase_campaign_config(
true,
Some("generated-character-drafts/editor/showcase-campaign/current.png"),
);
assert!(!location_has_public_showcase_read_grant(
Some(&current),
"generated-character-drafts/editor/showcase-campaign/previous.png",
false,
));
assert!(location_has_public_showcase_read_grant(
Some(&current),
"generated-character-drafts/editor/showcase-campaign/current.png",
false,
));
}
#[test]
fn ordinary_showcase_asset_grant_remains_effective_without_campaign_match() {
assert!(location_has_public_showcase_read_grant(
None,
"generated-characters/editor/asset-1.png",
true,
));
assert!(location_has_public_showcase_read_grant(
Some(&showcase_campaign_config(
true,
Some("generated-character-drafts/editor/showcase-campaign/current.png"),
)),
"generated-characters/editor/asset-1.png",
true,
));
}
#[test]
fn editor_asset_task_group_uses_final_output_as_primary() {
let assets = vec![
@@ -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;
}
@@ -826,9 +826,9 @@ describe('ImageCanvasEditorView asset library integration', () => {
await user.click(
await screen.findByRole('button', { name: '添加账号素材A' }),
);
expect(await screen.findByAltText('画布图片:账号素材A')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '添加账号素材B' }));
expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy();
expect(screen.getByAltText('画布图片:账号素材B')).toBeTruthy();
expect(await screen.findByAltText('画布图片:账号素材B')).toBeTruthy();
fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' });
expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty(
+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', () => {
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
minThreads: 1,
maxThreads: 8,
include: [
'src/index.test.ts',
'src/routing/activeAppPageRoutes.test.ts',
'src/routing/activeAppRoutes.test.ts',
'src/services/activeAppTitle.test.ts',