补齐陶泥儿精选审核链路
新增 editor_showcase_asset、点赞和活动配置表,生成素材默认不公开。 补齐素材提交精选审核、后台审核通过返还泥点、展示开关和公开精选分页接口。 更新素材库右键菜单和创作主页精选瀑布流,展示作者昵称、陶泥号、成本和点赞状态。 新增后台素材查询页,支持时间、用户、分类、关键词查询和缩略图详情查看。 同步 SpacetimeDB 绑定、前后端契约、项目文档和决策记录。
This commit is contained in:
@@ -11,6 +11,14 @@ import type {
|
||||
AdminDatabaseTableListResponse,
|
||||
AdminDatabaseTableRowsQuery,
|
||||
AdminDatabaseTableRowsResponse,
|
||||
AdminEditorAssetListQuery,
|
||||
AdminEditorAssetListResponse,
|
||||
AdminEditorShowcaseAssetResponse,
|
||||
AdminEditorShowcaseCampaignResponse,
|
||||
AdminEditorShowcaseDisplayRequest,
|
||||
AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse,
|
||||
AdminEditorShowcaseReviewRequest,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -19,6 +27,7 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRechargeProductRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
@@ -54,6 +63,23 @@ interface AdminRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface AdminAssetReadUrlQuery {
|
||||
objectKey?: string | null;
|
||||
legacyPublicPath?: string | null;
|
||||
expireSeconds?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminAssetReadUrlResponse {
|
||||
read?: {
|
||||
objectKey?: string;
|
||||
signedUrl?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
signedUrl?: string;
|
||||
objectKey?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class AdminApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
@@ -293,6 +319,81 @@ export function updateAdminWorkVisibility(
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminAssetReadUrl(query: AdminAssetReadUrlQuery) {
|
||||
return request<AdminAssetReadUrlResponse>(
|
||||
`/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminEditorAssets(
|
||||
token: string,
|
||||
query: AdminEditorAssetListQuery = {},
|
||||
) {
|
||||
return request<AdminEditorAssetListResponse>(
|
||||
`/admin/api/editor-assets${buildEditorAssetListQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminEditorShowcaseAssets(
|
||||
token: string,
|
||||
query: AdminEditorShowcaseListQuery = {},
|
||||
) {
|
||||
return request<AdminEditorShowcaseListResponse>(
|
||||
`/admin/api/editor-showcase/assets${buildEditorShowcaseListQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function reviewAdminEditorShowcaseAsset(
|
||||
token: string,
|
||||
payload: AdminEditorShowcaseReviewRequest,
|
||||
) {
|
||||
return request<AdminEditorShowcaseAssetResponse>(
|
||||
'/admin/api/editor-showcase/assets/review',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAdminEditorShowcaseDisplay(
|
||||
token: string,
|
||||
payload: AdminEditorShowcaseDisplayRequest,
|
||||
) {
|
||||
return request<AdminEditorShowcaseAssetResponse>(
|
||||
'/admin/api/editor-showcase/assets/display',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminEditorShowcaseCampaign(token: string) {
|
||||
return request<AdminEditorShowcaseCampaignResponse>(
|
||||
'/admin/api/editor-showcase/campaign',
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertAdminEditorShowcaseCampaign(
|
||||
token: string,
|
||||
payload: AdminUpsertEditorShowcaseCampaignRequest,
|
||||
) {
|
||||
return request<AdminEditorShowcaseCampaignResponse>(
|
||||
'/admin/api/editor-showcase/campaign',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listProfileRedeemCodes(token: string) {
|
||||
return request<ProfileRedeemCodeAdminListResponse>(
|
||||
'/admin/api/profile/redeem-codes',
|
||||
@@ -432,6 +533,29 @@ function buildRequestUrl(path: string) {
|
||||
return `${ADMIN_API_BASE_URL}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) {
|
||||
const params = new URLSearchParams();
|
||||
const objectKey = query.objectKey?.trim().replace(/^\/+/u, '') ?? '';
|
||||
const legacyPublicPath = query.legacyPublicPath?.trim() ?? '';
|
||||
if (objectKey) {
|
||||
params.set('objectKey', objectKey);
|
||||
} else if (legacyPublicPath) {
|
||||
params.set(
|
||||
'legacyPublicPath',
|
||||
`/${legacyPublicPath.replace(/^\/+/u, '')}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof query.expireSeconds === 'number' &&
|
||||
Number.isFinite(query.expireSeconds) &&
|
||||
query.expireSeconds > 0
|
||||
) {
|
||||
params.set('expireSeconds', String(Math.floor(query.expireSeconds)));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'eventKey', query.eventKey);
|
||||
@@ -471,6 +595,35 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildEditorAssetListQuery(query: AdminEditorAssetListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'cursor', query.cursor);
|
||||
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
|
||||
appendQueryParam(params, 'assetKind', query.assetKind);
|
||||
appendQueryParam(params, 'createdAfter', query.createdAfter);
|
||||
appendQueryParam(params, 'createdBefore', query.createdBefore);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildEditorShowcaseListQuery(query: AdminEditorShowcaseListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'cursor', query.cursor);
|
||||
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
|
||||
appendQueryParam(params, 'assetKind', query.assetKind);
|
||||
appendQueryParam(params, 'reviewStatus', query.reviewStatus);
|
||||
appendQueryParam(params, 'submittedAfter', query.submittedAfter);
|
||||
appendQueryParam(params, 'submittedBefore', query.submittedBefore);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function appendQueryParam(
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
|
||||
@@ -346,6 +346,135 @@ export interface AdminUpdateWorkVisibilityResponse {
|
||||
entry: AdminWorkVisibilityEntryPayload;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListQuery {
|
||||
cursor?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
assetKind?: string | null;
|
||||
createdAfter?: string | null;
|
||||
createdBefore?: string | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetPayload {
|
||||
assetId: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName?: string | null;
|
||||
authorPublicUserCode?: string | null;
|
||||
folderId: string;
|
||||
label: string;
|
||||
assetObjectId?: string | null;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: string;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: Record<string, unknown> | null;
|
||||
sourceResourceId?: string | null;
|
||||
thumbnailSrc?: string | null;
|
||||
generationCostMudPoints: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListResponse {
|
||||
entries: AdminEditorAssetPayload[];
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseListQuery {
|
||||
cursor?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
assetKind?: string | null;
|
||||
reviewStatus?: string | null;
|
||||
submittedAfter?: string | null;
|
||||
submittedBefore?: string | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseAssetPayload {
|
||||
showcaseId: string;
|
||||
assetId: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName?: string | null;
|
||||
authorPublicUserCode?: string | null;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: Record<string, unknown> | null;
|
||||
generationCostMudPoints: number;
|
||||
refundMudPoints: number;
|
||||
reviewStatus: 'pending' | 'approved' | 'rejected' | string;
|
||||
displayEnabled: boolean;
|
||||
likeCount: number;
|
||||
assetDeletedWhilePending: boolean;
|
||||
reviewedByAdminUserId?: string | null;
|
||||
reviewNote?: string | null;
|
||||
refundLedgerId?: string | null;
|
||||
refundCompletedAt?: string | null;
|
||||
submittedAt: string;
|
||||
reviewedAt?: string | null;
|
||||
approvedAt?: string | null;
|
||||
rejectedAt?: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseListResponse {
|
||||
entries: AdminEditorShowcaseAssetPayload[];
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseReviewRequest {
|
||||
showcaseId: string;
|
||||
reviewStatus: 'approved' | 'rejected';
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseDisplayRequest {
|
||||
showcaseId: string;
|
||||
displayEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseAssetResponse {
|
||||
entry: AdminEditorShowcaseAssetPayload;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseCampaignPayload {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
prompt: string;
|
||||
author: string;
|
||||
costText: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseCampaignResponse {
|
||||
campaign?: AdminEditorShowcaseCampaignPayload | null;
|
||||
}
|
||||
|
||||
export interface AdminUpsertEditorShowcaseCampaignRequest {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
prompt: string;
|
||||
author: string;
|
||||
costText: string;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
|
||||
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
|
||||
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
|
||||
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
@@ -245,6 +246,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'editor-showcase' ? (
|
||||
<AdminEditorAssetQueryPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Eye,
|
||||
Images,
|
||||
WalletCards,
|
||||
ShieldCheck,
|
||||
ListChecks,
|
||||
@@ -42,6 +43,7 @@ const routeIcons = {
|
||||
tasks: ListChecks,
|
||||
'recharge-products': BadgeDollarSign,
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Images,
|
||||
'creation-announcement': Megaphone,
|
||||
'creation-entry': SlidersHorizontal,
|
||||
'work-visibility': Eye,
|
||||
|
||||
@@ -39,3 +39,13 @@ test('后台模型定价路由可通过导航和 hash 访问', () => {
|
||||
'#editor-generation-pricing',
|
||||
);
|
||||
});
|
||||
|
||||
test('后台素材查询路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'editor-showcase',
|
||||
label: '素材查询',
|
||||
hash: '#editor-showcase',
|
||||
});
|
||||
expect(resolveAdminRoute('#editor-showcase')).toBe('editor-showcase');
|
||||
expect(routeHash('editor-showcase')).toBe('#editor-showcase');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AdminRouteId =
|
||||
| 'tasks'
|
||||
| 'recharge-products'
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'creation-announcement'
|
||||
| 'creation-entry'
|
||||
| 'work-visibility';
|
||||
@@ -34,6 +35,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
|
||||
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
|
||||
{id: 'editor-showcase', label: '素材查询', hash: '#editor-showcase'},
|
||||
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
|
||||
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
|
||||
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
listAdminEditorAssets,
|
||||
} from '../api/adminApiClient';
|
||||
import type {AdminEditorAssetPayload} from '../api/adminApiTypes';
|
||||
import {AdminEditorAssetQueryPage} from './AdminEditorAssetQueryPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
getAdminAssetReadUrl: vi.fn(),
|
||||
listAdminEditorAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
const generatedAsset: AdminEditorAssetPayload = {
|
||||
assetId: 'asset-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '作者昵称',
|
||||
authorPublicUserCode: 'SY-00000042',
|
||||
folderId: 'folder-1',
|
||||
label: '角色形象 1',
|
||||
assetObjectId: 'asset-object-1',
|
||||
imageSrc: '/generated-character-drafts/editor/spec.png',
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '完整提示词内容',
|
||||
actualPrompt: null,
|
||||
model: 'gpt-image-2',
|
||||
provider: 'character',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
generationInputs: {style: 'clay'},
|
||||
sourceResourceId: 'resource-1',
|
||||
thumbnailSrc: null,
|
||||
generationCostMudPoints: 12,
|
||||
createdAt: '2026-07-04T10:00:00Z',
|
||||
updatedAt: '2026-07-04T10:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValue({
|
||||
entries: [generatedAsset],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
||||
read: {
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
signedUrl:
|
||||
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询展示作者昵称和陶泥号', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('作者昵称')).toBeTruthy();
|
||||
expect(screen.getByText('SY-00000042')).toBeTruthy();
|
||||
expect(screen.queryByText('user-1')).toBeNull();
|
||||
});
|
||||
|
||||
test('后台素材查询按用户、分类和时间调用查询接口', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('img', {name: '素材:角色形象 1'});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('用户 ID'), {
|
||||
target: {value: 'user-1'},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('分类'), {
|
||||
target: {value: 'character'},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('开始时间'), {
|
||||
target: {value: '2026-07-01'},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('结束时间'), {
|
||||
target: {value: '2026-07-04'},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', {
|
||||
ownerUserId: 'user-1',
|
||||
assetKind: 'character',
|
||||
createdAfter: '2026-07-01T00:00:00+08:00',
|
||||
createdBefore: '2026-07-04T23:59:59.999+08:00',
|
||||
limit: 80,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', {name: '素材:角色形象 1'});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith({
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询可查看素材详情', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: '详情'}));
|
||||
|
||||
const dialog = screen.getByRole('dialog', {name: '素材详情'});
|
||||
expect(within(dialog).getByText('asset-1')).toBeTruthy();
|
||||
expect(within(dialog).getByText('1024 x 1024')).toBeTruthy();
|
||||
expect(within(dialog).getByText('12 泥点')).toBeTruthy();
|
||||
expect(within(dialog).getByText(/"style": "clay"/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('后台素材查询可打开弹窗查看完整提示词', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: '完整提示词内容'}));
|
||||
|
||||
const dialog = screen.getByRole('dialog', {name: '完整提示词'});
|
||||
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', {name: '关闭完整提示词'}));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', {name: '完整提示词'})).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -553,6 +553,46 @@ button:disabled {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-asset-query-filter-row {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-asset-query-filter-row .admin-field {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.admin-asset-query-filter-row .admin-field:last-child {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.admin-asset-query-thumb-button {
|
||||
display: inline-flex;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-asset-query-thumb {
|
||||
border: 1px solid #eaded2;
|
||||
border-radius: 8px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: #fffdf9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.admin-asset-query-thumb-placeholder {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-query-action-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -905,6 +945,17 @@ button:disabled {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-table textarea {
|
||||
width: min(100%, 220px);
|
||||
min-height: 64px;
|
||||
border: 1px solid #dfc8b7;
|
||||
border-radius: 8px;
|
||||
color: #3d1f10;
|
||||
background: #fffdf9;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin-muted-text {
|
||||
color: #a38f80;
|
||||
}
|
||||
@@ -939,6 +990,78 @@ button:disabled {
|
||||
min-width: 1180px;
|
||||
}
|
||||
|
||||
.admin-asset-query-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(1),
|
||||
.admin-asset-query-table td:nth-child(1) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(2),
|
||||
.admin-asset-query-table td:nth-child(2),
|
||||
.admin-asset-query-table th:nth-child(3),
|
||||
.admin-asset-query-table td:nth-child(3) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(4),
|
||||
.admin-asset-query-table td:nth-child(4) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(5),
|
||||
.admin-asset-query-table td:nth-child(5) {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(6),
|
||||
.admin-asset-query-table td:nth-child(6) {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(7),
|
||||
.admin-asset-query-table td:nth-child(7) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-dialog,
|
||||
.admin-asset-query-prompt-dialog {
|
||||
width: min(100%, 860px);
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-dialog .admin-panel-heading > div,
|
||||
.admin-asset-query-detail-dialog .admin-panel-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-dialog .admin-panel-heading span,
|
||||
.admin-asset-query-detail-dialog .admin-panel-heading span {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-full {
|
||||
max-height: min(68dvh, 520px);
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-layout > .admin-asset-query-thumb {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.admin-database-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
|
||||
@@ -40,6 +40,11 @@ export default defineConfig(({mode}) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api/assets': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/healthz': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
- 验证方式:运行 `cargo fmt --manifest-path server-rs/Cargo.toml --all`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。
|
||||
|
||||
## 2026-07-04 陶泥儿精选改为素材提交审核后公开
|
||||
|
||||
- 背景:`/creation` 的 `陶泥儿精选` 过去依赖 `editor_project_resource.public_showcase_enabled`,生成画布资源默认可公开,和“作品公开默认关闭、由用户主动投稿精选”的运营要求冲突,也无法在后台审核、返还泥点和配置固定活动卡。
|
||||
- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`、默认 `display_enabled=true` 并按 `generation_cost_mud_points` 返还 50% 泥点;拒绝后写入 `rejected`。公开接口 `GET /api/editor/showcase/resources` 只返回已通过且展示开启的快照,按通过时间和 `showcaseId` 倒序分页,并可携带后台配置的固定活动卡。旧 `editor_project_resource.public_showcase_enabled` 和旧 PATCH 接口只保留兼容,不再驱动精选公开。
|
||||
- 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`spacetime-client` 绑定与 mapper、`api-server` 编辑器和后台路由、admin-web 精选审核页、素材库右键菜单、`/creation` 精选瀑布流、图片画布文档和后端表目录。
|
||||
- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-module -p spacetime-client -p api-server`、前端 / 后台 typecheck 与精选相关组件测试,确认默认不公开、提交后 pending、审核通过后展示和返还、展示开关与点赞生效。
|
||||
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-07-03 外部编辑器 API 生成默认写入画布与素材库
|
||||
|
||||
- 背景:外部 API 面向美术 Agent 使用时,需要从自然语言自动选路,并保证生成结果不会只停留在接口回包里;同时后续素材生成需要复用已抽象出的美术规范,避免每次重新追问风格要求。
|
||||
@@ -92,7 +100,7 @@
|
||||
## 2026-06-22 创作主页精选展示全站公开画布生成资源
|
||||
|
||||
- 背景:`/creation` 的 `陶泥儿精选` 曾从账号级素材库读取,并在素材为空时用公开作品图片补充,导致新创作页出现不属于任何当前图片画布项目的素材。
|
||||
- 决策:`陶泥儿精选` 从全站 `editor_project_resource` 构建素材包和素材,读取路径固定为 `GET /api/editor/showcase/resources`;只保留 `sourceType="generated"`、图片源非空且 `public_showcase_enabled = true` 的画布生成资源,按创建时间和资源 ID 倒序 cursor 分页展示,每页最多 36 条,有 `nextCursor` 时前端滚动到底部继续追加。账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。任意画布左侧素材列表中,单素材右键菜单承接文字 `删除` 和 `公开展示该素材` 勾选项;勾选项默认打开,修改时写回对应 `editor_project_resource.public_showcase_enabled`。
|
||||
- 决策:该历史决策已被 2026-07-04 的“素材提交审核后公开”取代。历史背景仍有效:公开作品图片和假数据不应回填精选;但精选事实源不再是 `editor_project_resource.public_showcase_enabled`,而是 `editor_showcase_asset` 审核快照。
|
||||
- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、公开精选 BFF、图片画布素材列表右键菜单、账号素材库快照、创作主页改版计划和精选素材相关测试。
|
||||
- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts`、`CreationLandingView.test.tsx`、`ImageCanvasAssetRowView.test.tsx`、`useImageCanvasAssetLibrary.test.tsx` 与 `src/services/image-editor/editorProjectClient.test.ts`,确认公开生成资源展示、上传素材过滤、公开开关隐藏资源、删除入口在右键菜单中、公开作品不再 fallback。
|
||||
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
- 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、创建时间和更新时间;历史 layout 字段暂保留为兼容列,不再作为权威画布数据。
|
||||
- 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。
|
||||
- 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。
|
||||
- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
|
||||
- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、`generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
|
||||
- 新增 `editor_showcase_asset`、`editor_showcase_asset_like` 和 `editor_showcase_campaign_config` 表承接 `陶泥儿精选`:生成素材默认不公开,用户在素材菜单中提交精选审核后生成独立快照;后台审核通过才进入公开精选,并按生成成本返还 50% 泥点。公开列表不再读取 `editor_project_resource.public_showcase_enabled`,而是读取已通过且展示开启的精选快照,支持点赞数和首位活动卡。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
|
||||
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。
|
||||
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。
|
||||
|
||||
@@ -54,7 +54,7 @@ npm run check:server-rs-ddd
|
||||
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
|
||||
|
||||
- 健康检查:`GET /healthz`。
|
||||
- 后台管理:`/admin/api/*`,包括登录、Dashboard 运营看板、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
|
||||
- 后台管理:`/admin/api/*`,包括登录、Dashboard 运营看板、概览、HTTP debug、埋点、表查询、素材查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
|
||||
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
|
||||
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
|
||||
@@ -62,6 +62,7 @@ npm run check:server-rs-ddd
|
||||
- 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。
|
||||
- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。
|
||||
- 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。
|
||||
- 后台素材查询:`GET /admin/api/editor-assets` 读取账号级 `editor_asset` 中 `source_type = 'generated'` 的素材,支持 `ownerUserId`、`assetKind`、`createdAfter`、`createdBefore`、`cursor` 和 `limit`;返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不执行精选审核、返还或展示状态修改。
|
||||
- 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。
|
||||
- 拼图:`/api/runtime/puzzle/*`。
|
||||
- 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。
|
||||
@@ -463,7 +464,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`EditorProjectResource`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` / `resource_id` 倒序 cursor 分页;每页最多 36 条,响应返回 `nextCursor`,下一页通过 `?cursor=...` 继续读取。公开响应的作者展示字段优先提供 `authorDisplayName` / `display_name`,没有展示名时提供 `authorPublicUserCode` / 陶泥号作为展示兜底,前端公开展示绝不能兜底到内部 `ownerUserId` / `user_id`。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
|
||||
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和历史 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
|
||||
- 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。
|
||||
|
||||
### `editor_asset_folder`
|
||||
@@ -477,9 +478,30 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`EditorAsset`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json` 和可选 `source_resource_id`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `source_resource_id` 回查对应 project resource 的 `public_showcase_enabled`,供左侧素材菜单展示和切换公开状态;公开开关本身仍只落在 `editor_project_resource`,不在账号素材表复制真相。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
|
||||
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
|
||||
- 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。
|
||||
|
||||
### `editor_showcase_asset`
|
||||
|
||||
- Rust 结构体:`EditorShowcaseAsset`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:`陶泥儿精选` 的独立审核与公开快照表。用户从账号级生成素材提交后,后端把素材媒体、提示词、生成输入、素材类型和 `generation_cost_mud_points` 快照到该表,初始 `review_status = pending` 且 `display_enabled = false`。后台审核通过后写入 `approved`、默认开启展示并生成确定性返还流水 `editor-showcase-refund:{showcase_id}`,BFF 按 50% 生成成本返还泥点后回写 `refund_completed_at`;拒绝后写入 `rejected`。公开精选 `GET /api/editor/showcase/resources` 只读取 `review_status = approved`、`display_enabled = true` 且媒体非空的记录,按通过时间 / `showcase_id` 倒序 cursor 分页。素材删除时,待审核记录标记 `asset_deleted_while_pending`,已拒绝记录删除,已通过记录保留快照继续展示。
|
||||
- 索引:`by_editor_showcase_asset_owner_user_id`、`by_editor_showcase_asset_review_status`。
|
||||
|
||||
### `editor_showcase_asset_like`
|
||||
|
||||
- Rust 结构体:`EditorShowcaseAssetLike`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:`陶泥儿精选` 点赞去重表,主键由 `showcase_id:user_id` 组成。点赞 / 取消点赞通过登录接口更新该表,并同步回写 `editor_showcase_asset.like_count`;未通过审核或未展示的精选素材不能点赞。
|
||||
- 索引:`by_editor_showcase_asset_like_showcase_id`、`by_editor_showcase_asset_like_user_id`。
|
||||
|
||||
### `editor_showcase_campaign_config`
|
||||
|
||||
- Rust 结构体:`EditorShowcaseCampaignConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = default`。后台可配置启用状态、标题、图片 URL、提示词、作者和成本文案;固定活动卡不包含副标题或 Object Key,公开精选接口只在启用时返回该配置。
|
||||
- 索引:主键 `config_id`。
|
||||
|
||||
### `inventory_slot`
|
||||
|
||||
- Rust 结构体:`InventorySlot`
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
|
||||
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
|
||||
|
||||
精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。公开 BFF 必须返回资源作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
|
||||
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
|
||||
|
||||
瀑布流通过 `GET /api/editor/showcase/resources` 按 `created_at` / `resource_id` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。
|
||||
瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。
|
||||
|
||||
Tab:
|
||||
|
||||
@@ -112,11 +112,12 @@ Tab:
|
||||
|
||||
数据来源:
|
||||
|
||||
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,快照包含 `authorDisplayName` / `display_name` 和 `authorPublicUserCode` / 陶泥号用于作者展示;前端只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`。
|
||||
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。
|
||||
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId`、`assetId`、审核状态、展示状态、点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name` 和 `authorPublicUserCode` / 陶泥号用于展示;前端只展示 `reviewStatus="approved"` 且 `displayEnabled=true` 的后端结果。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`。
|
||||
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。
|
||||
- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
|
||||
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
|
||||
- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。菜单内 `公开展示该素材` 默认勾选,取消勾选后调用 `PATCH /api/editor/project-resources/{resourceId}/showcase` 写回 `public_showcase_enabled = false`,该资源从 `陶泥儿精选` 移除。
|
||||
- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。
|
||||
- 后台新增纯素材查询页,读取账号级 `editor_asset` 中 `sourceType="generated"` 的素材,用于查看所有用户生成素材。该页只提供时间、用户 ID、分类和关键词查询,以及缩略图详情、提示词全文、生成成本、作者展示名和陶泥号查看;不承载审核、返还、展示开关或活动卡配置操作。
|
||||
- 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。
|
||||
- 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。
|
||||
|
||||
@@ -127,7 +128,7 @@ Tab:
|
||||
- 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。
|
||||
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由;新建画布引导使用 `guide=toolbar` 一次性 query,主站九宫格直达生成器使用 `tool=<intent>` 一次性 query,画布消费后通过 `history.replaceState` 清理这两个参数。
|
||||
- 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。
|
||||
- `陶泥儿精选` 读取后端公开精选接口返回的全站公开画布生成资源;公开开关以后端 `editor_project_resource.public_showcase_enabled` 为准,前端只做展示过滤、同源同媒体副本兜底过滤和乐观交互回滚。
|
||||
- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status` 和 `display_enabled` 为准,前端只做展示组合、点赞交互和提交审核的乐观状态回滚。
|
||||
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
|
||||
|
||||
## 路由与导航
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -904,6 +904,7 @@ pub(crate) async fn generate_editor_video_for_owner(
|
||||
asset_kind: Some(asset_kind.clone()),
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
thumbnail_src: generated.thumbnail_src.clone(),
|
||||
generation_cost_mud_points: u64::from(normalized.price_mud_points),
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -491,6 +491,7 @@ pub async fn create_external_editor_asset(
|
||||
asset_kind: normalize_optional_string(payload.asset_kind),
|
||||
generation_inputs_json,
|
||||
source_resource_id: None,
|
||||
generation_cost_mud_points: 0,
|
||||
now_micros: current_utc_micros(),
|
||||
thumbnail_src: None,
|
||||
})
|
||||
|
||||
@@ -6,11 +6,14 @@ use axum::{
|
||||
use crate::{
|
||||
admin::{
|
||||
admin_dashboard, admin_debug_http, admin_get_creation_entry_config,
|
||||
admin_get_editor_generation_pricing, admin_list_database_table_rows,
|
||||
admin_list_database_tables, admin_list_tracking_event_keys, admin_list_tracking_events,
|
||||
admin_list_work_visibility, admin_login, admin_me, admin_overview,
|
||||
admin_update_work_visibility, admin_upsert_creation_entry_config,
|
||||
admin_upsert_creation_entry_event_banners_config, admin_upsert_editor_generation_pricing,
|
||||
admin_get_editor_generation_pricing, admin_get_editor_showcase_campaign,
|
||||
admin_list_database_table_rows, admin_list_database_tables,
|
||||
admin_list_editor_assets, admin_list_editor_showcase_assets, admin_list_tracking_event_keys,
|
||||
admin_list_tracking_events, admin_list_work_visibility, admin_login, admin_me,
|
||||
admin_overview, admin_review_editor_showcase_asset,
|
||||
admin_update_editor_showcase_asset_display, admin_update_work_visibility,
|
||||
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
|
||||
admin_upsert_editor_generation_pricing, admin_upsert_editor_showcase_campaign,
|
||||
admin_upsert_public_work_interaction_config, require_admin_auth,
|
||||
},
|
||||
runtime_profile::{
|
||||
@@ -113,6 +116,42 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/editor-assets",
|
||||
get(admin_list_editor_assets).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/editor-showcase/assets",
|
||||
get(admin_list_editor_showcase_assets).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/editor-showcase/assets/review",
|
||||
post(admin_review_editor_showcase_asset).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/editor-showcase/assets/display",
|
||||
post(admin_update_editor_showcase_asset_display).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/editor-showcase/campaign",
|
||||
get(admin_get_editor_showcase_campaign)
|
||||
.post(admin_upsert_editor_showcase_campaign)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/works/visibility",
|
||||
get(admin_list_work_visibility)
|
||||
|
||||
@@ -15,7 +15,8 @@ use crate::{
|
||||
get_editor_generation_pricing, get_editor_project, list_editor_projects,
|
||||
list_public_editor_project_resources, load_recent_editor_project,
|
||||
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
|
||||
update_editor_asset, update_editor_asset_folder, update_editor_project_resource_showcase,
|
||||
submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset,
|
||||
update_editor_asset_folder, update_editor_project_resource_showcase,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -117,6 +118,20 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/assets/{asset_id}/showcase-submissions",
|
||||
post(submit_editor_asset_showcase).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/showcase/assets/{showcase_id}/likes",
|
||||
post(toggle_editor_showcase_asset_like).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/generations",
|
||||
post(generate_editor_image)
|
||||
|
||||
@@ -268,6 +268,7 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
asset_kind: Some("sound-effect".to_string()),
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: u64::from(normalized.price_mud_points),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -486,6 +487,7 @@ pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
asset_kind: Some("background-music".to_string()),
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: u64::from(normalized.price_mud_points),
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -123,6 +123,159 @@ pub struct AdminUpdateWorkVisibilityResponse {
|
||||
pub entry: AdminWorkVisibilityEntryPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorAssetListQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub asset_kind: Option<String>,
|
||||
pub created_after: Option<String>,
|
||||
pub created_before: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorAssetPayload {
|
||||
pub asset_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: Option<String>,
|
||||
pub author_public_user_code: Option<String>,
|
||||
pub folder_id: String,
|
||||
pub label: String,
|
||||
pub asset_object_id: Option<String>,
|
||||
pub image_src: String,
|
||||
pub object_key: Option<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub source_type: String,
|
||||
pub prompt: Option<String>,
|
||||
pub actual_prompt: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub task_id: Option<String>,
|
||||
pub asset_kind: Option<String>,
|
||||
pub generation_inputs: Option<Value>,
|
||||
pub source_resource_id: Option<String>,
|
||||
pub thumbnail_src: Option<String>,
|
||||
pub generation_cost_mud_points: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorAssetListResponse {
|
||||
pub entries: Vec<AdminEditorAssetPayload>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorShowcaseListQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub asset_kind: Option<String>,
|
||||
pub review_status: Option<String>,
|
||||
pub submitted_after: Option<String>,
|
||||
pub submitted_before: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorShowcaseAssetPayload {
|
||||
pub showcase_id: String,
|
||||
pub asset_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub author_display_name: Option<String>,
|
||||
pub author_public_user_code: Option<String>,
|
||||
pub label: String,
|
||||
pub image_src: String,
|
||||
pub object_key: Option<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub prompt: Option<String>,
|
||||
pub actual_prompt: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub task_id: Option<String>,
|
||||
pub asset_kind: Option<String>,
|
||||
pub generation_inputs: Option<Value>,
|
||||
pub generation_cost_mud_points: u64,
|
||||
pub refund_mud_points: u64,
|
||||
pub review_status: String,
|
||||
pub display_enabled: bool,
|
||||
pub like_count: u64,
|
||||
pub asset_deleted_while_pending: bool,
|
||||
pub reviewed_by_admin_user_id: Option<String>,
|
||||
pub review_note: Option<String>,
|
||||
pub refund_ledger_id: Option<String>,
|
||||
pub refund_completed_at: Option<String>,
|
||||
pub submitted_at: String,
|
||||
pub reviewed_at: Option<String>,
|
||||
pub approved_at: Option<String>,
|
||||
pub rejected_at: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorShowcaseListResponse {
|
||||
pub entries: Vec<AdminEditorShowcaseAssetPayload>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorShowcaseReviewRequest {
|
||||
pub showcase_id: String,
|
||||
pub review_status: String,
|
||||
pub review_note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorShowcaseDisplayRequest {
|
||||
pub showcase_id: String,
|
||||
pub display_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorShowcaseAssetResponse {
|
||||
pub entry: AdminEditorShowcaseAssetPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorShowcaseCampaignPayload {
|
||||
pub enabled: bool,
|
||||
pub title: String,
|
||||
pub image_src: String,
|
||||
pub prompt: String,
|
||||
pub author: String,
|
||||
pub cost_text: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEditorShowcaseCampaignResponse {
|
||||
pub campaign: Option<AdminEditorShowcaseCampaignPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertEditorShowcaseCampaignRequest {
|
||||
pub enabled: bool,
|
||||
pub title: String,
|
||||
pub image_src: String,
|
||||
pub prompt: String,
|
||||
pub author: String,
|
||||
pub cost_text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminLoginResponse {
|
||||
@@ -411,3 +564,55 @@ pub struct AdminTrackingEventKeyPayload {
|
||||
pub struct AdminTrackingEventKeyListResponse {
|
||||
pub event_keys: Vec<AdminTrackingEventKeyPayload>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::AdminEditorShowcaseAssetPayload;
|
||||
|
||||
#[test]
|
||||
fn editor_showcase_asset_payload_serializes_author_fields_as_camel_case() {
|
||||
let payload = AdminEditorShowcaseAssetPayload {
|
||||
showcase_id: "showcase-1".to_string(),
|
||||
asset_id: "asset-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
author_display_name: Some("作者昵称".to_string()),
|
||||
author_public_user_code: Some("SY-00000042".to_string()),
|
||||
label: "角色形象 1".to_string(),
|
||||
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
|
||||
object_key: Some("generated-character-drafts/editor/spec.png".to_string()),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
prompt: None,
|
||||
actual_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
task_id: None,
|
||||
asset_kind: Some("character".to_string()),
|
||||
generation_inputs: None,
|
||||
generation_cost_mud_points: 12,
|
||||
refund_mud_points: 6,
|
||||
review_status: "pending".to_string(),
|
||||
display_enabled: false,
|
||||
like_count: 0,
|
||||
asset_deleted_while_pending: false,
|
||||
reviewed_by_admin_user_id: None,
|
||||
review_note: None,
|
||||
refund_ledger_id: None,
|
||||
refund_completed_at: None,
|
||||
submitted_at: "2026-07-04T10:00:00.000Z".to_string(),
|
||||
reviewed_at: None,
|
||||
approved_at: None,
|
||||
rejected_at: None,
|
||||
updated_at: "2026-07-04T10:00:00.000Z".to_string(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(payload).expect("payload should serialize");
|
||||
|
||||
assert_eq!(value["authorDisplayName"], json!("作者昵称"));
|
||||
assert_eq!(value["authorPublicUserCode"], json!("SY-00000042"));
|
||||
assert!(value.get("author_display_name").is_none());
|
||||
assert!(value.get("author_public_user_code").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,4 +479,229 @@ impl SpacetimeClient {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn submit_editor_showcase_asset(
|
||||
&self,
|
||||
input: EditorShowcaseAssetSubmitRecordInput,
|
||||
) -> Result<EditorShowcaseAssetRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"submit_editor_showcase_asset_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.submit_editor_showcase_asset_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_asset_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_public_editor_showcase_assets(
|
||||
&self,
|
||||
input: EditorShowcaseAssetPublicListRecordInput,
|
||||
) -> Result<Vec<EditorShowcaseAssetRecord>, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"list_public_editor_showcase_assets_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.list_public_editor_showcase_assets_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_asset_list_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_list_editor_showcase_assets(
|
||||
&self,
|
||||
input: EditorShowcaseAssetAdminListRecordInput,
|
||||
) -> Result<Vec<EditorShowcaseAssetRecord>, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"admin_list_editor_showcase_assets_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_list_editor_showcase_assets_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_asset_list_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_review_editor_showcase_asset(
|
||||
&self,
|
||||
input: EditorShowcaseAssetAdminReviewRecordInput,
|
||||
) -> Result<EditorShowcaseAssetRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"admin_review_editor_showcase_asset_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_review_editor_showcase_asset_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_asset_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_editor_showcase_asset_display(
|
||||
&self,
|
||||
input: EditorShowcaseAssetDisplayUpdateRecordInput,
|
||||
) -> Result<EditorShowcaseAssetRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"update_editor_showcase_asset_display_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.update_editor_showcase_asset_display_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_asset_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_editor_showcase_asset_refunded(
|
||||
&self,
|
||||
input: EditorShowcaseAssetRefundMarkRecordInput,
|
||||
) -> Result<EditorShowcaseAssetRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"mark_editor_showcase_asset_refunded_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.mark_editor_showcase_asset_refunded_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_asset_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn toggle_editor_showcase_asset_like(
|
||||
&self,
|
||||
input: EditorShowcaseAssetLikeToggleRecordInput,
|
||||
) -> Result<EditorShowcaseAssetRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"toggle_editor_showcase_asset_like_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.toggle_editor_showcase_asset_like_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_asset_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_editor_showcase_campaign_config(
|
||||
&self,
|
||||
input: EditorShowcaseCampaignConfigGetRecordInput,
|
||||
) -> Result<Option<EditorShowcaseCampaignConfigRecord>, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"get_editor_showcase_campaign_config_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.get_editor_showcase_campaign_config_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_campaign_config_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upsert_editor_showcase_campaign_config(
|
||||
&self,
|
||||
input: EditorShowcaseCampaignConfigUpsertRecordInput,
|
||||
) -> Result<Option<EditorShowcaseCampaignConfigRecord>, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"upsert_editor_showcase_campaign_config_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.upsert_editor_showcase_campaign_config_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_showcase_campaign_config_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,19 +39,25 @@ pub use mapper::{
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
|
||||
EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord,
|
||||
EditorProjectResourceShowcaseUpdateRecordInput, ExternalApiKeyAuthenticateRecordInput,
|
||||
ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput,
|
||||
ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobClaimRecordInput,
|
||||
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
|
||||
ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
|
||||
ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput,
|
||||
ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput,
|
||||
ExternalGenerationQueueStatsRecord, JumpHopActionRequest, JumpHopActionResponse,
|
||||
JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse,
|
||||
JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse,
|
||||
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult,
|
||||
JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse,
|
||||
JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
||||
EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetAdminListRecordInput,
|
||||
EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput,
|
||||
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
|
||||
EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput,
|
||||
EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput,
|
||||
EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput,
|
||||
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord,
|
||||
ExternalApiKeyRevokeRecordInput, ExternalGenerationJobAcknowledgeRecordInput,
|
||||
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
|
||||
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
|
||||
ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord,
|
||||
ExternalGenerationJobListRecordInput, ExternalGenerationJobRecord,
|
||||
ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord,
|
||||
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
||||
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
||||
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
|
||||
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
|
||||
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
|
||||
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse,
|
||||
@@ -202,8 +208,7 @@ use module_runtime::{
|
||||
RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeAdminListRecord,
|
||||
RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRechargeProductConfigRecord,
|
||||
RuntimeProfileRedeemCodeAdminListRecord,
|
||||
RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRedeemCodeAdminListRecord,
|
||||
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord,
|
||||
@@ -214,9 +219,8 @@ use module_runtime::{
|
||||
RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, build_analytics_metric_query_input,
|
||||
build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input,
|
||||
build_runtime_browse_history_record, build_runtime_browse_history_sync_input,
|
||||
build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record,
|
||||
build_runtime_profile_code_operation_record,
|
||||
build_runtime_profile_feedback_submission_input,
|
||||
build_runtime_profile_code_operation_record, build_runtime_profile_dashboard_get_input,
|
||||
build_runtime_profile_dashboard_record, build_runtime_profile_feedback_submission_input,
|
||||
build_runtime_profile_feedback_submission_record,
|
||||
build_runtime_profile_invite_code_admin_list_input,
|
||||
build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record,
|
||||
|
||||
@@ -82,7 +82,12 @@ pub use self::editor_project::{
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
|
||||
EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord,
|
||||
EditorProjectResourceShowcaseUpdateRecordInput,
|
||||
EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetAdminListRecordInput,
|
||||
EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput,
|
||||
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
|
||||
EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput,
|
||||
EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput,
|
||||
EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput,
|
||||
};
|
||||
pub use self::external_api_key::{
|
||||
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord,
|
||||
@@ -214,7 +219,9 @@ pub(crate) use self::editor_project::{
|
||||
map_editor_project_list_procedure_result, map_editor_project_optional_procedure_result,
|
||||
map_editor_project_required_procedure_result,
|
||||
map_editor_project_resource_list_procedure_result,
|
||||
map_editor_project_resource_procedure_result,
|
||||
map_editor_project_resource_procedure_result, map_editor_showcase_asset_list_procedure_result,
|
||||
map_editor_showcase_asset_procedure_result,
|
||||
map_editor_showcase_campaign_config_procedure_result,
|
||||
};
|
||||
pub(crate) use self::external_api_key::{
|
||||
map_external_api_key_list_procedure_result, map_external_api_key_single_procedure_result,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user