修复陶泥儿精选点赞刷新状态
新增 viewer 点赞读写投影并保留服务身份鉴权 公开接口返回权威 viewerLiked 并禁止个性化响应缓存 前端刷新和账号切换后重载点赞状态并丢弃过期响应 删除旧点赞返回 procedure 与 facade 并同步生成绑定和文档 补充点赞持久化、鉴权、竞态及失败状态测试
This commit is contained in:
@@ -679,6 +679,7 @@ describe('CreationLandingView', () => {
|
||||
toggleEditorShowcaseAssetLikeMock.mockResolvedValueOnce({
|
||||
showcaseId: 'showcase-character',
|
||||
likeCount: 4,
|
||||
viewerLiked: true,
|
||||
});
|
||||
|
||||
renderCreationLanding();
|
||||
@@ -712,6 +713,287 @@ describe('CreationLandingView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('restores the server-provided liked state after remount', async () => {
|
||||
const likedResource = {
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-character',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
showcaseCategory: 'characters',
|
||||
likeCount: 4,
|
||||
viewerLiked: true,
|
||||
};
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValue([likedResource]);
|
||||
|
||||
const firstRender = renderCreationLanding();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
firstRender.unmount();
|
||||
|
||||
renderCreationLanding();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits for the server before changing like state and count', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveLike!: (value: {
|
||||
showcaseId: string;
|
||||
likeCount: number;
|
||||
viewerLiked: boolean;
|
||||
}) => void;
|
||||
const likeResult = new Promise<{
|
||||
showcaseId: string;
|
||||
likeCount: number;
|
||||
viewerLiked: boolean;
|
||||
}>((resolve) => {
|
||||
resolveLike = resolve;
|
||||
});
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-character',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
likeCount: 3,
|
||||
viewerLiked: false,
|
||||
},
|
||||
]);
|
||||
toggleEditorShowcaseAssetLikeMock.mockReturnValueOnce(likeResult);
|
||||
|
||||
renderCreationLanding();
|
||||
const likeButton = await screen.findByRole('button', {
|
||||
name: '点赞:角色英雄',
|
||||
});
|
||||
await user.click(likeButton);
|
||||
|
||||
expect((likeButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(likeButton.getAttribute('aria-busy')).toBe('true');
|
||||
expect(screen.getByRole('button', { name: '点赞:角色英雄' })).toBeTruthy();
|
||||
expect(within(likeButton).getByText('3')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveLike({
|
||||
showcaseId: 'showcase-character',
|
||||
likeCount: 4,
|
||||
viewerLiked: true,
|
||||
});
|
||||
await likeResult;
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('4')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves the prior like state and announces a write failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-character',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
likeCount: 3,
|
||||
viewerLiked: false,
|
||||
},
|
||||
]);
|
||||
toggleEditorShowcaseAssetLikeMock.mockRejectedValueOnce(
|
||||
new Error('write failed'),
|
||||
);
|
||||
|
||||
renderCreationLanding();
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '点赞:角色英雄' }),
|
||||
);
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'点赞失败,请重试',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('3')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not fall back to an anonymous showcase read while auth is recovering', async () => {
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
const recoveringAuth = createAuthValue({ canAccessProtectedData: false });
|
||||
const readyAuth = createAuthValue({ canAccessProtectedData: true });
|
||||
const result = renderCreationLanding({ authValue: recoveringAuth });
|
||||
|
||||
await act(async () => Promise.resolve());
|
||||
expect(listPublicEditorProjectResourcesMock).not.toHaveBeenCalled();
|
||||
|
||||
result.rerender(
|
||||
<AuthUiContext.Provider value={readyAuth}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledWith({
|
||||
viewer: 'authenticated',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores an old account showcase response after an account switch', async () => {
|
||||
let resolveOldPage!: (value: unknown[]) => void;
|
||||
const oldPage = new Promise<unknown[]>((resolve) => {
|
||||
resolveOldPage = resolve;
|
||||
});
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
listPublicEditorProjectResourcesMock
|
||||
.mockReturnValueOnce(oldPage)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'new-account-resource',
|
||||
projectId: 'editor-showcase',
|
||||
label: '新账号素材',
|
||||
imageSrc: 'data:image/png;base64,new',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'new account',
|
||||
viewerLiked: false,
|
||||
},
|
||||
]);
|
||||
const result = renderCreationLanding();
|
||||
await waitFor(() =>
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
const nextAuth = createAuthValue({
|
||||
user: {
|
||||
...createAuthValue().user!,
|
||||
id: 'user-2',
|
||||
publicUserCode: '100002',
|
||||
},
|
||||
});
|
||||
result.rerender(
|
||||
<AuthUiContext.Provider value={nextAuth}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
expect(await screen.findByText('新账号素材')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveOldPage([
|
||||
{
|
||||
resourceId: 'old-account-resource',
|
||||
projectId: 'editor-showcase',
|
||||
label: '旧账号素材',
|
||||
imageSrc: 'data:image/png;base64,old',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'old account',
|
||||
viewerLiked: true,
|
||||
},
|
||||
]);
|
||||
await oldPage;
|
||||
});
|
||||
expect(screen.queryByText('旧账号素材')).toBeNull();
|
||||
expect(screen.getByText('新账号素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores an old account like response after an account switch', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveOldLike!: (value: {
|
||||
showcaseId: string;
|
||||
likeCount: number;
|
||||
viewerLiked: boolean;
|
||||
}) => void;
|
||||
const oldLike = new Promise<{
|
||||
showcaseId: string;
|
||||
likeCount: number;
|
||||
viewerLiked: boolean;
|
||||
}>((resolve) => {
|
||||
resolveOldLike = resolve;
|
||||
});
|
||||
const resource = {
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-showcase',
|
||||
showcaseId: 'showcase-character',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
viewerLiked: false,
|
||||
};
|
||||
listEditorProjectsMock.mockResolvedValue([]);
|
||||
listPublicEditorProjectResourcesMock
|
||||
.mockResolvedValueOnce([{ ...resource, likeCount: 3 }])
|
||||
.mockResolvedValueOnce([{ ...resource, likeCount: 10 }]);
|
||||
toggleEditorShowcaseAssetLikeMock.mockReturnValueOnce(oldLike);
|
||||
const result = renderCreationLanding();
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '点赞:角色英雄' }),
|
||||
);
|
||||
|
||||
const nextAuth = createAuthValue({
|
||||
user: {
|
||||
...createAuthValue().user!,
|
||||
id: 'user-2',
|
||||
publicUserCode: '100002',
|
||||
},
|
||||
});
|
||||
result.rerender(
|
||||
<AuthUiContext.Provider value={nextAuth}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
const newAccountLikeButton = await screen.findByRole('button', {
|
||||
name: '点赞:角色英雄',
|
||||
});
|
||||
expect(within(newAccountLikeButton).getByText('10')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveOldLike({
|
||||
showcaseId: 'showcase-character',
|
||||
likeCount: 4,
|
||||
viewerLiked: true,
|
||||
});
|
||||
await oldLike;
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: '点赞:角色英雄' }),
|
||||
).toBeTruthy();
|
||||
expect(within(newAccountLikeButton).getByText('10')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show a like action in the featured asset preview modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
@@ -1064,6 +1346,7 @@ describe('CreationLandingView', () => {
|
||||
await waitFor(() =>
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenLastCalledWith({
|
||||
cursor: 'cursor-page-2',
|
||||
viewer: 'authenticated',
|
||||
}),
|
||||
);
|
||||
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type EditorProjectResourceListPage,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
type EditorPublicShowcaseResourceSnapshot,
|
||||
type EditorShowcaseCampaignSnapshot,
|
||||
listEditorProjects,
|
||||
listPublicEditorProjectResources,
|
||||
@@ -165,13 +166,22 @@ function normalizeShowcaseResourcePage(
|
||||
page: EditorProjectResourceListPage | EditorProjectResourceSnapshot[],
|
||||
): EditorProjectResourceListPage {
|
||||
return Array.isArray(page)
|
||||
? { resources: page, nextCursor: null, campaign: null }
|
||||
? {
|
||||
resources: page.map((resource) => ({
|
||||
...resource,
|
||||
viewerLiked:
|
||||
(resource as Partial<EditorPublicShowcaseResourceSnapshot>)
|
||||
.viewerLiked === true,
|
||||
})),
|
||||
nextCursor: null,
|
||||
campaign: null,
|
||||
}
|
||||
: page;
|
||||
}
|
||||
|
||||
function mergeShowcaseResources(
|
||||
current: EditorProjectResourceSnapshot[],
|
||||
incoming: EditorProjectResourceSnapshot[],
|
||||
current: EditorPublicShowcaseResourceSnapshot[],
|
||||
incoming: EditorPublicShowcaseResourceSnapshot[],
|
||||
) {
|
||||
const seenResourceIds = new Set(
|
||||
current.map((resource) => resource.resourceId),
|
||||
@@ -835,6 +845,13 @@ export function CreationLandingView({
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const showcaseViewerUserId = authUi?.user?.id.trim() || null;
|
||||
const canLoadShowcaseViewer =
|
||||
!showcaseViewerUserId || Boolean(authUi?.canAccessProtectedData);
|
||||
const showcaseViewerKey = showcaseViewerUserId
|
||||
? `user:${showcaseViewerUserId}`
|
||||
: 'anonymous';
|
||||
const showcaseRequestScope = `${showcaseViewerKey}:${canLoadShowcaseViewer ? 'ready' : 'recovering'}`;
|
||||
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
|
||||
const [recentProjects, setRecentProjects] = useState<EditorProjectSnapshot[]>(
|
||||
[],
|
||||
@@ -845,7 +862,7 @@ export function CreationLandingView({
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [projectError, setProjectError] = useState<string | null>(null);
|
||||
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
|
||||
EditorProjectResourceSnapshot[]
|
||||
EditorPublicShowcaseResourceSnapshot[]
|
||||
>([]);
|
||||
const [showcaseCampaign, setShowcaseCampaign] =
|
||||
useState<EditorShowcaseCampaignSnapshot | null>(null);
|
||||
@@ -865,17 +882,27 @@ export function CreationLandingView({
|
||||
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
|
||||
useState<string | null>(null);
|
||||
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
|
||||
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [pendingLikeShowcaseIds, setPendingLikeShowcaseIds] = useState<
|
||||
Set<string>
|
||||
>(() => new Set());
|
||||
const [showcaseLikeError, setShowcaseLikeError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isPendingFeatureDialogOpen, setIsPendingFeatureDialogOpen] =
|
||||
useState(false);
|
||||
const showcaseLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const showcaseLoadingMoreCursorRef = useRef<string | null>(null);
|
||||
const showcaseFailedLoadMoreCursorRef = useRef<string | null>(null);
|
||||
const showcaseRequestStateRef = useRef({
|
||||
scope: showcaseRequestScope,
|
||||
generation: 0,
|
||||
});
|
||||
if (showcaseRequestStateRef.current.scope !== showcaseRequestScope) {
|
||||
showcaseRequestStateRef.current = {
|
||||
scope: showcaseRequestScope,
|
||||
generation: showcaseRequestStateRef.current.generation + 1,
|
||||
};
|
||||
}
|
||||
|
||||
const refreshRecentProjects = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
@@ -910,21 +937,44 @@ export function CreationLandingView({
|
||||
refreshRecentProjects();
|
||||
}, [refreshRecentProjects]);
|
||||
|
||||
const refreshShowcaseResources = useCallback(() => {
|
||||
useEffect(() => {
|
||||
const requestState = { ...showcaseRequestStateRef.current };
|
||||
const isCurrentRequest = () =>
|
||||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||||
showcaseRequestStateRef.current.generation === requestState.generation;
|
||||
|
||||
setIsLoadingShowcase(true);
|
||||
setIsLoadingMoreShowcase(false);
|
||||
showcaseLoadingMoreCursorRef.current = null;
|
||||
showcaseFailedLoadMoreCursorRef.current = null;
|
||||
setProjectShowcaseResources([]);
|
||||
setShowcaseCampaign(null);
|
||||
setShowcaseNextCursor(null);
|
||||
setAssetError(null);
|
||||
setShowcaseLoadMoreError(null);
|
||||
listPublicEditorProjectResources()
|
||||
setShowcaseLikeError(null);
|
||||
setPendingLikeShowcaseIds(new Set());
|
||||
setSelectedShowcaseItem(null);
|
||||
if (!canLoadShowcaseViewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
listPublicEditorProjectResources({
|
||||
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
|
||||
})
|
||||
.then((page) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
const normalizedPage = normalizeShowcaseResourcePage(page);
|
||||
setProjectShowcaseResources(normalizedPage.resources);
|
||||
setShowcaseCampaign(normalizedPage.campaign ?? null);
|
||||
setShowcaseNextCursor(normalizedPage.nextCursor);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
setProjectShowcaseResources([]);
|
||||
setShowcaseCampaign(null);
|
||||
setShowcaseNextCursor(null);
|
||||
@@ -933,12 +983,12 @@ export function CreationLandingView({
|
||||
error instanceof Error ? error.message : '读取陶泥儿精选失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingShowcase(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshShowcaseResources();
|
||||
}, [refreshShowcaseResources]);
|
||||
.finally(() => {
|
||||
if (isCurrentRequest()) {
|
||||
setIsLoadingShowcase(false);
|
||||
}
|
||||
});
|
||||
}, [canLoadShowcaseViewer, showcaseRequestScope, showcaseViewerUserId]);
|
||||
|
||||
const loadMoreShowcaseResources = useCallback(
|
||||
(options: { force?: boolean } = {}) => {
|
||||
@@ -956,10 +1006,20 @@ export function CreationLandingView({
|
||||
return;
|
||||
}
|
||||
showcaseLoadingMoreCursorRef.current = cursor;
|
||||
const requestState = { ...showcaseRequestStateRef.current };
|
||||
const isCurrentRequest = () =>
|
||||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||||
showcaseRequestStateRef.current.generation === requestState.generation;
|
||||
setIsLoadingMoreShowcase(true);
|
||||
setShowcaseLoadMoreError(null);
|
||||
listPublicEditorProjectResources({ cursor })
|
||||
listPublicEditorProjectResources({
|
||||
cursor,
|
||||
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
|
||||
})
|
||||
.then((page) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
const normalizedPage = normalizeShowcaseResourcePage(page);
|
||||
setProjectShowcaseResources((current) =>
|
||||
mergeShowcaseResources(current, normalizedPage.resources),
|
||||
@@ -969,19 +1029,30 @@ export function CreationLandingView({
|
||||
showcaseFailedLoadMoreCursorRef.current = null;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
showcaseFailedLoadMoreCursorRef.current = cursor;
|
||||
setShowcaseLoadMoreError(
|
||||
error instanceof Error ? error.message : '读取陶泥儿精选失败',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
if (showcaseLoadingMoreCursorRef.current === cursor) {
|
||||
showcaseLoadingMoreCursorRef.current = null;
|
||||
}
|
||||
setIsLoadingMoreShowcase(false);
|
||||
});
|
||||
},
|
||||
[isLoadingMoreShowcase, isLoadingShowcase, showcaseNextCursor],
|
||||
[
|
||||
isLoadingMoreShowcase,
|
||||
isLoadingShowcase,
|
||||
showcaseNextCursor,
|
||||
showcaseViewerUserId,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1056,19 +1127,19 @@ export function CreationLandingView({
|
||||
[createProject],
|
||||
);
|
||||
|
||||
const updateShowcaseLikeCount = useCallback(
|
||||
(showcaseId: string, likeCount: number) => {
|
||||
const updateShowcaseViewerState = useCallback(
|
||||
(showcaseId: string, likeCount: number, viewerLiked: boolean) => {
|
||||
setProjectShowcaseResources((current) =>
|
||||
current.map((resource) =>
|
||||
resource.showcaseId === showcaseId ||
|
||||
resource.resourceId === showcaseId
|
||||
? { ...resource, likeCount }
|
||||
? { ...resource, likeCount, viewerLiked }
|
||||
: resource,
|
||||
),
|
||||
);
|
||||
setSelectedShowcaseItem((current) =>
|
||||
current?.showcaseId === showcaseId
|
||||
? { ...current, likeCount }
|
||||
? { ...current, likeCount, viewerLiked }
|
||||
: current,
|
||||
);
|
||||
},
|
||||
@@ -1089,44 +1160,38 @@ export function CreationLandingView({
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLiked = !likedShowcaseIds.has(showcaseId);
|
||||
const previousLikeCount = item.likeCount ?? 0;
|
||||
const optimisticLikeCount = Math.max(
|
||||
0,
|
||||
previousLikeCount + (nextLiked ? 1 : -1),
|
||||
);
|
||||
const nextLiked = item.viewerLiked !== true;
|
||||
const requestState = { ...showcaseRequestStateRef.current };
|
||||
const requestViewerKey = showcaseViewerKey;
|
||||
const isCurrentRequest = () =>
|
||||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||||
showcaseRequestStateRef.current.generation === requestState.generation &&
|
||||
requestViewerKey === showcaseViewerKey;
|
||||
setShowcaseLikeError(null);
|
||||
setPendingLikeShowcaseIds((current) => new Set(current).add(showcaseId));
|
||||
setLikedShowcaseIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (nextLiked) {
|
||||
next.add(showcaseId);
|
||||
} else {
|
||||
next.delete(showcaseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
updateShowcaseLikeCount(showcaseId, optimisticLikeCount);
|
||||
|
||||
toggleEditorShowcaseAssetLike(showcaseId, nextLiked)
|
||||
.then((record) => {
|
||||
updateShowcaseLikeCount(
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
updateShowcaseViewerState(
|
||||
showcaseId,
|
||||
record.likeCount ?? optimisticLikeCount,
|
||||
record.likeCount ?? item.likeCount ?? 0,
|
||||
record.viewerLiked,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setLikedShowcaseIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (nextLiked) {
|
||||
next.delete(showcaseId);
|
||||
} else {
|
||||
next.add(showcaseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
updateShowcaseLikeCount(showcaseId, previousLikeCount);
|
||||
if (isCurrentRequest()) {
|
||||
setShowcaseLikeError(
|
||||
nextLiked ? '点赞失败,请重试' : '取消点赞失败,请重试',
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCurrentRequest()) {
|
||||
return;
|
||||
}
|
||||
setPendingLikeShowcaseIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(showcaseId);
|
||||
@@ -1134,7 +1199,12 @@ export function CreationLandingView({
|
||||
});
|
||||
});
|
||||
},
|
||||
[authUi, likedShowcaseIds, pendingLikeShowcaseIds, updateShowcaseLikeCount],
|
||||
[
|
||||
authUi,
|
||||
pendingLikeShowcaseIds,
|
||||
showcaseViewerKey,
|
||||
updateShowcaseViewerState,
|
||||
],
|
||||
);
|
||||
|
||||
const showcaseItems = useMemo(
|
||||
@@ -1227,6 +1297,11 @@ export function CreationLandingView({
|
||||
|
||||
return (
|
||||
<>
|
||||
{showcaseLikeError ? (
|
||||
<PlatformStatusMessage tone="error" surface="platform" role="alert">
|
||||
{showcaseLikeError}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<div
|
||||
ref={showcaseMasonryRef}
|
||||
className="creation-landing__asset-waterfall"
|
||||
@@ -1235,9 +1310,7 @@ export function CreationLandingView({
|
||||
>
|
||||
{visibleShowcaseItems.map((item) => {
|
||||
const showcaseId = item.showcaseId?.trim();
|
||||
const isLiked = Boolean(
|
||||
showcaseId && likedShowcaseIds.has(showcaseId),
|
||||
);
|
||||
const isLiked = item.viewerLiked === true;
|
||||
const isLikePending = Boolean(
|
||||
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
|
||||
);
|
||||
@@ -1302,6 +1375,7 @@ export function CreationLandingView({
|
||||
: 'creation-landing__asset-like-button'
|
||||
}
|
||||
aria-label={`${isLiked ? '取消点赞' : '点赞'}:${item.label}`}
|
||||
aria-busy={isLikePending}
|
||||
disabled={isLikePending}
|
||||
onClick={() => handleToggleShowcaseLike(item)}
|
||||
>
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ShowcaseAssetItem = {
|
||||
author: string;
|
||||
cost: string;
|
||||
likeCount?: number | null;
|
||||
viewerLiked?: boolean;
|
||||
showcaseId?: string | null;
|
||||
campaign?: boolean;
|
||||
};
|
||||
@@ -983,6 +984,7 @@ function toLibraryShowcaseItem(
|
||||
author: resolveGroupAuthor(sortedAssets, fallbackAuthorName),
|
||||
cost: resolveGroupCost(sortedAssets),
|
||||
likeCount: normalizeNumber(assetRecord(primaryAsset).likeCount),
|
||||
viewerLiked: assetRecord(primaryAsset).viewerLiked === true,
|
||||
showcaseId: normalizeText(assetRecord(primaryAsset).showcaseId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,6 +282,7 @@ describe('editorProjectClient', () => {
|
||||
],
|
||||
imageSequenceDurationMs: 6_000,
|
||||
publicShowcaseEnabled: true,
|
||||
viewerLiked: false,
|
||||
},
|
||||
],
|
||||
nextCursor: 'cursor-next',
|
||||
@@ -306,6 +307,7 @@ describe('editorProjectClient', () => {
|
||||
expect(page.resources[0]?.authorPublicUserCode).toBe('SY-00000042');
|
||||
expect(page.resources[0]?.imageSequenceFrames).toHaveLength(2);
|
||||
expect(page.resources[0]?.imageSequenceDurationMs).toBe(6_000);
|
||||
expect(page.resources[0]?.viewerLiked).toBe(false);
|
||||
expect(page.nextCursor).toBe('cursor-next');
|
||||
expect(page.campaign?.title).toBe('活动精选');
|
||||
expect(page.campaign?.imageWidth).toBe(900);
|
||||
@@ -348,6 +350,34 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('loads viewer-specific showcase state with authentication enabled', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'showcase-1',
|
||||
projectId: 'editor-showcase',
|
||||
imageSrc: '/showcase-1.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
viewerLiked: true,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const page = await listPublicEditorProjectResources({
|
||||
viewer: 'authenticated',
|
||||
});
|
||||
|
||||
expect(page.resources[0]?.viewerLiked).toBe(true);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/showcase/resources',
|
||||
{ method: 'GET' },
|
||||
'读取陶泥儿精选素材失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads an explicit project by id', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
@@ -706,6 +736,7 @@ describe('editorProjectClient', () => {
|
||||
reviewStatus: 'approved',
|
||||
displayEnabled: true,
|
||||
likeCount: 8,
|
||||
viewerLiked: true,
|
||||
generationCostMudPoints: 20,
|
||||
refundMudPoints: 10,
|
||||
},
|
||||
@@ -717,6 +748,7 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
|
||||
expect(showcaseAsset.likeCount).toBe(8);
|
||||
expect(showcaseAsset.viewerLiked).toBe(true);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/showcase/assets/showcase-asset-1/likes',
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -257,6 +257,11 @@ export type EditorProjectResourceSnapshot = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type EditorPublicShowcaseResourceSnapshot =
|
||||
EditorProjectResourceSnapshot & {
|
||||
viewerLiked: boolean;
|
||||
};
|
||||
|
||||
export type EditorAssetFolderSnapshot = {
|
||||
folderId: string;
|
||||
label: string;
|
||||
@@ -798,13 +803,13 @@ type EditorProjectResourceResponse = {
|
||||
};
|
||||
|
||||
type EditorProjectResourceListResponse = {
|
||||
resources: EditorProjectResourceSnapshot[];
|
||||
resources: EditorPublicShowcaseResourceSnapshot[];
|
||||
nextCursor?: string | null;
|
||||
campaign?: EditorShowcaseCampaignSnapshot | null;
|
||||
};
|
||||
|
||||
export type EditorProjectResourceListPage = {
|
||||
resources: EditorProjectResourceSnapshot[];
|
||||
resources: EditorPublicShowcaseResourceSnapshot[];
|
||||
nextCursor: string | null;
|
||||
campaign?: EditorShowcaseCampaignSnapshot | null;
|
||||
};
|
||||
@@ -829,6 +834,10 @@ type EditorShowcaseAssetResponse = {
|
||||
showcaseAsset: EditorProjectResourceSnapshot;
|
||||
};
|
||||
|
||||
type EditorShowcaseAssetViewerResponse = {
|
||||
showcaseAsset: EditorPublicShowcaseResourceSnapshot;
|
||||
};
|
||||
|
||||
type EditorImageGenerationResponse = EditorImageGenerationResult;
|
||||
type EditorBackgroundRemovalResponse = EditorBackgroundRemovalResult;
|
||||
type EditorPixelArtSnapResponse = EditorPixelArtSnapResult;
|
||||
@@ -856,8 +865,10 @@ export async function listEditorProjects() {
|
||||
|
||||
export async function listPublicEditorProjectResources({
|
||||
cursor,
|
||||
viewer = 'anonymous',
|
||||
}: {
|
||||
cursor?: string | null;
|
||||
viewer?: 'anonymous' | 'authenticated';
|
||||
} = {}): Promise<EditorProjectResourceListPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (cursor?.trim()) {
|
||||
@@ -866,17 +877,24 @@ export async function listPublicEditorProjectResources({
|
||||
const url = params.toString()
|
||||
? `${EDITOR_SHOWCASE_RESOURCE_API}?${params.toString()}`
|
||||
: EDITOR_SHOWCASE_RESOURCE_API;
|
||||
const response = await requestJson<EditorProjectResourceListResponse>(
|
||||
url,
|
||||
{ method: 'GET' },
|
||||
'读取陶泥儿精选素材失败',
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
},
|
||||
);
|
||||
const response =
|
||||
viewer === 'authenticated'
|
||||
? await requestJson<EditorProjectResourceListResponse>(
|
||||
url,
|
||||
{ method: 'GET' },
|
||||
'读取陶泥儿精选素材失败',
|
||||
)
|
||||
: await requestJson<EditorProjectResourceListResponse>(
|
||||
url,
|
||||
{ method: 'GET' },
|
||||
'读取陶泥儿精选素材失败',
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
},
|
||||
);
|
||||
return {
|
||||
resources: response.resources,
|
||||
nextCursor: response.nextCursor?.trim() || null,
|
||||
@@ -1097,7 +1115,7 @@ export async function toggleEditorShowcaseAssetLike(
|
||||
showcaseId: string,
|
||||
liked: boolean,
|
||||
) {
|
||||
const response = await requestJson<EditorShowcaseAssetResponse>(
|
||||
const response = await requestJson<EditorShowcaseAssetViewerResponse>(
|
||||
`${EDITOR_SHOWCASE_ASSET_API_BASE}/${encodeURIComponent(showcaseId)}/likes`,
|
||||
jsonRequest('POST', { liked }),
|
||||
liked ? '点赞精选素材失败' : '取消点赞精选素材失败',
|
||||
|
||||
Reference in New Issue
Block a user