完善精选审核与素材查询展示

移除素材查询分类筛选和分类列

精选审核通过后改为手动设置展示分类

公开精选按分类展示并补充底部点赞按钮

使用文档大拇指透明图标并同步点赞测试

同步精选审核数据契约、绑定和文档
This commit is contained in:
2026-07-05 17:56:29 +08:00
parent a24bbeeeb8
commit b288b091b2
29 changed files with 571 additions and 175 deletions
-2
View File
@@ -599,7 +599,6 @@ 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, 'keyword', query.keyword);
appendQueryParam(params, 'createdAfter', query.createdAfter);
appendQueryParam(params, 'createdBefore', query.createdBefore);
@@ -614,7 +613,6 @@ 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);
+2 -2
View File
@@ -349,7 +349,6 @@ export interface AdminUpdateWorkVisibilityResponse {
export interface AdminEditorAssetListQuery {
cursor?: string | null;
ownerUserId?: string | null;
assetKind?: string | null;
keyword?: string | null;
createdAfter?: string | null;
createdBefore?: string | null;
@@ -391,7 +390,6 @@ export interface AdminEditorAssetListResponse {
export interface AdminEditorShowcaseListQuery {
cursor?: string | null;
ownerUserId?: string | null;
assetKind?: string | null;
reviewStatus?: string | null;
submittedAfter?: string | null;
submittedBefore?: string | null;
@@ -431,6 +429,7 @@ export interface AdminEditorShowcaseAssetPayload {
approvedAt?: string | null;
rejectedAt?: string | null;
updatedAt: string;
showcaseCategory?: string | null;
}
export interface AdminEditorShowcaseListResponse {
@@ -447,6 +446,7 @@ export interface AdminEditorShowcaseReviewRequest {
export interface AdminEditorShowcaseDisplayRequest {
showcaseId: string;
displayEnabled: boolean;
showcaseCategory?: string | null;
}
export interface AdminEditorShowcaseAssetResponse {
@@ -71,7 +71,7 @@ test('后台素材查询展示作者昵称和陶泥号', async () => {
expect(screen.queryByText('user-1')).toBeNull();
});
test('后台素材查询按用户、分类和时间调用查询接口', async () => {
test('后台素材查询按用户、搜索和时间调用查询接口', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
@@ -84,9 +84,6 @@ test('后台素材查询按用户、分类和时间调用查询接口', async ()
fireEvent.change(screen.getByLabelText('用户 ID'), {
target: {value: 'user-1'},
});
fireEvent.change(screen.getByLabelText('分类'), {
target: {value: 'character'},
});
fireEvent.change(screen.getByLabelText('搜索'), {
target: {value: '陶泥角色'},
});
@@ -100,7 +97,6 @@ test('后台素材查询按用户、分类和时间调用查询接口', async ()
await waitFor(() => {
expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', {
ownerUserId: 'user-1',
assetKind: 'character',
keyword: '陶泥角色',
createdAfter: '2026-07-01T00:00:00+08:00',
createdBefore: '2026-07-04T23:59:59.999+08:00',
@@ -109,6 +105,19 @@ test('后台素材查询按用户、分类和时间调用查询接口', async ()
});
});
test('后台素材查询不展示分类筛选和分类列', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
expect(screen.queryByLabelText('分类')).toBeNull();
expect(screen.queryByRole('columnheader', {name: '分类'})).toBeNull();
});
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
render(
<AdminEditorAssetQueryPage
@@ -130,6 +139,29 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () =>
});
});
test('后台素材查询格式化微秒时间文本', async () => {
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
createdAt: '1783231493.573727Z',
updatedAt: '1783231493.573727Z',
},
],
nextCursor: null,
});
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
});
test('后台素材查询可查看素材详情', async () => {
render(
<AdminEditorAssetQueryPage
@@ -20,16 +20,6 @@ interface AdminEditorAssetQueryPageProps {
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const assetKindOptions = [
{value: '', label: '全部'},
{value: 'character', label: '角色'},
{value: 'ui-design', label: 'UI'},
{value: 'icon', label: '图标'},
{value: 'icon-spritesheet', label: '图标图集'},
{value: 'spec', label: '规范'},
{value: 'publication-material', label: '宣发素材'},
];
export function AdminEditorAssetQueryPage({
token,
onUnauthorized,
@@ -37,7 +27,6 @@ export function AdminEditorAssetQueryPage({
const [entries, setEntries] = useState<AdminEditorAssetPayload[]>([]);
const [keyword, setKeyword] = useState('');
const [ownerUserId, setOwnerUserId] = useState('');
const [assetKind, setAssetKind] = useState('');
const [createdAfter, setCreatedAfter] = useState('');
const [createdBefore, setCreatedBefore] = useState('');
const [nextCursor, setNextCursor] = useState<string | null>(null);
@@ -54,7 +43,7 @@ export function AdminEditorAssetQueryPage({
useEffect(() => {
void refreshPage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, ownerUserId, assetKind, keyword, createdAfter, createdBefore]);
}, [token, ownerUserId, keyword, createdAfter, createdBefore]);
async function refreshPage() {
setIsLoading(true);
@@ -93,7 +82,6 @@ export function AdminEditorAssetQueryPage({
function buildListQuery(): AdminEditorAssetListQuery {
return {
ownerUserId: ownerUserId || null,
assetKind: assetKind || null,
keyword: keyword.trim() || null,
createdAfter: dateInputToStartRfc3339(createdAfter),
createdBefore: dateInputToEndRfc3339(createdBefore),
@@ -149,19 +137,6 @@ export function AdminEditorAssetQueryPage({
onChange={(event) => setOwnerUserId(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<select
value={assetKind}
onChange={(event) => setAssetKind(event.target.value)}
>
{assetKindOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="admin-field">
<span></span>
<input
@@ -179,7 +154,6 @@ export function AdminEditorAssetQueryPage({
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@@ -199,19 +173,13 @@ export function AdminEditorAssetQueryPage({
>
<AdminAssetThumbnail entry={entry} />
</button>
<small>{entry.label || '-'}</small>
</td>
<td>
{formatDateTime(entry.createdAt)}
<small>{entry.assetId}</small>
</td>
<td>{formatDateTime(entry.createdAt)}</td>
<td>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</td>
<td>
{assetKindLabel(entry.assetKind || entry.provider || '-')}
<small>{entry.provider || '-'}</small>
</td>
<td>
<button
className="admin-text-button admin-asset-query-prompt-text"
@@ -359,9 +327,6 @@ function AdminAssetDetailDialog({
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</AdminInfoItem>
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
<AdminInfoItem label="分类">
{assetKindLabel(entry.assetKind || '-')}
</AdminInfoItem>
<AdminInfoItem label="尺寸">
{entry.width} x {entry.height}
</AdminInfoItem>
@@ -498,7 +463,7 @@ function mergeAssetEntries(
[...current, ...incoming].forEach((entry) => byId.set(entry.assetId, entry));
return [...byId.values()].sort(
(left, right) =>
Date.parse(right.createdAt) - Date.parse(left.createdAt) ||
parseAdminTimestamp(right.createdAt) - parseAdminTimestamp(left.createdAt) ||
right.assetId.localeCompare(left.assetId),
);
}
@@ -512,7 +477,7 @@ function dateInputToEndRfc3339(value: string) {
}
function formatDateTime(value: string) {
const timestamp = Date.parse(value);
const timestamp = parseAdminTimestamp(value);
if (!Number.isFinite(timestamp)) {
return value || '-';
}
@@ -525,13 +490,17 @@ function formatDateTime(value: string) {
}).format(timestamp);
}
function assetKindLabel(value: string) {
const normalizedValue = value.trim();
return (
assetKindOptions.find((option) => option.value === normalizedValue)?.label ||
normalizedValue ||
'-'
);
function parseAdminTimestamp(value: string | null | undefined) {
const normalizedValue = value?.trim() ?? '';
const secondsMicrosMatch = normalizedValue.match(/^(-?\d+)\.(\d{6})Z$/u);
if (secondsMicrosMatch) {
const seconds = Number(secondsMicrosMatch[1]);
const micros = Number(secondsMicrosMatch[2]);
if (Number.isFinite(seconds) && Number.isFinite(micros)) {
return seconds * 1000 + Math.floor(micros / 1000);
}
}
return Date.parse(normalizedValue);
}
function authorDisplayName(entry: AdminEditorAssetPayload) {
@@ -56,6 +56,7 @@ const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
approvedAt: null,
rejectedAt: null,
updatedAt: '2026-07-04T10:00:00Z',
showcaseCategory: null,
};
const approvedShowcaseAsset: AdminEditorShowcaseAssetPayload = {
@@ -65,6 +66,7 @@ const approvedShowcaseAsset: AdminEditorShowcaseAssetPayload = {
label: '角色形象 2',
reviewStatus: 'approved',
displayEnabled: true,
showcaseCategory: 'characters',
reviewedAt: '2026-07-04T10:10:00Z',
approvedAt: '2026-07-04T10:10:00Z',
};
@@ -97,7 +99,7 @@ beforeEach(() => {
entry: {
...pendingShowcaseAsset,
reviewStatus: 'approved',
displayEnabled: true,
displayEnabled: false,
reviewedAt: '2026-07-04T10:10:00Z',
approvedAt: '2026-07-04T10:10:00Z',
},
@@ -137,7 +139,6 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
expect(screen.getByLabelText('状态')).toHaveProperty('value', 'pending');
expect(listAdminEditorShowcaseAssets).toHaveBeenCalledWith('admin-token', {
ownerUserId: null,
assetKind: null,
reviewStatus: 'pending',
submittedAfter: null,
submittedBefore: null,
@@ -145,6 +146,29 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
});
});
test('后台精选审核格式化微秒时间并显示素材名', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
submittedAt: '1783231493.573727Z',
},
],
nextCursor: null,
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
expect(screen.queryByText('showcase-1')).toBeNull();
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
});
test('后台精选审核可以通过素材并查看完整提示词', async () => {
render(
<AdminEditorShowcaseReviewPage
@@ -192,6 +216,7 @@ test('后台精选审核可以切换展示和保存活动卡', async () => {
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: 'characters',
},
);
});
@@ -218,3 +243,81 @@ test('后台精选审核可以切换展示和保存活动卡', async () => {
);
});
});
test('后台精选审核已通过素材可以设置精选分类', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...approvedShowcaseAsset,
showcaseCategory: null,
displayEnabled: false,
},
],
nextCursor: null,
});
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValueOnce({
entry: {
...approvedShowcaseAsset,
showcaseCategory: 'ui',
displayEnabled: false,
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
target: {value: 'ui'},
});
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: 'ui',
},
);
});
});
test('后台精选审核清空精选分类时自动隐藏素材', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [approvedShowcaseAsset],
nextCursor: null,
});
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValueOnce({
entry: {
...approvedShowcaseAsset,
showcaseCategory: null,
displayEnabled: false,
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
target: {value: ''},
});
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: null,
},
);
});
});
@@ -25,14 +25,12 @@ interface AdminEditorShowcaseReviewPageProps {
const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300;
const assetKindOptions = [
{value: '', label: '全部'},
{value: 'character', label: '角色'},
{value: 'ui-design', label: 'UI'},
{value: 'icon', label: '图标'},
{value: 'icon-spritesheet', label: '图标图集'},
{value: 'spec', label: '规范'},
{value: 'publication-material', label: '宣发素材'},
const showcaseCategoryOptions = [
{value: 'packs', label: '素材包'},
{value: 'characters', label: '角色'},
{value: 'ui', label: 'UI'},
{value: 'music', label: '音乐'},
{value: 'marketing', label: '美宣'},
];
const reviewStatusOptions = [
@@ -49,7 +47,6 @@ export function AdminEditorShowcaseReviewPage({
const [entries, setEntries] = useState<AdminEditorShowcaseAssetPayload[]>([]);
const [reviewStatus, setReviewStatus] = useState('pending');
const [ownerUserId, setOwnerUserId] = useState('');
const [assetKind, setAssetKind] = useState('');
const [submittedAfter, setSubmittedAfter] = useState('');
const [submittedBefore, setSubmittedBefore] = useState('');
const [nextCursor, setNextCursor] = useState<string | null>(null);
@@ -77,7 +74,7 @@ export function AdminEditorShowcaseReviewPage({
useEffect(() => {
void refreshPage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, reviewStatus, ownerUserId, assetKind, submittedAfter, submittedBefore]);
}, [token, reviewStatus, ownerUserId, submittedAfter, submittedBefore]);
useEffect(() => {
void getAdminEditorShowcaseCampaign(token)
@@ -131,7 +128,6 @@ export function AdminEditorShowcaseReviewPage({
function buildListQuery(): AdminEditorShowcaseListQuery {
return {
ownerUserId: ownerUserId || null,
assetKind: assetKind || null,
reviewStatus: reviewStatus || null,
submittedAfter: dateInputToStartRfc3339(submittedAfter),
submittedBefore: dateInputToEndRfc3339(submittedBefore),
@@ -166,6 +162,25 @@ export function AdminEditorShowcaseReviewPage({
const response = await updateAdminEditorShowcaseDisplay(token, {
showcaseId: entry.showcaseId,
displayEnabled: !entry.displayEnabled,
showcaseCategory: entry.showcaseCategory ?? null,
});
replaceEntry(response.entry);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
}
}
async function updateCategory(
entry: AdminEditorShowcaseAssetPayload,
showcaseCategory: string,
) {
setErrorMessage('');
const nextCategory = showcaseCategory.trim() || null;
try {
const response = await updateAdminEditorShowcaseDisplay(token, {
showcaseId: entry.showcaseId,
displayEnabled: nextCategory ? entry.displayEnabled : false,
showcaseCategory: nextCategory,
});
replaceEntry(response.entry);
} catch (error: unknown) {
@@ -267,19 +282,6 @@ export function AdminEditorShowcaseReviewPage({
onChange={(event) => setOwnerUserId(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<select
value={assetKind}
onChange={(event) => setAssetKind(event.target.value)}
>
{assetKindOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="admin-table-wrap">
@@ -310,25 +312,47 @@ export function AdminEditorShowcaseReviewPage({
>
<AdminShowcaseThumbnail entry={entry} />
</button>
<small>{entry.label || '-'}</small>
</td>
<td>
{formatDateTime(entry.submittedAt)}
<small>{entry.showcaseId}</small>
</td>
<td>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</td>
<td>
{assetKindLabel(entry.assetKind || entry.provider || '-')}
<small>{entry.provider || '-'}</small>
{entry.reviewStatus === 'approved' ? (
<select
aria-label={`精选分类:${entry.label}`}
value={entry.showcaseCategory ?? ''}
onChange={(event) =>
updateCategory(entry, event.target.value)
}
>
<option value=""></option>
{showcaseCategoryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
'-'
)}
</td>
<td>
<span className={reviewStatusClassName(entry.reviewStatus)}>
{reviewStatusLabel(entry.reviewStatus)}
</span>
{entry.reviewStatus === 'approved' ? (
<small>{entry.displayEnabled ? '展示中' : '已隐藏'}</small>
<small>
{entry.displayEnabled
? '展示中'
: entry.showcaseCategory
? '已隐藏'
: '待设置分类'}
</small>
) : null}
</td>
<td>
@@ -619,7 +643,7 @@ function AdminShowcaseDetailDialog({
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
<AdminInfoItem label="素材 ID">{entry.assetId}</AdminInfoItem>
<AdminInfoItem label="分类">
{assetKindLabel(entry.assetKind || '-')}
{showcaseCategoryLabel(entry.showcaseCategory)}
</AdminInfoItem>
<AdminInfoItem label="状态">
{reviewStatusLabel(entry.reviewStatus)}
@@ -769,7 +793,8 @@ function mergeShowcaseEntries(
);
return [...byId.values()].sort(
(left, right) =>
Date.parse(right.submittedAt) - Date.parse(left.submittedAt) ||
parseAdminTimestamp(right.submittedAt) -
parseAdminTimestamp(left.submittedAt) ||
right.showcaseId.localeCompare(left.showcaseId),
);
}
@@ -783,7 +808,7 @@ function dateInputToEndRfc3339(value: string) {
}
function formatDateTime(value: string) {
const timestamp = Date.parse(value);
const timestamp = parseAdminTimestamp(value);
if (!Number.isFinite(timestamp)) {
return value || '-';
}
@@ -796,10 +821,24 @@ function formatDateTime(value: string) {
}).format(timestamp);
}
function assetKindLabel(value: string) {
const normalizedValue = value.trim();
function parseAdminTimestamp(value: string | null | undefined) {
const normalizedValue = value?.trim() ?? '';
const secondsMicrosMatch = normalizedValue.match(/^(-?\d+)\.(\d{6})Z$/u);
if (secondsMicrosMatch) {
const seconds = Number(secondsMicrosMatch[1]);
const micros = Number(secondsMicrosMatch[2]);
if (Number.isFinite(seconds) && Number.isFinite(micros)) {
return seconds * 1000 + Math.floor(micros / 1000);
}
}
return Date.parse(normalizedValue);
}
function showcaseCategoryLabel(value: string | null | undefined) {
const normalizedValue = value?.trim() ?? '';
return (
assetKindOptions.find((option) => option.value === normalizedValue)?.label ||
showcaseCategoryOptions.find((option) => option.value === normalizedValue)
?.label ||
normalizedValue ||
'-'
);
+21 -8
View File
@@ -567,6 +567,8 @@ button:disabled {
.admin-asset-query-thumb-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
@@ -578,7 +580,8 @@ button:disabled {
width: 72px;
height: 72px;
background: #fffdf9;
object-fit: cover;
object-fit: contain;
object-position: center;
}
.admin-asset-query-thumb-placeholder {
@@ -1008,21 +1011,16 @@ button:disabled {
.admin-asset-query-table th:nth-child(4),
.admin-asset-query-table td:nth-child(4) {
width: 10%;
width: 34%;
}
.admin-asset-query-table th:nth-child(5),
.admin-asset-query-table td:nth-child(5) {
width: 30%;
width: 8%;
}
.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%;
}
@@ -1033,6 +1031,21 @@ button:disabled {
.admin-showcase-review-table th:nth-child(1),
.admin-showcase-review-table td:nth-child(1) {
width: 8%;
text-align: center;
}
.admin-showcase-review-table td:nth-child(1) small {
display: block;
max-width: 100%;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-showcase-review-table td:nth-child(4) select {
width: 100%;
min-width: 0;
}
.admin-showcase-review-table th:nth-child(2),
@@ -27,7 +27,7 @@
## 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 接口只保留兼容,不再驱动精选公开。
- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false``showcase_category=null`,运营需按前台 Tab 手动设置分类并开启展示;审核通过时`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`
@@ -48,7 +48,7 @@
- 新增 `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``generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
- 新增 `editor_showcase_asset``editor_showcase_asset_like``editor_showcase_campaign_config` 表承接 `陶泥儿精选`:生成素材默认不公开,用户在素材菜单中提交精选审核后生成独立快照;后台审核通过才进入公开精选,并按生成成本返还 50% 泥点。公开列表不再读取 `editor_project_resource.public_showcase_enabled`,而是读取已通过展示开启的精选快照,支持点赞数和首位活动卡。
- 新增 `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。
@@ -62,7 +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/editor/projects/{projectId}/agent-conversations``/api/editor/agent-conversations/{conversationId}*``/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` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset``source_type = 'generated'` 的素材,支持 `ownerUserId``assetKind``keyword``createdAfter``createdBefore``cursor``limit`;返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。
- 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset``source_type = 'generated'` 的素材,支持 `ownerUserId``keyword``createdAfter``createdBefore``cursor``limit`;返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。
- 自定义世界 / RPG`/api/runtime/custom-world*``/api/story/*``/api/runtime/chat/*`
- 拼图:`/api/runtime/puzzle/*`
- 抓大鹅 Match3D`/api/creation/match3d/*``/api/runtime/match3d/*`
@@ -502,7 +502,7 @@ npm run check:server-rs-ddd
- 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`,已拒绝记录删除,已通过记录保留快照继续展示。
- 说明:`陶泥儿精选` 的独立审核与公开快照表。用户从账号级生成素材提交后,后端把素材媒体、提示词、生成输入、素材类型和 `generation_cost_mud_points` 快照到该表,初始 `review_status = pending``display_enabled = false``showcase_category = null`。后台审核通过后写入 `approved`,但仍保持不展示;运营需在后台按前台 Tab 手动设置 `showcase_category``packs``characters``ui``music``marketing`)并开启展示后,素材才进入公开精选。审核通过时生成确定性返还流水 `editor-showcase-refund:{showcase_id}`,BFF 按 50% 生成成本返还泥点后回写 `refund_completed_at`;拒绝后写入 `rejected`。公开精选 `GET /api/editor/showcase/resources` 只读取 `review_status = approved``display_enabled = true``showcase_category` 合法且媒体非空的记录,按通过时间 / `showcase_id` 倒序 cursor 分页。素材删除时,待审核记录标记 `asset_deleted_while_pending`,已拒绝记录删除,已通过记录保留快照继续展示。
- 索引:`by_editor_showcase_asset_owner_user_id``by_editor_showcase_asset_review_status`
### `editor_showcase_asset_like`
@@ -73,7 +73,7 @@
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `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 显示保守占位,不伪造内容。
精选内容只使用用户从账号级素材库主动提交、后台审核通过、手动设置精选分类且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营需在后台按前台 Tab 设置分类(素材包、角色、UI、音乐、美宣)并开启展示。`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` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。
@@ -112,12 +112,12 @@ Tab
数据来源:
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId``assetId`、审核状态、展示状态、点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name``authorPublicUserCode` / 陶泥号用于展示;前端只展示 `reviewStatus="approved"``displayEnabled=true` 的后端结果。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId``assetId`、审核状态、展示状态、`showcaseCategory`点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name``authorPublicUserCode` / 陶泥号用于展示;前端`showcaseCategory` 放入素材包、角色、UI、音乐或美宣 Tab,只展示后端已经筛过的公开结果。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。
- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。
- 后台新增纯素材查询页,读取账号级 `editor_asset``sourceType="generated"` 的素材,用于查看所有用户生成素材。该页只提供时间、用户 ID、分类和关键词查询,以及缩略图详情、提示词全文、生成成本、作者展示名和陶泥号查看;不承载审核、返还、展示开关或活动卡配置操作。
- 后台新增纯素材查询页,读取账号级 `editor_asset``sourceType="generated"` 的素材,用于查看所有用户生成素材。该页只提供时间、用户 ID 和关键词查询,以及缩略图详情、提示词全文、生成成本、作者展示名和陶泥号查看;不提供分类筛选,也不承载审核、返还、展示开关或活动卡配置操作。
- 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。
- 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。
@@ -128,7 +128,7 @@ Tab
- 最近项目继续使用编辑器项目接口:`listEditorProjects``createEditorProject``/editor/canvas?projectid=xxx`
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由;新建画布引导使用 `guide=toolbar` 一次性 query,主站九宫格直达生成器使用 `tool=<intent>` 一次性 query,画布消费后通过 `history.replaceState` 清理这两个参数。
- 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。
- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status``display_enabled` 为准,前端只做展示组合、点赞交互和提交审核的乐观状态回滚。
- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status``display_enabled``showcase_category` 为准,前端只做展示组合、Tab 归类、点赞交互和提交审核的乐观状态回滚。
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
## 路由与导航
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+3 -2
View File
@@ -422,7 +422,6 @@ pub async fn admin_list_editor_assets(
limit: page_size + 1,
cursor: normalize_admin_optional(query.cursor),
owner_user_id: normalize_admin_optional(query.owner_user_id),
asset_kind: normalize_admin_optional(query.asset_kind),
keyword: normalize_admin_optional(query.keyword),
created_after_micros: date_range.created_after_micros,
created_before_micros: date_range.created_before_micros,
@@ -463,7 +462,6 @@ pub async fn admin_list_editor_showcase_assets(
limit: page_size + 1,
cursor: normalize_admin_optional(query.cursor),
owner_user_id: normalize_admin_optional(query.owner_user_id),
asset_kind: normalize_admin_optional(query.asset_kind),
review_status: normalize_admin_optional(query.review_status),
submitted_after_micros: query
.submitted_after
@@ -536,6 +534,7 @@ pub async fn admin_update_editor_showcase_asset_display(
admin_user_id: admin.session().subject.clone(),
display_enabled: payload.display_enabled,
updated_at_micros: current_utc_micros(),
showcase_category: payload.showcase_category,
})
.await
.map_err(map_admin_spacetime_error)?;
@@ -706,6 +705,7 @@ fn admin_editor_showcase_asset_payload_from_record(
approved_at: record.approved_at,
rejected_at: record.rejected_at,
updated_at: record.updated_at,
showcase_category: record.showcase_category,
}
}
@@ -3108,6 +3108,7 @@ mod tests {
approved_at: None,
rejected_at: None,
updated_at: "2026-07-04T10:00:00.000Z".to_string(),
showcase_category: None,
}
}
@@ -594,6 +594,8 @@ pub struct EditorProjectResourcePayload {
task_id: Option<String>,
source_resource_id: Option<String>,
asset_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
showcase_category: Option<String>,
generation_inputs: Option<Value>,
public_showcase_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -3186,6 +3188,7 @@ pub(crate) fn editor_project_resource_payload_from_record(
task_id: record.task_id,
source_resource_id: record.source_resource_id,
asset_kind: record.asset_kind,
showcase_category: None,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
@@ -3227,6 +3230,7 @@ fn editor_project_resource_payload_from_showcase_record(
task_id: record.task_id,
source_resource_id: record.source_resource_id,
asset_kind: record.asset_kind,
showcase_category: record.showcase_category,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
@@ -5218,6 +5222,7 @@ mod tests {
approved_at: Some("2026-06-23T00:00:00.000Z".to_string()),
rejected_at: None,
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
showcase_category: Some("characters".to_string()),
}
}
@@ -5326,6 +5331,7 @@ mod tests {
task_id: None,
source_resource_id: None,
asset_kind: Some("spec".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
@@ -5408,6 +5414,7 @@ mod tests {
task_id: None,
source_resource_id: None,
asset_kind: Some("spec".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
@@ -5486,6 +5493,7 @@ mod tests {
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
@@ -5842,6 +5850,7 @@ mod tests {
task_id: Some("task-character".to_string()),
source_resource_id: Some("resource-spec".to_string()),
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: Some(json!({
"fields": [{"title": "角色设定", "value": "银发游侠"}],
"references": []
@@ -5946,6 +5955,7 @@ mod tests {
task_id: Some("extgen-cutout".to_string()),
source_resource_id: Some("resource-source".to_string()),
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: Some(json!({
"fields": [{"title": "prompt", "value": "角色"}],
"references": []
@@ -6042,6 +6052,7 @@ mod tests {
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
@@ -6134,6 +6145,7 @@ mod tests {
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
@@ -6267,6 +6279,7 @@ mod tests {
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
@@ -128,7 +128,6 @@ pub struct AdminUpdateWorkVisibilityResponse {
pub struct AdminEditorAssetListQuery {
pub cursor: Option<String>,
pub owner_user_id: Option<String>,
pub asset_kind: Option<String>,
pub keyword: Option<String>,
pub created_after: Option<String>,
pub created_before: Option<String>,
@@ -176,7 +175,6 @@ pub struct AdminEditorAssetListResponse {
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>,
@@ -218,6 +216,7 @@ pub struct AdminEditorShowcaseAssetPayload {
pub approved_at: Option<String>,
pub rejected_at: Option<String>,
pub updated_at: String,
pub showcase_category: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -240,6 +239,7 @@ pub struct AdminEditorShowcaseReviewRequest {
pub struct AdminEditorShowcaseDisplayRequest {
pub showcase_id: String,
pub display_enabled: bool,
pub showcase_category: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -607,6 +607,7 @@ mod tests {
approved_at: None,
rejected_at: None,
updated_at: "2026-07-04T10:00:00.000Z".to_string(),
showcase_category: None,
};
let value = serde_json::to_value(payload).expect("payload should serialize");
@@ -172,6 +172,7 @@ pub struct EditorShowcaseAssetRecord {
pub approved_at: Option<String>,
pub rejected_at: Option<String>,
pub updated_at: String,
pub showcase_category: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -349,7 +350,6 @@ pub struct AdminEditorAssetListRecordInput {
pub limit: u32,
pub cursor: Option<String>,
pub owner_user_id: Option<String>,
pub asset_kind: Option<String>,
pub keyword: Option<String>,
pub created_after_micros: Option<i64>,
pub created_before_micros: Option<i64>,
@@ -373,7 +373,6 @@ pub struct EditorShowcaseAssetAdminListRecordInput {
pub limit: u32,
pub cursor: Option<String>,
pub owner_user_id: Option<String>,
pub asset_kind: Option<String>,
pub review_status: Option<String>,
pub submitted_after_micros: Option<i64>,
pub submitted_before_micros: Option<i64>,
@@ -394,6 +393,7 @@ pub struct EditorShowcaseAssetDisplayUpdateRecordInput {
pub admin_user_id: String,
pub display_enabled: bool,
pub updated_at_micros: i64,
pub showcase_category: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -674,7 +674,6 @@ impl From<AdminEditorAssetListRecordInput> for crate::module_bindings::AdminEdit
limit: input.limit,
cursor: input.cursor,
owner_user_id: input.owner_user_id,
asset_kind: input.asset_kind,
keyword: input.keyword,
created_after_micros: input.created_after_micros,
created_before_micros: input.created_before_micros,
@@ -701,7 +700,6 @@ impl From<EditorShowcaseAssetAdminListRecordInput>
limit: input.limit,
cursor: input.cursor,
owner_user_id: input.owner_user_id,
asset_kind: input.asset_kind,
review_status: input.review_status,
submitted_after_micros: input.submitted_after_micros,
submitted_before_micros: input.submitted_before_micros,
@@ -732,6 +730,7 @@ impl From<EditorShowcaseAssetDisplayUpdateRecordInput>
admin_user_id: input.admin_user_id,
display_enabled: input.display_enabled,
updated_at_micros: input.updated_at_micros,
showcase_category: input.showcase_category,
}
}
}
@@ -1204,6 +1203,7 @@ fn map_editor_showcase_asset_snapshot(
approved_at: snapshot.approved_at_micros.map(format_timestamp_micros),
rejected_at: snapshot.rejected_at_micros.map(format_timestamp_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
showcase_category: snapshot.showcase_category,
})
}
@@ -10,7 +10,6 @@ pub struct AdminEditorAssetListInput {
pub limit: u32,
pub cursor: Option<String>,
pub owner_user_id: Option<String>,
pub asset_kind: Option<String>,
pub keyword: Option<String>,
pub created_after_micros: Option<i64>,
pub created_before_micros: Option<i64>,
@@ -10,7 +10,6 @@ pub struct EditorShowcaseAssetAdminListInput {
pub limit: u32,
pub cursor: Option<String>,
pub owner_user_id: Option<String>,
pub asset_kind: Option<String>,
pub review_status: Option<String>,
pub submitted_after_micros: Option<i64>,
pub submitted_before_micros: Option<i64>,
@@ -11,6 +11,7 @@ pub struct EditorShowcaseAssetDisplayUpdateInput {
pub admin_user_id: String,
pub display_enabled: bool,
pub updated_at_micros: i64,
pub showcase_category: Option<String>,
}
impl __sdk::InModule for EditorShowcaseAssetDisplayUpdateInput {
@@ -41,6 +41,7 @@ pub struct EditorShowcaseAssetSnapshot {
pub approved_at_micros: Option<i64>,
pub rejected_at_micros: Option<i64>,
pub updated_at_micros: i64,
pub showcase_category: Option<String>,
}
impl __sdk::InModule for EditorShowcaseAssetSnapshot {
@@ -41,6 +41,7 @@ pub struct EditorShowcaseAsset {
pub approved_at: Option<__sdk::Timestamp>,
pub rejected_at: Option<__sdk::Timestamp>,
pub updated_at: __sdk::Timestamp,
pub showcase_category: Option<String>,
}
impl __sdk::InModule for EditorShowcaseAsset {
@@ -86,6 +87,7 @@ pub struct EditorShowcaseAssetCols {
pub approved_at: __sdk::__query_builder::Col<EditorShowcaseAsset, Option<__sdk::Timestamp>>,
pub rejected_at: __sdk::__query_builder::Col<EditorShowcaseAsset, Option<__sdk::Timestamp>>,
pub updated_at: __sdk::__query_builder::Col<EditorShowcaseAsset, __sdk::Timestamp>,
pub showcase_category: __sdk::__query_builder::Col<EditorShowcaseAsset, Option<String>>,
}
impl __sdk::__query_builder::HasCols for EditorShowcaseAsset {
@@ -141,6 +143,7 @@ impl __sdk::__query_builder::HasCols for EditorShowcaseAsset {
approved_at: __sdk::__query_builder::Col::new(table_name, "approved_at"),
rejected_at: __sdk::__query_builder::Col::new(table_name, "rejected_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
showcase_category: __sdk::__query_builder::Col::new(table_name, "showcase_category"),
}
}
}
@@ -13,6 +13,7 @@ const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending";
const EDITOR_SHOWCASE_STATUS_APPROVED: &str = "approved";
const EDITOR_SHOWCASE_STATUS_REJECTED: &str = "rejected";
const EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID: &str = "global";
const EDITOR_SHOWCASE_CATEGORIES: [&str; 5] = ["packs", "characters", "ui", "music", "marketing"];
#[spacetimedb::table(
accessor = editor_project,
@@ -175,6 +176,8 @@ pub struct EditorShowcaseAsset {
approved_at: Option<Timestamp>,
rejected_at: Option<Timestamp>,
updated_at: Timestamp,
#[default(None::<String>)]
showcase_category: Option<String>,
}
#[spacetimedb::table(
@@ -374,7 +377,6 @@ pub struct AdminEditorAssetListInput {
pub limit: u32,
pub cursor: Option<String>,
pub owner_user_id: Option<String>,
pub asset_kind: Option<String>,
pub keyword: Option<String>,
pub created_after_micros: Option<i64>,
pub created_before_micros: Option<i64>,
@@ -448,6 +450,7 @@ pub struct EditorShowcaseAssetSnapshot {
pub approved_at_micros: Option<i64>,
pub rejected_at_micros: Option<i64>,
pub updated_at_micros: i64,
pub showcase_category: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -561,7 +564,6 @@ pub struct EditorShowcaseAssetAdminListInput {
pub limit: u32,
pub cursor: Option<String>,
pub owner_user_id: Option<String>,
pub asset_kind: Option<String>,
pub review_status: Option<String>,
pub submitted_after_micros: Option<i64>,
pub submitted_before_micros: Option<i64>,
@@ -582,6 +584,7 @@ pub struct EditorShowcaseAssetDisplayUpdateInput {
pub admin_user_id: String,
pub display_enabled: bool,
pub updated_at_micros: i64,
pub showcase_category: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -1510,7 +1513,6 @@ fn admin_list_editor_assets(
let limit = input.limit.clamp(1, 500) as usize;
let cursor = parse_public_showcase_cursor(input.cursor.as_deref());
let owner_user_id = normalize_optional(input.owner_user_id);
let asset_kind = normalize_optional(input.asset_kind);
let keyword = normalize_optional(input.keyword).map(|value| value.to_lowercase());
let mut assets = ctx
.db
@@ -1522,13 +1524,6 @@ fn admin_list_editor_assets(
&& owner_user_id
.as_ref()
.is_none_or(|value| asset.owner_user_id == *value)
&& asset_kind.as_ref().is_none_or(|value| {
asset
.asset_kind
.as_deref()
.map(str::trim)
.is_some_and(|kind| kind == value)
})
&& input
.created_after_micros
.is_none_or(|value| created_at_micros >= value)
@@ -1906,6 +1901,7 @@ fn submit_editor_showcase_asset(
approved_at: None,
rejected_at: None,
updated_at: now,
showcase_category: None,
});
ctx.db
@@ -1935,6 +1931,10 @@ fn list_public_editor_showcase_assets(
&& asset.display_enabled
&& !asset.asset_deleted_while_pending
&& (asset.refund_mud_points == 0 || asset.refund_completed_at.is_some())
&& asset
.showcase_category
.as_deref()
.is_some_and(is_valid_showcase_category)
&& !asset.image_src.trim().is_empty()
&& is_after_public_showcase_cursor(
approved_at_micros,
@@ -1963,7 +1963,6 @@ fn admin_list_editor_showcase_assets(
let limit = input.limit.clamp(1, 500) as usize;
let cursor = parse_public_showcase_cursor(input.cursor.as_deref());
let owner_user_id = normalize_optional(input.owner_user_id);
let asset_kind = normalize_optional(input.asset_kind);
let review_status = normalize_optional(input.review_status)
.map(|status| normalize_showcase_review_status(status.as_str()))
.transpose()?;
@@ -1976,13 +1975,6 @@ fn admin_list_editor_showcase_assets(
owner_user_id
.as_ref()
.is_none_or(|value| asset.owner_user_id == *value)
&& asset_kind.as_ref().is_none_or(|value| {
asset
.asset_kind
.as_deref()
.map(str::trim)
.is_some_and(|kind| kind == value)
})
&& review_status
.as_ref()
.is_none_or(|value| asset.review_status == *value)
@@ -2050,7 +2042,7 @@ fn admin_review_editor_showcase_asset(
.delete(&showcase_id);
ctx.db.editor_showcase_asset().insert(EditorShowcaseAsset {
review_status,
display_enabled: approved && asset.refund_mud_points == 0,
display_enabled: false,
reviewed_by_admin_user_id: Some(admin_user_id),
review_note: normalize_optional(input.review_note),
refund_ledger_id,
@@ -2085,6 +2077,14 @@ fn update_editor_showcase_asset_display(
if asset.asset_deleted_while_pending {
return Err("素材已删除,不能公开展示".to_string());
}
let showcase_category = input
.showcase_category
.map(|value| normalize_showcase_category(value.as_str()))
.transpose()?
.or_else(|| asset.showcase_category.clone());
if input.display_enabled && showcase_category.is_none() {
return Err("公开展示前必须先设置精选分类".to_string());
}
if input.display_enabled && asset.refund_mud_points > 0 && asset.refund_completed_at.is_none() {
return Err("精选返还完成前不能公开展示".to_string());
}
@@ -2097,6 +2097,7 @@ fn update_editor_showcase_asset_display(
display_enabled: input.display_enabled,
reviewed_by_admin_user_id: Some(admin_user_id),
updated_at: now,
showcase_category,
..asset
});
@@ -2127,7 +2128,6 @@ fn mark_editor_showcase_asset_refunded(
.showcase_id()
.delete(&showcase_id);
ctx.db.editor_showcase_asset().insert(EditorShowcaseAsset {
display_enabled: true,
refund_completed_at: Some(now),
updated_at: now,
..asset
@@ -2609,6 +2609,18 @@ fn normalize_showcase_review_status(value: &str) -> Result<String, String> {
}
}
fn normalize_showcase_category(value: &str) -> Result<String, String> {
let normalized = value.trim();
if is_valid_showcase_category(normalized) {
return Ok(normalized.to_string());
}
Err("精选分类只支持 packs、characters、ui、music 或 marketing".to_string())
}
fn is_valid_showcase_category(value: &str) -> bool {
EDITOR_SHOWCASE_CATEGORIES.contains(&value.trim())
}
fn update_editor_showcase_like_count(
ctx: &ReducerContext,
asset: EditorShowcaseAsset,
@@ -2684,6 +2696,7 @@ fn showcase_snapshot_from_row(row: EditorShowcaseAsset) -> EditorShowcaseAssetSn
approved_at_micros: timestamp_option_micros(row.approved_at),
rejected_at_micros: timestamp_option_micros(row.rejected_at),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
showcase_category: row.showcase_category,
}
}
@@ -1257,6 +1257,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null);
}
}
if table_name == "editor_showcase_asset" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:精选分类字段晚于审核表加入,旧迁移包按未设置分类兼容。
object
.entry("showcase_category".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
@@ -381,6 +381,65 @@ describe('CreationLandingView', () => {
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
it('likes a featured asset from the waterfall card action', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-character',
projectId: 'project-newest',
showcaseId: 'showcase-character',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
actualPrompt: null,
model: 'character-model',
provider: 'provider',
taskId: 'task-1',
showcaseCategory: 'characters',
likeCount: 3,
generationCostMudPoints: 20,
},
]);
toggleEditorShowcaseAssetLikeMock.mockResolvedValueOnce({
showcaseId: 'showcase-character',
likeCount: 4,
});
renderCreationLanding();
await user.click(screen.getByRole('tab', { name: '角色' }));
const likeButton = await screen.findByRole('button', {
name: '点赞:角色英雄',
});
expect(
likeButton.querySelector(
'img[src="/creation-home/showcase-like-thumb.png"]',
),
).toBeTruthy();
await user.click(likeButton);
await waitFor(() => {
expect(toggleEditorShowcaseAssetLikeMock).toHaveBeenCalledWith(
'showcase-character',
true,
);
});
expect(
screen.getByRole('button', { name: '取消点赞:角色英雄' }),
).toBeTruthy();
expect(
within(
screen
.getByText('角色英雄')
.closest('.creation-landing__asset-card') as HTMLElement,
).getByText('4'),
).toBeTruthy();
});
it('opens a fullscreen asset preview modal with thumbnail navigation', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
@@ -1,4 +1,4 @@
import { Film, Heart, Image as ImageIcon, Music2 } from 'lucide-react';
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
@@ -61,6 +61,9 @@ const CREATION_HOME_ASSETS = {
projectNew: '/creation-home/project-new.png',
} as const;
const SHOWCASE_LIKE_ICON_SRC =
'/creation-home/showcase-like-thumb.png';
const CREATION_FEATURES: CreationFeatureItem[] = [
{
title: '游戏视觉规范',
@@ -387,10 +390,11 @@ function CreationShowcaseModal({
disabled={isLikePending}
onClick={() => onToggleLike(item)}
>
<Heart
size={16}
<img
className="creation-landing__showcase-like-icon"
src={SHOWCASE_LIKE_ICON_SRC}
alt=""
aria-hidden="true"
fill={isLiked ? 'currentColor' : 'none'}
/>
<span>{isLikePending ? '处理中' : isLiked ? '已点赞' : '点赞'}</span>
</PlatformActionButton>
@@ -780,39 +784,59 @@ export function CreationLandingView({
return (
<>
<div className="creation-landing__asset-waterfall" aria-label="用户素材瀑布流">
{showcaseItems.map((item) => (
<button
key={item.id}
type="button"
className="creation-landing__asset-card"
onClick={() => {
setSelectedShowcaseItem(item);
setSelectedShowcaseIndex(0);
}}
>
<ShowcaseBundlePreview item={item} />
<div className="creation-landing__asset-meta">
<h3>{item.label}</h3>
<p>{item.prompt}</p>
<dl>
<div>
<dt></dt>
<dd>{item.author}</dd>
{showcaseItems.map((item) => {
const showcaseId = item.showcaseId?.trim();
const isLiked = Boolean(
showcaseId && likedShowcaseIds.has(showcaseId),
);
const isLikePending = Boolean(
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
);
return (
<article key={item.id} className="creation-landing__asset-card">
<button
type="button"
className="creation-landing__asset-card-open"
onClick={() => {
setSelectedShowcaseItem(item);
setSelectedShowcaseIndex(0);
}}
>
<ShowcaseBundlePreview item={item} />
<div className="creation-landing__asset-meta">
<h3>{item.label}</h3>
<p>{item.prompt}</p>
<dl>
<div>
<dt></dt>
<dd>{item.author}</dd>
</div>
<div>
<dt></dt>
<dd>{item.cost}</dd>
</div>
</dl>
</div>
<div>
<dt></dt>
<dd>{item.cost}</dd>
</div>
{typeof item.likeCount === 'number' ? (
<div>
<dt></dt>
<dd>{item.likeCount}</dd>
</div>
) : null}
</dl>
</div>
</button>
))}
</button>
{showcaseId ? (
<button
type="button"
className={
isLiked
? 'creation-landing__asset-like-button creation-landing__asset-like-button--active'
: 'creation-landing__asset-like-button'
}
aria-label={`${isLiked ? '取消点赞' : '点赞'}${item.label}`}
disabled={isLikePending}
onClick={() => handleToggleShowcaseLike(item)}
>
<img src={SHOWCASE_LIKE_ICON_SRC} alt="" aria-hidden="true" />
<span>{item.likeCount ?? 0}</span>
</button>
) : null}
</article>
);
})}
</div>
{renderShowcaseLoadMoreSentinel()}
</>

Some files were not shown because too many files have changed in this diff Show More