合并 master 分支

合并 origin/master 到 AI 游戏创作客户端功能分支。

解决画布文档、决策记录和本地工具配置冲突。

适配背景色决策与编辑器 Agent 测试到现行 LLM API。
This commit is contained in:
AIGameCreator App
2026-07-08 11:51:30 +08:00
109 changed files with 5700 additions and 1578 deletions
+3 -1
View File
@@ -12,7 +12,9 @@
"Bash(curl -s http://localhost:8081/health)",
"Bash(npm -v)",
"Bash(cargo --version)",
"Bash(rustc --version)"
"Bash(rustc --version)",
"Skill(code-review)",
"Skill(code-review:*)"
]
}
}
+2
View File
@@ -1,5 +1,7 @@
# 微信小程序 web-view 登录配置。
# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false
WECHAT_MINI_PROGRAM_APP_ID=""
WECHAT_MINI_PROGRAM_APP_SECRET=""
WECHAT_JS_CODE_SESSION_ENDPOINT=""
+4
View File
@@ -199,6 +199,10 @@ VITE_LLM_DEBUG_LOG="false"
# Set to "true" to expose local diagnostic panels, or "false" to hide them.
VITE_DEBUG_MODE=""
# Optional: show the image editor right-side Agent entry at runtime.
# This is read by api-server and exposed through /api/runtime/frontend-config.
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# Optional: official VikingDB credentials for regenerating build-tag similarities
# with the Python embedding script. The script auto-loads `.env.local` and uses
# the fixed `bge-large-zh` embedding model.
+2
View File
@@ -42,6 +42,8 @@ LLM_DEBUG_LOG="true"
# 注意:不要在客户端启用调试日志,避免敏感数据泄露
# VITE_LLM_DEBUG_LOG="false"
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=true
ALIYUN_OSS_BUCKET="xushi-dev"
ALIYUN_OSS_REGION="oss-cn-beijing"
ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
+150
View File
@@ -11,8 +11,11 @@ import type {
AdminDatabaseTableListResponse,
AdminDatabaseTableRowsQuery,
AdminDatabaseTableRowsResponse,
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
AdminEditorAssetListQuery,
AdminEditorAssetListResponse,
AdminDirectUploadTicketPayload,
AdminEditorShowcaseAssetResponse,
AdminEditorShowcaseCampaignResponse,
AdminEditorShowcaseDisplayRequest,
@@ -27,6 +30,7 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest,
@@ -394,6 +398,35 @@ export function upsertAdminEditorShowcaseCampaign(
);
}
export async function uploadAdminEditorShowcaseCampaignImage(
token: string,
file: File,
): Promise<AdminUploadedEditorShowcaseCampaignImage> {
const contentType = resolveAdminImageContentType(file);
const dimensions = await readAdminImageFileDimensions(file);
const response = await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
'/admin/api/editor-showcase/campaign/image-upload-ticket',
{
method: 'POST',
token,
body: {
fileName: file.name.trim() || 'showcase-campaign.png',
contentType,
contentLength: file.size,
} satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
},
);
await postAdminDirectUploadFile(response.upload, file);
const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, '');
return {
imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath,
imageObjectKey: objectKey,
imageWidth: dimensions.imageWidth,
imageHeight: dimensions.imageHeight,
legacyPublicPath: response.upload.legacyPublicPath,
};
}
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',
@@ -556,6 +589,123 @@ function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) {
return queryString ? `?${queryString}` : '';
}
function resolveAdminImageContentType(file: File) {
const declaredType = file.type.trim();
if (declaredType.startsWith('image/')) {
return declaredType;
}
const extension = file.name.trim().toLowerCase().match(/\.([a-z0-9]+)$/u)?.[1];
if (extension === 'jpg' || extension === 'jpeg') {
return 'image/jpeg';
}
if (extension === 'png') {
return 'image/png';
}
if (extension === 'webp') {
return 'image/webp';
}
if (extension === 'gif') {
return 'image/gif';
}
return declaredType || 'application/octet-stream';
}
function normalizeAdminImageDimensions(width: number, height: number) {
const imageWidth = Math.round(width);
const imageHeight = Math.round(height);
if (
!Number.isFinite(imageWidth) ||
!Number.isFinite(imageHeight) ||
imageWidth <= 0 ||
imageHeight <= 0
) {
return null;
}
return { imageWidth, imageHeight };
}
async function readAdminImageFileDimensions(file: File) {
if (typeof createImageBitmap === 'function') {
try {
const bitmap = await createImageBitmap(file);
const dimensions = normalizeAdminImageDimensions(
bitmap.width,
bitmap.height,
);
bitmap.close();
if (dimensions) {
return dimensions;
}
} catch {
// Fall back to HTMLImageElement decoding below.
}
}
if (
typeof Image === 'undefined' ||
typeof URL === 'undefined' ||
typeof URL.createObjectURL !== 'function'
) {
throw new Error('读取活动卡图片尺寸失败,请重新选择图片');
}
return new Promise<{ imageWidth: number; imageHeight: number }>(
(resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
const cleanup = () => {
if (typeof URL.revokeObjectURL === 'function') {
URL.revokeObjectURL(objectUrl);
}
};
image.onload = () => {
cleanup();
const dimensions = normalizeAdminImageDimensions(
image.naturalWidth || image.width,
image.naturalHeight || image.height,
);
if (dimensions) {
resolve(dimensions);
return;
}
reject(new Error('读取活动卡图片尺寸失败,请重新选择图片'));
};
image.onerror = () => {
cleanup();
reject(new Error('读取活动卡图片尺寸失败,请重新选择图片'));
};
image.src = objectUrl;
},
);
}
function buildAdminDirectUploadFormData(
upload: AdminDirectUploadTicketPayload,
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file, file.name);
return formData;
}
async function postAdminDirectUploadFile(
upload: AdminDirectUploadTicketPayload,
file: File,
) {
const response = await fetch(upload.host, {
method: 'POST',
body: buildAdminDirectUploadFormData(upload, file),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
}
function buildQueryString(query: AdminTrackingEventListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'eventKey', query.eventKey);
+33
View File
@@ -457,6 +457,9 @@ export interface AdminEditorShowcaseCampaignPayload {
enabled: boolean;
title: string;
imageSrc: string;
imageObjectKey?: string | null;
imageWidth?: number | null;
imageHeight?: number | null;
prompt: string;
author: string;
costText: string;
@@ -471,11 +474,41 @@ export interface AdminUpsertEditorShowcaseCampaignRequest {
enabled: boolean;
title: string;
imageSrc: string;
imageObjectKey?: string | null;
imageWidth?: number | null;
imageHeight?: number | null;
prompt: string;
author: string;
costText: string;
}
export interface AdminDirectUploadTicketPayload {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
contentType?: string | null;
formFields: Record<string, string | null | undefined>;
}
export interface AdminCreateEditorShowcaseCampaignImageUploadTicketRequest {
fileName: string;
contentType: string;
contentLength: number;
}
export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
upload: AdminDirectUploadTicketPayload;
}
export interface AdminUploadedEditorShowcaseCampaignImage {
imageSrc: string;
imageObjectKey: string;
imageWidth: number;
imageHeight: number;
legacyPublicPath: string;
}
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
@@ -1,14 +1,20 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {beforeEach, expect, test, vi} from 'vitest';
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';
import type { AdminEditorAssetPayload } from '../api/adminApiTypes';
import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
@@ -34,7 +40,7 @@ const generatedAsset: AdminEditorAssetPayload = {
provider: 'character',
taskId: 'task-1',
assetKind: 'character',
generationInputs: {style: 'clay'},
generationInputs: { style: 'clay' },
sourceResourceId: 'resource-1',
thumbnailSrc: null,
generationCostMudPoints: 12,
@@ -60,10 +66,7 @@ beforeEach(() => {
test('后台素材查询展示作者昵称和陶泥号', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('作者昵称')).toBeTruthy();
@@ -73,25 +76,22 @@ test('后台素材查询展示作者昵称和陶泥号', async () => {
test('后台素材查询按用户、搜索和时间调用查询接口', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('img', {name: '素材:角色形象 1'});
await screen.findByRole('img', { name: '素材:角色形象 1' });
fireEvent.change(screen.getByLabelText('用户 ID'), {
target: {value: 'user-1'},
target: { value: 'user-1' },
});
fireEvent.change(screen.getByLabelText('搜索'), {
target: {value: '陶泥角色'},
target: { value: '陶泥角色' },
});
fireEvent.change(screen.getByLabelText('开始时间'), {
target: {value: '2026-07-01'},
target: { value: '2026-07-01' },
});
fireEvent.change(screen.getByLabelText('结束时间'), {
target: {value: '2026-07-04'},
target: { value: '2026-07-04' },
});
await waitFor(() => {
@@ -107,26 +107,20 @@ test('后台素材查询按用户、搜索和时间调用查询接口', async ()
test('后台素材查询不展示分类筛选和分类列', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
expect(screen.queryByLabelText('分类')).toBeNull();
expect(screen.queryByRole('columnheader', {name: '分类'})).toBeNull();
expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull();
});
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const image = await screen.findByRole('img', {name: '素材:角色形象 1'});
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
await waitFor(() => {
expect(image.getAttribute('src')).toBe(
@@ -139,6 +133,32 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () =>
});
});
test('后台素材查询音频素材使用统一封面缩略图', async () => {
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
assetId: 'asset-audio-1',
label: '胜利音效',
imageSrc: '/generated-editor-audios/sfx.mp3',
objectKey: 'generated-editor-audios/sfx.mp3',
assetKind: 'sound-effect',
},
],
nextCursor: null,
});
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const image = await screen.findByRole('img', { name: '素材:胜利音效' });
expect(image.getAttribute('src')).toBe(
'/creation-home/audio-asset-cover.png',
);
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
});
test('后台素材查询格式化微秒时间文本', async () => {
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
@@ -152,10 +172,7 @@ test('后台素材查询格式化微秒时间文本', async () => {
});
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
@@ -164,15 +181,12 @@ test('后台素材查询格式化微秒时间文本', async () => {
test('后台素材查询可查看素材详情', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', {name: '详情'}));
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
const dialog = screen.getByRole('dialog', {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();
@@ -181,20 +195,21 @@ test('后台素材查询可查看素材详情', async () => {
test('后台素材查询可打开弹窗查看完整提示词', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', {name: '完整提示词内容'}));
fireEvent.click(
await screen.findByRole('button', { name: '完整提示词内容' }),
);
const dialog = screen.getByRole('dialog', {name: '完整提示词'});
const dialog = screen.getByRole('dialog', { name: '完整提示词' });
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', {name: '关闭完整提示词'}));
fireEvent.click(
within(dialog).getByRole('button', { name: '关闭完整提示词' }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', {name: '完整提示词'})).toBeNull();
expect(screen.queryByRole('dialog', { name: '完整提示词' })).toBeNull();
});
});
@@ -1,17 +1,17 @@
import {Eye, FileText, RefreshCcw, X} from 'lucide-react';
import type {ReactNode} from 'react';
import {useEffect, useState} from 'react';
import { Eye, FileText, RefreshCcw, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import {
getAdminAssetReadUrl,
listAdminEditorAssets,
} from '../api/adminApiClient';
import type {AdminAssetReadUrlResponse} from '../api/adminApiClient';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import type {
AdminEditorAssetListQuery,
AdminEditorAssetPayload,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminEditorAssetQueryPageProps {
token: string;
@@ -19,6 +19,7 @@ interface AdminEditorAssetQueryPageProps {
}
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
export function AdminEditorAssetQueryPage({
token,
@@ -274,10 +275,11 @@ export function AdminEditorAssetQueryPage({
);
}
function AdminAssetThumbnail({entry}: {entry: AdminEditorAssetPayload}) {
function AdminAssetThumbnail({ entry }: { entry: AdminEditorAssetPayload }) {
const isAudio = isAdminAudioAsset(entry);
const imageSrc = useAdminResolvedAssetImageSrc(
entry.thumbnailSrc || entry.imageSrc,
entry.objectKey,
isAudio ? AUDIO_ASSET_COVER_SRC : entry.thumbnailSrc || entry.imageSrc,
isAudio ? null : entry.objectKey,
);
const alt = `素材:${entry.label || entry.assetId}`;
@@ -288,6 +290,16 @@ function AdminAssetThumbnail({entry}: {entry: AdminEditorAssetPayload}) {
);
}
function isAdminAudioAsset(entry: AdminEditorAssetPayload) {
const assetKind = entry.assetKind?.trim() ?? '';
return (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio' ||
/\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim())
);
}
function AdminAssetDetailDialog({
entry,
onClose,
@@ -334,7 +346,9 @@ function AdminAssetDetailDialog({
{entry.generationCostMudPoints}
</AdminInfoItem>
<AdminInfoItem label="模型">{entry.model || '-'}</AdminInfoItem>
<AdminInfoItem label="Provider">{entry.provider || '-'}</AdminInfoItem>
<AdminInfoItem label="Provider">
{entry.provider || '-'}
</AdminInfoItem>
<AdminInfoItem label="Task ID">{entry.taskId || '-'}</AdminInfoItem>
<AdminInfoItem label="Object Key">
{entry.objectKey || '-'}
@@ -463,7 +477,8 @@ function mergeAssetEntries(
[...current, ...incoming].forEach((entry) => byId.set(entry.assetId, entry));
return [...byId.values()].sort(
(left, right) =>
parseAdminTimestamp(right.createdAt) - parseAdminTimestamp(left.createdAt) ||
parseAdminTimestamp(right.createdAt) -
parseAdminTimestamp(left.createdAt) ||
right.assetId.localeCompare(left.assetId),
);
}
@@ -505,13 +520,13 @@ function parseAdminTimestamp(value: string | null | undefined) {
function authorDisplayName(entry: AdminEditorAssetPayload) {
return (
entry.authorDisplayName?.trim() ||
entry.authorPublicUserCode?.trim() ||
'-'
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
function formatGenerationInputs(value: Record<string, unknown> | null | undefined) {
function formatGenerationInputs(
value: Record<string, unknown> | null | undefined,
) {
if (!value) {
return '-';
}
@@ -1,7 +1,13 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {beforeEach, expect, test, vi} from 'vitest';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminAssetReadUrl,
@@ -9,10 +15,11 @@ import {
listAdminEditorShowcaseAssets,
reviewAdminEditorShowcaseAsset,
updateAdminEditorShowcaseDisplay,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminEditorShowcaseCampaign,
} from '../api/adminApiClient';
import type {AdminEditorShowcaseAssetPayload} from '../api/adminApiTypes';
import {AdminEditorShowcaseReviewPage} from './AdminEditorShowcaseReviewPage';
import type { AdminEditorShowcaseAssetPayload } from '../api/adminApiTypes';
import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
@@ -20,6 +27,7 @@ vi.mock('../api/adminApiClient', () => ({
listAdminEditorShowcaseAssets: vi.fn(),
reviewAdminEditorShowcaseAsset: vi.fn(),
updateAdminEditorShowcaseDisplay: vi.fn(),
uploadAdminEditorShowcaseCampaignImage: vi.fn(),
upsertAdminEditorShowcaseCampaign: vi.fn(),
}));
@@ -40,7 +48,7 @@ const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
provider: 'character',
taskId: 'task-1',
assetKind: 'character',
generationInputs: {style: 'clay'},
generationInputs: { style: 'clay' },
generationCostMudPoints: 12,
refundMudPoints: 6,
reviewStatus: 'pending',
@@ -82,6 +90,9 @@ beforeEach(() => {
enabled: true,
title: '活动卡',
imageSrc: '/campaign.png',
imageObjectKey: null,
imageWidth: null,
imageHeight: null,
prompt: '活动提示词',
author: '官方',
costText: '12 泥点',
@@ -115,12 +126,24 @@ beforeEach(() => {
enabled: false,
title: '新活动卡',
imageSrc: '/new-campaign.png',
imageObjectKey: null,
imageWidth: null,
imageHeight: null,
prompt: '新活动提示词',
author: '官方',
costText: '6 泥点',
updatedAt: '2026-07-04T10:20:00Z',
},
});
vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValue({
imageSrc: '/generated-character-drafts/editor/showcase-campaign/card.png',
imageObjectKey:
'generated-character-drafts/editor/showcase-campaign/card.png',
imageWidth: 900,
imageHeight: 1200,
legacyPublicPath:
'/generated-character-drafts/editor/showcase-campaign/card.png',
});
});
test('后台精选审核展示待审核素材和活动卡配置', async () => {
@@ -169,6 +192,36 @@ test('后台精选审核格式化微秒时间并显示素材名', async () => {
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
});
test('后台精选审核音频素材使用统一封面缩略图', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
showcaseId: 'showcase-audio-1',
assetId: 'asset-audio-1',
label: '胜利音效',
imageSrc: '/generated-editor-audios/sfx.mp3',
objectKey: 'generated-editor-audios/sfx.mp3',
assetKind: 'sound-effect',
},
],
nextCursor: null,
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
const image = await screen.findByRole('img', { name: '精选素材:胜利音效' });
expect(image.getAttribute('src')).toBe(
'/creation-home/audio-asset-cover.png',
);
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
});
test('后台精选审核可以通过素材并查看完整提示词', async () => {
render(
<AdminEditorShowcaseReviewPage
@@ -177,11 +230,10 @@ test('后台精选审核可以通过素材并查看完整提示词', async () =>
/>,
);
fireEvent.change(
await screen.findByLabelText('审核备注:角色形象 1'),
{target: {value: '质量通过'}},
);
fireEvent.click(screen.getByRole('button', {name: '通过'}));
fireEvent.change(await screen.findByLabelText('审核备注:角色形象 1'), {
target: { value: '质量通过' },
});
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() => {
expect(reviewAdminEditorShowcaseAsset).toHaveBeenCalledWith('admin-token', {
@@ -191,8 +243,8 @@ test('后台精选审核可以通过素材并查看完整提示词', async () =>
});
});
fireEvent.click(screen.getByRole('button', {name: '完整提示词内容'}));
const dialog = screen.getByRole('dialog', {name: '完整提示词'});
fireEvent.click(screen.getByRole('button', { name: '完整提示词内容' }));
const dialog = screen.getByRole('dialog', { name: '完整提示词' });
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
});
@@ -209,7 +261,7 @@ test('后台精选审核可以切换展示和保存活动卡', async () => {
/>,
);
fireEvent.click(await screen.findByRole('button', {name: '隐藏'}));
fireEvent.click(await screen.findByRole('button', { name: '隐藏' }));
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
@@ -222,15 +274,15 @@ test('后台精选审核可以切换展示和保存活动卡', async () => {
});
fireEvent.change(await screen.findByLabelText('标题'), {
target: {value: '新活动卡'},
target: { value: '新活动卡' },
});
fireEvent.change(screen.getByLabelText('图片地址'), {
target: {value: '/new-campaign.png'},
target: { value: '/new-campaign.png' },
});
fireEvent.change(screen.getByLabelText('成本文案'), {
target: {value: '6 泥点'},
target: { value: '6 泥点' },
});
fireEvent.click(screen.getByRole('button', {name: '保存活动卡'}));
fireEvent.click(screen.getByRole('button', { name: '保存活动卡' }));
await waitFor(() => {
expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith(
@@ -238,12 +290,90 @@ test('后台精选审核可以切换展示和保存活动卡', async () => {
expect.objectContaining({
title: '新活动卡',
imageSrc: '/new-campaign.png',
imageObjectKey: null,
costText: '6 泥点',
}),
);
});
});
test('后台精选活动卡上传图片时替换图片引用并隐藏 objectKey', async () => {
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByDisplayValue('活动卡');
const file = new File(['image-bytes'], 'card.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('上传活动卡图片'), {
target: { files: [file] },
});
await waitFor(() => {
expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith(
'admin-token',
file,
);
});
expect(
await screen.findByDisplayValue(
'/generated-character-drafts/editor/showcase-campaign/card.png',
),
).toBeTruthy();
expect(
screen.queryByDisplayValue(
'generated-character-drafts/editor/showcase-campaign/card.png',
),
).toBeNull();
vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValueOnce({
imageSrc:
'/generated-character-drafts/editor/showcase-campaign/replacement.png',
imageObjectKey:
'generated-character-drafts/editor/showcase-campaign/replacement.png',
imageWidth: 1024,
imageHeight: 1536,
legacyPublicPath:
'/generated-character-drafts/editor/showcase-campaign/replacement.png',
});
const replacementFile = new File(['replacement-bytes'], 'replacement.png', {
type: 'image/png',
});
fireEvent.change(screen.getByLabelText('上传活动卡图片'), {
target: { files: [replacementFile] },
});
await waitFor(() => {
expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith(
'admin-token',
replacementFile,
);
});
expect(
await screen.findByDisplayValue(
'/generated-character-drafts/editor/showcase-campaign/replacement.png',
),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '保存活动卡' }));
await waitFor(() => {
expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/showcase-campaign/replacement.png',
imageObjectKey:
'generated-character-drafts/editor/showcase-campaign/replacement.png',
imageWidth: 1024,
imageHeight: 1536,
}),
);
});
});
test('后台精选审核已通过素材可以设置精选分类', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
@@ -271,7 +401,7 @@ test('后台精选审核已通过素材可以设置精选分类', async () => {
);
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
target: {value: 'ui'},
target: { value: 'ui' },
});
await waitFor(() => {
@@ -286,7 +416,7 @@ test('后台精选审核已通过素材可以设置精选分类', async () => {
});
});
test('后台精选审核清空精选分类时自动隐藏素材', async () => {
test('后台精选审核清空精选分类时不会自动隐藏素材', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [approvedShowcaseAsset],
nextCursor: null,
@@ -295,7 +425,7 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => {
entry: {
...approvedShowcaseAsset,
showcaseCategory: null,
displayEnabled: false,
displayEnabled: true,
},
});
@@ -307,7 +437,7 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => {
);
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
target: {value: ''},
target: { value: '' },
});
await waitFor(() => {
@@ -315,8 +445,8 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => {
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: null,
displayEnabled: true,
showcaseCategory: '',
},
);
});
@@ -1,6 +1,6 @@
import {Eye, FileText, RefreshCcw, X} from 'lucide-react';
import type {ReactNode} from 'react';
import {useEffect, useState} from 'react';
import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
getAdminAssetReadUrl,
@@ -8,15 +8,16 @@ import {
listAdminEditorShowcaseAssets,
reviewAdminEditorShowcaseAsset,
updateAdminEditorShowcaseDisplay,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminEditorShowcaseCampaign,
} from '../api/adminApiClient';
import type {AdminAssetReadUrlResponse} from '../api/adminApiClient';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import type {
AdminEditorShowcaseAssetPayload,
AdminEditorShowcaseCampaignPayload,
AdminEditorShowcaseListQuery,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminEditorShowcaseReviewPageProps {
token: string;
@@ -24,20 +25,20 @@ interface AdminEditorShowcaseReviewPageProps {
}
const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300;
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
const showcaseCategoryOptions = [
{value: 'packs', label: '素材包'},
{value: 'characters', label: '角色'},
{value: 'ui', label: 'UI'},
{value: 'music', label: '音乐'},
{value: 'marketing', label: '美宣'},
{ value: 'characters', label: '角色' },
{ value: 'ui', label: 'UI' },
{ value: 'music', label: '音乐' },
{ value: 'marketing', label: '美宣' },
];
const reviewStatusOptions = [
{value: '', label: '全部'},
{value: 'pending', label: '待审核'},
{value: 'approved', label: '已通过'},
{value: 'rejected', label: '已拒绝'},
{ value: '', label: '全部' },
{ value: 'pending', label: '待审核' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已拒绝' },
];
export function AdminEditorShowcaseReviewPage({
@@ -68,8 +69,14 @@ export function AdminEditorShowcaseReviewPage({
prompt: '',
author: '',
costText: '',
imageObjectKey: null,
imageWidth: null,
imageHeight: null,
});
const [isSavingCampaign, setIsSavingCampaign] = useState(false);
const [isUploadingCampaignImage, setIsUploadingCampaignImage] =
useState(false);
const campaignImageInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
void refreshPage();
@@ -175,11 +182,11 @@ export function AdminEditorShowcaseReviewPage({
showcaseCategory: string,
) {
setErrorMessage('');
const nextCategory = showcaseCategory.trim() || null;
const nextCategory = showcaseCategory.trim();
try {
const response = await updateAdminEditorShowcaseDisplay(token, {
showcaseId: entry.showcaseId,
displayEnabled: nextCategory ? entry.displayEnabled : false,
displayEnabled: entry.displayEnabled,
showcaseCategory: nextCategory,
});
replaceEntry(response.entry);
@@ -196,6 +203,9 @@ export function AdminEditorShowcaseReviewPage({
enabled: campaignDraft.enabled,
title: campaignDraft.title,
imageSrc: campaignDraft.imageSrc,
imageObjectKey: campaignDraft.imageObjectKey ?? null,
imageWidth: campaignDraft.imageWidth ?? null,
imageHeight: campaignDraft.imageHeight ?? null,
prompt: campaignDraft.prompt,
author: campaignDraft.author,
costText: campaignDraft.costText,
@@ -210,6 +220,31 @@ export function AdminEditorShowcaseReviewPage({
}
}
async function handleCampaignImageFile(file: File | null | undefined) {
if (!file) {
return;
}
setIsUploadingCampaignImage(true);
setErrorMessage('');
try {
const upload = await uploadAdminEditorShowcaseCampaignImage(token, file);
setCampaignDraft((current) => ({
...current,
imageSrc: upload.imageSrc,
imageObjectKey: upload.imageObjectKey,
imageWidth: upload.imageWidth,
imageHeight: upload.imageHeight,
}));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsUploadingCampaignImage(false);
if (campaignImageInputRef.current) {
campaignImageInputRef.current.value = '';
}
}
}
function replaceEntry(entry: AdminEditorShowcaseAssetPayload) {
setEntries((current) =>
current.map((item) =>
@@ -314,9 +349,7 @@ export function AdminEditorShowcaseReviewPage({
</button>
<small>{entry.label || '-'}</small>
</td>
<td>
{formatDateTime(entry.submittedAt)}
</td>
<td>{formatDateTime(entry.submittedAt)}</td>
<td>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
@@ -342,16 +375,14 @@ export function AdminEditorShowcaseReviewPage({
)}
</td>
<td>
<span className={reviewStatusClassName(entry.reviewStatus)}>
<span
className={reviewStatusClassName(entry.reviewStatus)}
>
{reviewStatusLabel(entry.reviewStatus)}
</span>
{entry.reviewStatus === 'approved' ? (
<small>
{entry.displayEnabled
? '展示中'
: entry.showcaseCategory
? '已隐藏'
: '待设置分类'}
{entry.displayEnabled ? '展示中' : '未展示'}
</small>
) : null}
</td>
@@ -481,15 +512,39 @@ export function AdminEditorShowcaseReviewPage({
</label>
<label className="admin-field">
<span></span>
<input
value={campaignDraft.imageSrc}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
imageSrc: event.target.value,
}))
}
/>
<div className="admin-showcase-campaign-image-row">
<input
value={campaignDraft.imageSrc}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
imageSrc: event.target.value,
imageObjectKey: null,
imageWidth: null,
imageHeight: null,
}))
}
/>
<input
ref={campaignImageInputRef}
aria-label="上传活动卡图片"
className="admin-hidden-file-input"
type="file"
accept="image/*"
onChange={(event) =>
handleCampaignImageFile(event.currentTarget.files?.[0])
}
/>
<button
className="admin-secondary-button"
disabled={isUploadingCampaignImage}
type="button"
onClick={() => campaignImageInputRef.current?.click()}
>
<Upload size={16} aria-hidden="true" />
<span>{isUploadingCampaignImage ? '上传中' : '上传'}</span>
</button>
</div>
</label>
<label className="admin-field">
<span></span>
@@ -589,7 +644,11 @@ function AdminShowcaseThumbnail({
}: {
entry: AdminEditorShowcaseAssetPayload;
}) {
const imageSrc = useAdminResolvedAssetImageSrc(entry.imageSrc, entry.objectKey);
const isAudio = isAdminShowcaseAudioAsset(entry);
const imageSrc = useAdminResolvedAssetImageSrc(
isAudio ? AUDIO_ASSET_COVER_SRC : entry.imageSrc,
isAudio ? null : entry.objectKey,
);
const alt = `精选素材:${entry.label || entry.showcaseId}`;
return imageSrc ? (
@@ -599,6 +658,16 @@ function AdminShowcaseThumbnail({
);
}
function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) {
const assetKind = entry.assetKind?.trim() ?? '';
return (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio' ||
/\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim())
);
}
function AdminShowcaseDetailDialog({
entry,
onClose,
@@ -659,7 +728,9 @@ function AdminShowcaseDetailDialog({
</AdminInfoItem>
<AdminInfoItem label="点赞数">{entry.likeCount}</AdminInfoItem>
<AdminInfoItem label="模型">{entry.model || '-'}</AdminInfoItem>
<AdminInfoItem label="Provider">{entry.provider || '-'}</AdminInfoItem>
<AdminInfoItem label="Provider">
{entry.provider || '-'}
</AdminInfoItem>
<AdminInfoItem label="Task ID">{entry.taskId || '-'}</AdminInfoItem>
<AdminInfoItem label="Object Key">
{entry.objectKey || '-'}
@@ -869,13 +940,13 @@ function reviewStatusClassName(value: string) {
function authorDisplayName(entry: AdminEditorShowcaseAssetPayload) {
return (
entry.authorDisplayName?.trim() ||
entry.authorPublicUserCode?.trim() ||
'-'
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
function formatGenerationInputs(value: Record<string, unknown> | null | undefined) {
function formatGenerationInputs(
value: Record<string, unknown> | null | undefined,
) {
if (!value) {
return '-';
}
+14
View File
@@ -1091,6 +1091,20 @@ button:disabled {
min-width: 160px;
}
.admin-hidden-file-input {
display: none;
}
.admin-showcase-campaign-image-row {
display: flex;
align-items: center;
gap: 8px;
}
.admin-showcase-campaign-image-row input {
min-width: 0;
}
.admin-showcase-campaign-prompt {
min-width: min(100%, 320px);
}
+3
View File
@@ -49,6 +49,7 @@ AUTH_REFRESH_COOKIE_PATH=/api/auth
AUTH_REFRESH_COOKIE_SAME_SITE=Lax
AUTH_REFRESH_COOKIE_SECURE=true
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false
GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101
GENARRATIVE_SPACETIME_DATABASE=genarrative-prod
@@ -138,6 +139,8 @@ GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false
# 可选:显式要求备份工作目录所在文件系统至少保留的可用空间;为空时按数据目录大小 + 安全余量估算。
GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES=
# 可选:定时 / publish 前备份使用独立最小权限 AccessKey;为空时回退 ALIYUN_OSS_ACCESS_KEY_*。
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
@@ -44,6 +44,39 @@
- 影响范围:AI 游戏创作 App 的 Tauri Rust 配置加载、主窗口配置面板、CLI wrapper、agent-run smoke、`check-config` 门禁、`.gitignore` 和实施计划文档。
- 验证方式:运行 `npm run ai-game-creator-shell:typecheck``cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-07-02 图片画布生成抠图背景色使用 screenColor 传递
- 背景:画布角色、图标和 UI 素材生成过去固定要求 `#00FF00` 绿幕,后续 BGfilter 服务需要按生成时背景色做去背景,不能继续把背景色写死在 prompt 或后处理里。
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取不再向用户提供手动抠图背景色选择;前端用户路径统一提交 `screenColor=auto`,但用户可见生成输入快照不再写入 `抠图背景色``抠图模型`。api-server 在 11 个候选色中自动决策具体 hex,失败后兜底 `#CFEFFF`;最终 prompt 和 BgFilter 去背景只接收解析后的具体 hex 作为 `screen_color`。后端仍保留手动 hex 解析能力供内部兼容,角色动作抽帧暂不接入该选择,继续使用 legacy `#00FF00` 绿幕。
- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、BgFilter 服务入参、图片画布 MVP 和角色形象生成设计文档。
- 验证方式:运行画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_green_screen --manifest-path server-rs/Cargo.toml``cargo test -p platform-image generated_asset_sheet_light_blue_key_color_removes_selected_background --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## 2026-07-05 图片画布抠图背景色自动决策
- 背景:手动背景色选择对用户负担较高,且不同角色、图标和 UI 素材主题需要避开不同主体色;但 BgFilter 和生成 prompt 仍必须拿到明确的纯色 hex。
- 决策:前端用户路径直接固定通过 `screenColor=auto` 提交,不再展示背景色选项;api-server 新增 `editor_screen_background_decision` 模块,在角色形象、图标 spritesheet 和 UI 设计图素材提取组装 prompt 前解析 `screenColor`。手动 hex 直接校验并使用;`auto` 通过服务端 LLM 在 11 个候选色中选择具体 hex,最多重试 3 次,LLM 未配置、请求失败或返回非法颜色时 fallback 到 `浅雾蓝 #CFEFFF`。自动解析结果不写入用户可见生成输入快照;最终生图 prompt 和 BgFilter `screen_color` 永远只接收具体 hex,不透传 `auto`
- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、api-server LLM 调用、BgFilter 参数、图片画布文档。
- 验证方式:运行背景决策模块单测、画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_screen_background_decision editor_green_screen --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`
## 2026-07-05 BgFilter 失败时本地纯色去背兜底
- 背景:曾用一次性 BgFilter live probe 稳定复现 BgFilter 对 2K 输入返回 `HTTP 500 {"detail":"inference failed"}`,浏览器生成链路会因此收到“BgFilter 服务返回非成功状态”。
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取仍优先调用独立 BgFilter;若 BgFilter 请求失败、返回非成功状态、返回空图片或非法图片,api-server 记录 warning 后使用本地 `editor_green_screen` 按解析后的纯色背景执行确定性去背兜底,不中断生成。手动任意图片去背景仍只走独立 BiRefNet BFF,不使用该兜底。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、BgFilter 运维排障、图片画布生成后处理。
- 验证方式:运行 `cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess editor_green_screen --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`
## 2026-07-03 图片画布生成纯色背景资产接入 BgFilter
- 背景:独立 BgFilter 服务已部署在 image host,并提供 `POST /bgfilter/remove-background`,支持显式 `screen_color``seg_model`。手动去背景已有独立 BiRefNet BFF,不能把两个服务的配置或语义混在一起。
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file``screen_color=<screenColor>``seg_model=<segModel>`,前端用户路径固定提交 `segModel=birefnet` 且不展示抠图模型选择;`anime-seg` 路径保留为后端可识别的内部能力但不对用户可见。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底。角色动作抽帧仍保留 legacy `#00FF00` 与本地 `editor_green_screen` 透明化。
- 影响范围:`server-rs/crates/api-server/src/config.rs``server-rs/crates/api-server/src/editor_project.rs`、图片画布 MVP 文档和角色形象生成设计文档。
- 验证方式:运行 `cargo test -p api-server config::tests::from_env_reads_editor_bgfilter_settings_and_reuses_background_token editor_project::tests::editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## 2026-07-03 作品公开默认关闭
- 背景:作品发布完成不应默认进入公开广场 / 公开详情 / 公开互动消费路径,需要先由后台可见性开关明确开启。
@@ -55,7 +88,7 @@
## 2026-07-04 陶泥儿精选改为素材提交审核后公开
- 背景:`/creation``陶泥儿精选` 过去依赖 `editor_project_resource.public_showcase_enabled`,生成画布资源默认可公开,和“作品公开默认关闭、由用户主动投稿精选”的运营要求冲突,也无法在后台审核、返还泥点和配置固定活动卡。
- 决策:`陶泥儿精选` 的公开事实改为独立 `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 接口只保留兼容,不再驱动精选公开。
- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false``showcase_category=null`,运营按前台具体 Tab 手动设置分类并开启展示;未设置分类的素材展示开启后进入前台“全部”,但不进入角色 / UI / 音乐 / 美宣具体分类。审核通过时按 `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`
@@ -267,7 +300,7 @@
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。`,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材;图标素材生成只保留扣绿后的 spritesheet 图集,不再额外铺独立图标。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2`纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材;图标素材生成只保留透明 spritesheet 图集,不再额外铺独立图标。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。
- 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -60,7 +60,7 @@ npm run check:server-rs-ddd
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
- 资产基础能力:`/api/assets/direct-upload-tickets``/api/assets/sts-upload-credentials``/api/assets/objects/*``/api/assets/read-*`,负责直传、确认、绑定和读取。
- 外部 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 正常可用。
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/frontend-config``/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/frontend-config``api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/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``keyword``createdAfter``createdBefore``cursor``limit`;返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。
- 自定义世界 / RPG`/api/runtime/custom-world*``/api/story/*``/api/runtime/chat/*`
@@ -219,6 +219,7 @@ npm run check:server-rs-ddd
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*``platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses``APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models``/v1/chat/completions``/v1/responses` 可用性。
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible``GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1``GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY``APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models``/v1/chat/completions``/v1/responses` 可用性。
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event``event_key = external_generation_run`metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations``/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required``request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt``max_attempts``retry_delay_ms``reference_image_bytes_total``request_params`,不要把 `SendRequest` 当成上游业务错误。
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>``seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。角色动作抽帧仍沿用 legacy `#00FF00` 和本地 `editor_green_screen` 透明化。
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
@@ -503,7 +504,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``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`,已拒绝记录删除,已通过记录保留快照继续展示。
- 说明:`陶泥儿精选` 的独立审核与公开快照表。用户从账号级生成素材提交后,后端把素材媒体、提示词、生成输入、素材类型和 `generation_cost_mud_points` 快照到该表,初始 `review_status = pending``display_enabled = false``showcase_category = null`。后台审核通过后写入 `approved`,但仍保持不展示;运营在后台按前台具体 Tab 手动设置 `showcase_category``characters``ui``music``marketing`)并开启展示。未设置分类的素材不归入具体 Tab,但展示开启后仍进入前台“全部”。审核通过时生成确定性返还流水 `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`
@@ -517,7 +518,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`EditorShowcaseCampaignConfig`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = default`。后台可配置启用状态、标题、图片 URL、提示词、作者和成本文案;固定活动卡不包含副标题或 Object Key,公开精选接口只在启用时返回该配置
- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = global`。后台可配置启用状态、标题、图片地址、提示词、作者和成本文案;上传按钮通过后台受控上传票据把图片写入 OSS,保存时同时落 `image_src`、内部图片 OSS `image_object_key``image_width``image_height`。公开精选接口只在启用时返回该配置,前端优先用 `image_object_key` 走签名读地址展示,并按记录的图片宽高决定活动卡比例
- 索引:主键 `config_id`
### `inventory_slot`
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。
- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`
- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。
- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材,不使用账号级非项目素材、公开作品补充或 mock 素材。
- 现有 `/creation/<play>` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。
## 页面结构
@@ -73,13 +73,13 @@
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
精选内容只使用用户从账号级素材库主动提交、后台审核通过、手动设置精选分类且展示状态开启的 `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 显示保守占位,不伪造内容。
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `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=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。
Tab
- 素材包
- 全部
- 角色
- UI
- 音乐
@@ -90,7 +90,7 @@ Tab
展示口径:
- 素材包:每个列表项展示同一规范图生成的规范图和相关素材组合
- 全部:展示所有已公开精选素材,包含未设置分类的素材
- 角色:每个列表项展示游戏角色图和该角色生成的角色动画素材组合。
- UI:每个列表项展示游戏 UI 图和该 UI 图拆出的子素材组合。
- 音乐:展示背景音乐和音效。
@@ -112,11 +112,11 @@ Tab
数据来源:
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId``assetId`、审核状态、展示状态、`showcaseCategory`、点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name``authorPublicUserCode` / 陶泥号用于展示;前端`showcaseCategory` 放入素材包、角色、UI、音乐或美宣 Tab,只展示后端已经筛过的公开结果。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId``assetId`、审核状态、展示状态、`showcaseCategory`、点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name``authorPublicUserCode` / 陶泥号用于展示;前端默认进入“全部”,展示所有后端已经筛过的公开结果;角色、UI、音乐或美宣 Tab 只展示对应 `showcaseCategory` 的素材。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`
- 全部、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。
- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。
- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。素材不可提交或后端拒绝提交时,菜单必须显示具体原因,例如非生成素材、素材尚未保存、没有媒体内容或审核未通过不能重复提交。
- 后台新增纯素材查询页,读取账号级 `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` `showcase_category` 为准,前端只做展示组合、Tab 归类、点赞交互和提交审核的乐观状态回滚。
- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status``display_enabled` 为准,`showcase_category` 只用于前端具体 Tab 归类,前端只做展示组合、Tab 归类、点赞交互和提交审核的乐观状态回滚。
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
## 路由与导航
@@ -185,7 +185,7 @@
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景素材自身不要出现绿色描边、绿色底板或绿色阴影。` 生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化,并复用图标素材拆分流程,把透明 spritesheet 图集和拆分素材都放到画布。
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
@@ -28,6 +28,7 @@
- 支持自定义画面比例和大小尺寸。
- 模型固定为 `gpt-image-2`,模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`
- 默认画面比例为 `16:9`,默认大小为 `1K`
- UI 素材提取面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto``segModel=birefnet`。后端先在 11 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
## 提示词契约
@@ -57,10 +58,10 @@
- 后端固定使用 `gpt-image-2` 图片编辑链路,并固定提示词:
```text
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
```
- 后端收到 spritesheet 后先把带绿幕源图写入 OSS,再`server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1``素材 2`
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1``素材 2`
- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。
## 验收点
@@ -70,5 +71,5 @@
- 从画布选择时只能绑定图标规范图片。
- 请求参数包含 `kind: "ui-design"``model: "gpt-image-2"`、比例、大小与可选参考图。
- 上传普通参考图后,请求参考图数组同时包含图标规范和普通参考图,生成图层信息面板展示 `用户输入``图标规范` 与普通参考图。
- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,画布同时出现 spritesheet 图集和拆分后的独立素材。
- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,请求包含 `screenColor`画布同时出现透明 spritesheet 图集和拆分后的独立素材。
- UI 素材提取面板上传普通参考图后,提取请求参考图数组同时包含红框 UI 设计图和普通参考图,生成图层信息面板展示 `UI设计图` 与普通参考图。
@@ -47,17 +47,18 @@
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig``0.5K` 按 VectorEngine 文档传 `"512"`
- `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024``1K 2:3/9:16 -> 1024x1536``1K 3:2/16:9 -> 1536x1024``2K 1:1 -> 2048x2048``2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
- 不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto``segModel=birefnet`。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`,最终 prompt 和 BgFilter 不透传 `auto`
- Prompt 固定为:
```text
参考图1的图标规范,背景必须是单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
<素材描述按中文顿号拼接>
```
## 去背与保存
- 后端收到 spritesheet 后先把带绿幕源图写入 OSS,再`server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法,底层复用 `platform-image::generated_asset_sheets`
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor``segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见
- 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。
- 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。
@@ -29,7 +29,7 @@
- 上传后的每张常规参考图以缩略图展示。
- 每张常规参考图右下角显示大号序号,从 `1` 开始递增。
3. 唯一文本框为 `角色设定`
4. 左下角展示画面比例和大小选择按钮
4. 左下角展示画面比例和大小,不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto``segModel=birefnet`
5. 右下角展示模型选择和生成按钮。
## 普通生成面板视觉口径
@@ -49,9 +49,10 @@
- 角色规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为:
1. 角色规范图。
2. 常规参考图列表。
- 请求同时提交 `model``aspectRatio``imageSize`
- 请求同时提交 `model``screenColor``segModel``aspectRatio``imageSize`
- `model` 支持 `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
- 前端用户路径固定提交 `screenColor=auto``segModel=birefnet`,不从生成器快照或输入快照恢复旧手动背景色 / 抠图模型。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。
- 尺寸选项来源以 VectorEngine 接入文档为准:
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`
@@ -60,11 +61,11 @@
- `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架:
```text
基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光;禁止生成美术视觉规范出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯色背景 <颜色名> <hex> / RGB(<r>,<g>,<b>),只作为抠像底色;纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带与背景色相同或相近的描边、投影或反光;禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
角色设定:<用户输入的角色设定>
```
- 角色图生成完成后,编辑器后端必须先把带绿幕源图写入 OSS,再`server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法:复用 `platform-image::generated_asset_sheets` 的绿幕 / 近白背景去背能力,并开启内部绿幕 / 近白镂空检测。角色图 prompt 固定要求标准 `#00FF00 / RGB(0,255,0)` 绿幕;后端路径仍兼容生成模型把标准绿幕压成暗绿 / 灰绿背景的情况,但这类宽松识别只用于从画布边缘连通扩散出的背景,不作为全图断开绿色区域删除依据。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用远端 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。
- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file``screen_color=<screenColor>``seg_model=<segModel>`,用户路径默认并只提交 `seg_model=birefnet`。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这字段。
-`assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`
## 生成规范参考图
@@ -102,7 +103,7 @@
- `角色规范``上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。
- `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。
- 上传常规参考图后缩略图右下角显示序号。
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model``aspectRatio``imageSize`
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model``screenColor``aspectRatio``imageSize`
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
@@ -113,7 +114,7 @@
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`
- 角色图层重绘同样走 `/api/editor/images/generations``kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`
- 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent``imageConfig.aspectRatio/imageSize``gpt-image-2` 使用文档列出的 `size` 字符串。
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后通过编辑器通用抠图方法执行绿幕 / 近白背景去背和内部镂空清理、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
- 本次验证命令:
@@ -161,7 +162,7 @@
- 视频生成完成后,后端按面板选择抽取对应帧数:`32``40``48`
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
- 每帧必须先把带绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。
- 每帧必须先把带 legacy `#00FF00` 绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。角色动作暂不接入角色 / 图标生图的 `screenColor` 选择。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"``assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览。
+1 -1
View File
@@ -25,7 +25,7 @@ pipeline {
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: false, description: '是否额外构建并归档 Pingora 影子网关二进制')
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: '是否构建并归档 Pingora 影子网关二进制release 发布默认必须包含')
booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 API 发布')
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Api-Deploy', description: 'API 发布流水线作业名')
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
+6 -1
View File
@@ -16,7 +16,7 @@ pipeline {
string(name: 'BUILD_VERSION', defaultValue: '', description: '待发布版本号')
string(name: 'BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
string(name: 'BUILD_NUMBER_TO_DEPLOY', defaultValue: '', description: '要复制归档产物的上游构建号')
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: false, description: '上游构建是否包含 Pingora 影子网关产物')
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: '上游构建是否包含 Pingora 影子网关产物release 发布默认必须包含')
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
string(name: 'SERVICE_NAME', defaultValue: 'genarrative-api.service', description: 'systemd 服务名')
@@ -93,12 +93,17 @@ pipeline {
bash -lc '
set -euo pipefail
chmod +x "build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-on.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-off.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs" "build/${BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs" "build/${BUILD_VERSION}/scripts/deploy/pingora-tls-cert-sync.mjs"
pingora_deploy_args=()
if [[ "${INCLUDE_PINGORA_GATEWAY:-false}" == "true" ]]; then
pingora_deploy_args+=(--require-pingora-gateway)
fi
"build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" \
--source-dir "build/${BUILD_VERSION}" \
--version "${BUILD_VERSION}" \
--release-root "${RELEASE_ROOT}" \
--current-link "${CURRENT_LINK}" \
--service "${SERVICE_NAME}" \
"${pingora_deploy_args[@]}" \
--health-url "${HEALTH_URL}" \
--api-env-file "${API_ENV_FILE:-/etc/genarrative/api-server.env}" \
--database "${DATABASE}" \
@@ -25,6 +25,7 @@ pipeline {
booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci')
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,透传给 Stdb module 构建的迁移 bootstrap secret 凭据 ID;留空则由 Stdb 构建自动生成')
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'API release 是否构建、归档并部署 Pingora 影子网关;release 默认必须包含')
string(name: 'WEB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Web-Build', description: 'Web 构建流水线作业名')
string(name: 'API_BUILD_JOB_NAME', defaultValue: 'Genarrative-Api-Build', description: 'API 构建流水线作业名')
string(name: 'STDB_BUILD_JOB_NAME', defaultValue: 'Genarrative-Stdb-Module-Build', description: 'Stdb 构建流水线作业名')
@@ -134,6 +135,7 @@ pipeline {
string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT),
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION),
string(name: 'NOTIFICATION_EMAILS', value: params.NOTIFICATION_EMAILS ?: ''),
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY),
]
env.API_BUILD_NUMBER = apiRun.number.toString()
}
@@ -196,6 +198,7 @@ pipeline {
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: params.CONFIRM_RELEASE_DEPLOY_AGENT),
string(name: 'BUILD_JOB_NAME', value: params.API_BUILD_JOB_NAME),
string(name: 'BUILD_NUMBER_TO_DEPLOY', value: env.API_BUILD_NUMBER),
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY),
string(name: 'DATABASE', value: params.DATABASE),
string(name: 'SPACETIME_SERVER_URL', value: params.SPACETIME_SERVER_URL ?: ''),
]
@@ -234,6 +237,7 @@ pipeline {
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')),
string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''),
string(name: 'DATABASE', value: params.DATABASE ?: ''),
string(name: 'INCLUDE_PINGORA_GATEWAY', value: String.valueOf(params.INCLUDE_PINGORA_GATEWAY)),
string(name: 'SUMMARY', value: '全量构建发布编排结束'),
]
def notificationRecipients = params.NOTIFICATION_EMAILS?.trim()

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