合并编辑器素材库分支
合并 codex/editor-asset-library 到 AI 游戏创作 App 分支。 保留 AI 游戏创作客户端配置决策,并合入素材库分支新增文档。 排除素材库分支误带的 .env.local 本机密钥改动。
This commit is contained in:
@@ -15,6 +15,7 @@ import type {
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminTrackingEventListQuery,
|
||||
AdminTrackingEventKeyListResponse,
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
@@ -192,6 +193,13 @@ export function listAdminTrackingEvents(
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminTrackingEventKeys(token: string) {
|
||||
return request<AdminTrackingEventKeyListResponse>(
|
||||
'/admin/api/tracking/event-keys',
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminCreationEntryConfig(token: string) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config',
|
||||
@@ -430,9 +438,14 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
appendQueryParam(params, 'userId', query.userId);
|
||||
appendQueryParam(params, 'scopeKind', query.scopeKind);
|
||||
appendQueryParam(params, 'scopeId', query.scopeId);
|
||||
appendQueryParam(params, 'startDate', query.startDate);
|
||||
appendQueryParam(params, 'endDate', query.endDate);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
if (query.exportAll) {
|
||||
params.set('exportAll', 'true');
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
@@ -206,7 +206,10 @@ export interface AdminTrackingEventListQuery {
|
||||
userId?: string;
|
||||
scopeKind?: TrackingScopeKind | '';
|
||||
scopeId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
exportAll?: boolean;
|
||||
}
|
||||
|
||||
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
||||
@@ -503,3 +506,13 @@ export interface AdminTrackingEventEntryPayload {
|
||||
export interface AdminTrackingEventListResponse {
|
||||
entries: AdminTrackingEventEntryPayload[];
|
||||
}
|
||||
|
||||
export interface AdminTrackingEventKeyPayload {
|
||||
eventKey: string;
|
||||
eventTitle: string;
|
||||
scopeKinds: string[];
|
||||
}
|
||||
|
||||
export interface AdminTrackingEventKeyListResponse {
|
||||
eventKeys: AdminTrackingEventKeyPayload[];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
adminProfileTaskTrackingEventDefinitions,
|
||||
adminTrackingEventDefinitions,
|
||||
buildAdminTrackingEventKeyOptions,
|
||||
filterAdminProfileTaskTrackingEventDefinitions,
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
filterAdminTrackingEventDefinitions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from './trackingEventDefinitions';
|
||||
|
||||
describe('admin tracking event definitions', () => {
|
||||
test('后台埋点筛选候选包含后端通用埋点清单', () => {
|
||||
const keys = adminTrackingEventDefinitions.map((definition) => definition.key);
|
||||
const keys = adminTrackingEventDefinitions.map(
|
||||
(definition) => definition.key,
|
||||
);
|
||||
|
||||
expect(keys.length).toBeGreaterThan(40);
|
||||
expect(keys).toContain('daily_login');
|
||||
@@ -22,21 +26,48 @@ describe('admin tracking event definitions', () => {
|
||||
});
|
||||
|
||||
test('任务配置候选只开放适合个人任务的事件', () => {
|
||||
expect(adminProfileTaskTrackingEventDefinitions.map(({key}) => key)).toEqual([
|
||||
'daily_login',
|
||||
]);
|
||||
expect(filterAdminProfileTaskTrackingEventDefinitions('').map(({key}) => key)).toEqual([
|
||||
'daily_login',
|
||||
expect(
|
||||
adminProfileTaskTrackingEventDefinitions.map(({ key }) => key),
|
||||
).toEqual(['daily_login']);
|
||||
expect(
|
||||
filterAdminProfileTaskTrackingEventDefinitions('').map(({ key }) => key),
|
||||
).toEqual(['daily_login']);
|
||||
});
|
||||
|
||||
test('后台埋点 key 候选从真实库响应合并静态标题', () => {
|
||||
const options = buildAdminTrackingEventKeyOptions([
|
||||
{
|
||||
eventKey: 'work_play_start',
|
||||
eventTitle: 'work_play_start',
|
||||
scopeKinds: ['work', 'user'],
|
||||
},
|
||||
{
|
||||
eventKey: 'unknown_event',
|
||||
eventTitle: '未知事件',
|
||||
scopeKinds: ['user'],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(options.find(({ key }) => key === 'work_play_start')?.title).toBe(
|
||||
'作品开始游玩',
|
||||
);
|
||||
expect(options.find(({ key }) => key === 'unknown_event')?.title).toBe(
|
||||
'未知事件',
|
||||
);
|
||||
expect(
|
||||
filterAdminTrackingEventKeyOptions(options, '作品').map(({ key }) => key),
|
||||
).toEqual(['work_play_start']);
|
||||
});
|
||||
|
||||
test('后台埋点筛选支持按中文名称和 key 搜索', () => {
|
||||
expect(filterAdminTrackingEventDefinitions('上传票据').map(({key}) => key)).toEqual([
|
||||
'asset_upload_ticket_create',
|
||||
]);
|
||||
expect(filterAdminTrackingEventDefinitions('work_play').map(({key}) => key)).toEqual([
|
||||
'work_play_start',
|
||||
]);
|
||||
expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe('每日登录');
|
||||
expect(
|
||||
filterAdminTrackingEventDefinitions('上传票据').map(({ key }) => key),
|
||||
).toEqual(['asset_upload_ticket_create']);
|
||||
expect(
|
||||
filterAdminTrackingEventDefinitions('work_play').map(({ key }) => key),
|
||||
).toEqual(['work_play_start']);
|
||||
expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe(
|
||||
'每日登录',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type {TrackingScopeKind} from '../api/adminApiTypes';
|
||||
import type {
|
||||
AdminTrackingEventKeyPayload,
|
||||
TrackingScopeKind,
|
||||
} from '../api/adminApiTypes';
|
||||
|
||||
export interface AdminTrackingEventDefinition {
|
||||
key: string;
|
||||
@@ -8,6 +11,14 @@ export interface AdminTrackingEventDefinition {
|
||||
taskConfigEligible?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminTrackingEventKeyOption {
|
||||
key: string;
|
||||
title: string;
|
||||
scopeKind: TrackingScopeKind | string;
|
||||
scopeKinds: string[];
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
|
||||
{
|
||||
key: 'auth_login_options_view',
|
||||
@@ -25,7 +36,8 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
|
||||
key: 'daily_login',
|
||||
title: '每日登录',
|
||||
scopeKind: 'user',
|
||||
remark: '认证成功或 refresh 续期后由后端幂等记录,用于每日登录任务进度校验。',
|
||||
remark:
|
||||
'认证成功或 refresh 续期后由后端幂等记录,用于每日登录任务进度校验。',
|
||||
taskConfigEligible: true,
|
||||
},
|
||||
{
|
||||
@@ -368,7 +380,8 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
|
||||
key: 'match3d_route_success',
|
||||
title: '抓大鹅路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
remark:
|
||||
'抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'square_hole_route_success',
|
||||
@@ -392,12 +405,15 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
|
||||
key: 'work_play_start',
|
||||
title: '作品开始游玩',
|
||||
scopeKind: 'work',
|
||||
remark: '拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。',
|
||||
remark:
|
||||
'拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。',
|
||||
},
|
||||
];
|
||||
|
||||
export const adminProfileTaskTrackingEventDefinitions =
|
||||
adminTrackingEventDefinitions.filter((definition) => definition.taskConfigEligible);
|
||||
adminTrackingEventDefinitions.filter(
|
||||
(definition) => definition.taskConfigEligible,
|
||||
);
|
||||
|
||||
export function findAdminTrackingEventDefinition(eventKey: string) {
|
||||
const normalizedEventKey = eventKey.trim();
|
||||
@@ -412,6 +428,41 @@ export function filterAdminTrackingEventDefinitions(query: string) {
|
||||
return filterTrackingEventDefinitions(adminTrackingEventDefinitions, query);
|
||||
}
|
||||
|
||||
export function buildAdminTrackingEventKeyOptions(
|
||||
eventKeys: AdminTrackingEventKeyPayload[],
|
||||
): AdminTrackingEventKeyOption[] {
|
||||
const options = new Map<string, AdminTrackingEventKeyOption>();
|
||||
for (const payload of eventKeys) {
|
||||
const key = payload.eventKey.trim();
|
||||
if (!key || options.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const definition = findAdminTrackingEventDefinition(key);
|
||||
const scopeKinds = payload.scopeKinds.length
|
||||
? payload.scopeKinds
|
||||
: definition
|
||||
? [definition.scopeKind]
|
||||
: [];
|
||||
options.set(key, {
|
||||
key,
|
||||
title: definition?.title ?? (payload.eventTitle || key),
|
||||
scopeKind: definition?.scopeKind ?? scopeKinds[0] ?? '',
|
||||
scopeKinds,
|
||||
remark: definition?.remark ?? '真实埋点库中已出现的 event key。',
|
||||
});
|
||||
}
|
||||
return [...options.values()].sort((left, right) =>
|
||||
left.key.localeCompare(right.key),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterAdminTrackingEventKeyOptions(
|
||||
options: AdminTrackingEventKeyOption[],
|
||||
query: string,
|
||||
) {
|
||||
return filterTrackingEventDefinitions(options, query);
|
||||
}
|
||||
|
||||
export function filterAdminProfileTaskTrackingEventDefinitions(query: string) {
|
||||
return filterTrackingEventDefinitions(
|
||||
adminProfileTaskTrackingEventDefinitions,
|
||||
@@ -420,7 +471,9 @@ export function filterAdminProfileTaskTrackingEventDefinitions(query: string) {
|
||||
}
|
||||
|
||||
function filterTrackingEventDefinitions(
|
||||
definitions: AdminTrackingEventDefinition[],
|
||||
definitions: Array<
|
||||
AdminTrackingEventDefinition | AdminTrackingEventKeyOption
|
||||
>,
|
||||
query: string,
|
||||
) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { getAdminDashboard } from '../api/adminApiClient';
|
||||
import type { AdminDashboardResponse } from '../api/adminApiTypes';
|
||||
@@ -64,18 +64,27 @@ const dashboardResponse: AdminDashboardResponse = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date('2026-06-27T08:00:00+08:00'));
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue(dashboardResponse);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('Dashboard 默认加载今日指标并支持运营汇总页签', async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setupUser();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('本日总生产素材数')).toBeTruthy();
|
||||
expect(await screen.findByText('总计数据')).toBeTruthy();
|
||||
expect(screen.getByText('时段数据')).toBeTruthy();
|
||||
expect(screen.getByText('本日生产素材数')).toBeTruthy();
|
||||
expect(screen.getByText('总注册用户')).toBeTruthy();
|
||||
expect(screen.getByText('本日新增用户数')).toBeTruthy();
|
||||
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
|
||||
expect(screen.getByText('生产素材')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '本时段' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '运营汇总' }));
|
||||
|
||||
@@ -84,11 +93,26 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
|
||||
expect(screen.getByText('访问模块分布')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Dashboard 选择本周时按当前终止日期填充整周范围', async () => {
|
||||
const user = userEvent.setup();
|
||||
test('Dashboard 默认日期使用北京时间', async () => {
|
||||
vi.setSystemTime(new Date('2026-06-26T17:30:00Z'));
|
||||
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('本日总生产素材数');
|
||||
await waitFor(() => {
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-27',
|
||||
endDate: '2026-06-27',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 选择本周时按今天填充整周范围', async () => {
|
||||
const user = setupUser();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('本日生产素材数');
|
||||
fireEvent.change(screen.getByLabelText('终止日期'), {
|
||||
target: { value: '2026-07-02' },
|
||||
});
|
||||
@@ -96,19 +120,19 @@ test('Dashboard 选择本周时按当前终止日期填充整周范围', async (
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'week',
|
||||
anchor: '2026-07-05',
|
||||
startDate: '2026-06-29',
|
||||
endDate: '2026-07-05',
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-22',
|
||||
endDate: '2026-06-28',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 选择本月时按当前终止日期填充整月范围', async () => {
|
||||
const user = userEvent.setup();
|
||||
test('Dashboard 选择本月时按今天填充整月范围', async () => {
|
||||
const user = setupUser();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('本日总生产素材数');
|
||||
await screen.findByText('本日生产素材数');
|
||||
fireEvent.change(screen.getByLabelText('终止日期'), {
|
||||
target: { value: '2026-07-12' },
|
||||
});
|
||||
@@ -116,10 +140,28 @@ test('Dashboard 选择本月时按当前终止日期填充整月范围', async (
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'month',
|
||||
anchor: '2026-07-31',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-06-30',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 从本月切回本日时回到今天', async () => {
|
||||
const user = setupUser();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('本日生产素材数');
|
||||
await user.click(screen.getByRole('button', { name: '本月' }));
|
||||
await user.click(screen.getByRole('button', { name: '本日' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: '2026-06-27',
|
||||
endDate: '2026-06-27',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -127,7 +169,7 @@ test('Dashboard 选择本月时按当前终止日期填充整月范围', async (
|
||||
test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('本日总生产素材数');
|
||||
await screen.findByText('本日生产素材数');
|
||||
fireEvent.change(screen.getByLabelText('起始日期'), {
|
||||
target: { value: '2026-06-01' },
|
||||
});
|
||||
@@ -145,3 +187,7 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
|
||||
});
|
||||
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
|
||||
});
|
||||
|
||||
function setupUser() {
|
||||
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
}
|
||||
|
||||
@@ -18,13 +18,12 @@ interface AdminDashboardPageProps {
|
||||
type AdminDashboardTab = 'metrics' | 'operations';
|
||||
|
||||
const granularityOptions: Array<{
|
||||
id: AdminDashboardGranularity;
|
||||
id: Exclude<AdminDashboardGranularity, 'period'>;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'day', label: '本日' },
|
||||
{ id: 'week', label: '本周' },
|
||||
{ id: 'month', label: '本月' },
|
||||
{ id: 'period', label: '本时段' },
|
||||
];
|
||||
|
||||
export function AdminDashboardPage({
|
||||
@@ -37,22 +36,22 @@ export function AdminDashboardPage({
|
||||
const [granularity, setGranularity] =
|
||||
useState<AdminDashboardGranularity>('day');
|
||||
const [dateRange, setDateRange] = useState(() => {
|
||||
const today = formatDateInput(new Date());
|
||||
const today = formatBeijingDateInput(new Date());
|
||||
return { startDate: today, endDate: today };
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
const loadDashboard = useCallback(async (range = dateRange) => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await getAdminDashboard(token, {
|
||||
granularity,
|
||||
anchor: granularity === 'period' ? undefined : dateRange.endDate,
|
||||
startDate: dateRange.startDate,
|
||||
endDate: dateRange.endDate,
|
||||
granularity: 'period',
|
||||
anchor: undefined,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
});
|
||||
setDashboard(response);
|
||||
} catch (error: unknown) {
|
||||
@@ -60,13 +59,7 @@ export function AdminDashboardPage({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
dateRange.endDate,
|
||||
dateRange.startDate,
|
||||
granularity,
|
||||
onUnauthorized,
|
||||
token,
|
||||
]);
|
||||
}, [dateRange, onUnauthorized, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
@@ -83,26 +76,49 @@ export function AdminDashboardPage({
|
||||
}, [loadDashboard]);
|
||||
|
||||
const metrics = dashboard?.metrics;
|
||||
const metricCards = useMemo(
|
||||
const totalMetricCards = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'generated-assets',
|
||||
label: `${rangePrefix(granularity)}总生产素材数`,
|
||||
value: metrics?.generatedAssets ?? 0,
|
||||
unit: '个',
|
||||
},
|
||||
{
|
||||
id: 'consumed-mud-points',
|
||||
label: `${rangePrefix(granularity)}总消耗泥点数`,
|
||||
value: metrics?.consumedMudPoints ?? 0,
|
||||
unit: '泥点',
|
||||
},
|
||||
{
|
||||
id: 'total-registered-users',
|
||||
label: '总注册用户',
|
||||
value: metrics?.totalRegisteredUsers ?? 0,
|
||||
unit: '人',
|
||||
},
|
||||
{
|
||||
id: 'total-visit-users',
|
||||
label: '总访问人数',
|
||||
value: metrics?.totalVisitUsers ?? 0,
|
||||
unit: '人',
|
||||
},
|
||||
{
|
||||
id: 'total-visit-count',
|
||||
label: '总访问次数',
|
||||
value: metrics?.totalVisitCount ?? 0,
|
||||
unit: '次',
|
||||
},
|
||||
{
|
||||
id: 'current-users',
|
||||
label: '当前使用人数(五分钟统计一次)',
|
||||
value: metrics?.currentUsers ?? 0,
|
||||
unit: '人',
|
||||
},
|
||||
],
|
||||
[metrics],
|
||||
);
|
||||
const periodMetricCards = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'generated-assets',
|
||||
label: `${rangePrefix(granularity)}生产素材数`,
|
||||
value: metrics?.generatedAssets ?? 0,
|
||||
unit: '个',
|
||||
},
|
||||
{
|
||||
id: 'consumed-mud-points',
|
||||
label: `${rangePrefix(granularity)}消耗泥点数`,
|
||||
value: metrics?.consumedMudPoints ?? 0,
|
||||
unit: '泥点',
|
||||
},
|
||||
{
|
||||
id: 'new-registered-users',
|
||||
label: `${rangePrefix(granularity)}新增用户数`,
|
||||
@@ -115,30 +131,12 @@ export function AdminDashboardPage({
|
||||
value: metrics?.visitUsers ?? 0,
|
||||
unit: '人',
|
||||
},
|
||||
{
|
||||
id: 'total-visit-users',
|
||||
label: '总访问人数',
|
||||
value: metrics?.totalVisitUsers ?? 0,
|
||||
unit: '人',
|
||||
},
|
||||
{
|
||||
id: 'visit-count',
|
||||
label: `${rangePrefix(granularity)}访问次数`,
|
||||
value: metrics?.visitCount ?? 0,
|
||||
unit: '次',
|
||||
},
|
||||
{
|
||||
id: 'total-visit-count',
|
||||
label: '总访问次数',
|
||||
value: metrics?.totalVisitCount ?? 0,
|
||||
unit: '次',
|
||||
},
|
||||
{
|
||||
id: 'current-users',
|
||||
label: '当前使用人数(五分钟统计一次)',
|
||||
value: metrics?.currentUsers ?? 0,
|
||||
unit: '人',
|
||||
},
|
||||
],
|
||||
[granularity, metrics],
|
||||
);
|
||||
@@ -244,16 +242,41 @@ export function AdminDashboardPage({
|
||||
|
||||
{activeTab === 'metrics' ? (
|
||||
<>
|
||||
<div className="admin-dashboard-metric-grid">
|
||||
{metricCards.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
unit={metric.unit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>总计数据</h3>
|
||||
<span>全站累计</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-metric-grid">
|
||||
{totalMetricCards.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
unit={metric.unit}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>时段数据</h3>
|
||||
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-metric-grid">
|
||||
{periodMetricCards.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
unit={metric.unit}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-dashboard-chart-grid">
|
||||
{(dashboard?.charts ?? []).map((chart) => (
|
||||
@@ -299,13 +322,22 @@ export function AdminDashboardPage({
|
||||
</section>
|
||||
);
|
||||
|
||||
function handleGranularityChange(nextGranularity: AdminDashboardGranularity) {
|
||||
setGranularity(nextGranularity);
|
||||
setDateRange((current) =>
|
||||
nextGranularity === 'period'
|
||||
? normalizeDateRange(current)
|
||||
: buildPresetDateRange(nextGranularity, current.endDate),
|
||||
function handleGranularityChange(
|
||||
nextGranularity: Exclude<AdminDashboardGranularity, 'period'>,
|
||||
) {
|
||||
const nextRange = buildPresetDateRange(
|
||||
nextGranularity,
|
||||
formatBeijingDateInput(new Date()),
|
||||
);
|
||||
const rangeChanged =
|
||||
nextRange.startDate !== dateRange.startDate ||
|
||||
nextRange.endDate !== dateRange.endDate;
|
||||
setGranularity(nextGranularity);
|
||||
if (rangeChanged) {
|
||||
setDateRange(nextRange);
|
||||
} else {
|
||||
void loadDashboard(nextRange);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateRangeChange(
|
||||
@@ -418,11 +450,13 @@ function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN').format(value);
|
||||
}
|
||||
|
||||
function formatDateInput(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
function formatBeijingDateInput(date: Date) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function rangePrefix(granularity: AdminDashboardGranularity) {
|
||||
@@ -442,7 +476,7 @@ function buildPresetDateRange(
|
||||
granularity: AdminDashboardGranularity,
|
||||
selectedDate: string,
|
||||
) {
|
||||
const fallbackDate = formatDateInput(new Date());
|
||||
const fallbackDate = formatBeijingDateInput(new Date());
|
||||
const anchor = parseDateValueAsUtc(selectedDate)
|
||||
? selectedDate
|
||||
: fallbackDate;
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import {ChevronDown, PowerOff, RefreshCcw, Save} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useMemo, useState} from 'react';
|
||||
import { ChevronDown, PowerOff, RefreshCcw, Save } from 'lucide-react';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
disableProfileTaskConfig,
|
||||
listAdminTrackingEventKeys,
|
||||
listProfileTaskConfigs,
|
||||
upsertProfileTaskConfig,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminTrackingEventKeyPayload,
|
||||
ProfileTaskConfigAdminResponse,
|
||||
ProfileTaskCycle,
|
||||
TrackingScopeKind,
|
||||
} from '../api/adminApiTypes';
|
||||
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import {
|
||||
filterAdminProfileTaskTrackingEventDefinitions,
|
||||
buildAdminTrackingEventKeyOptions,
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import {handlePageError} from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminTaskConfigPageProps {
|
||||
token: string;
|
||||
@@ -25,8 +28,8 @@ interface AdminTaskConfigPageProps {
|
||||
onResultChange: (result: ProfileTaskConfigAdminResponse) => void;
|
||||
}
|
||||
|
||||
const taskCycles: Array<{value: ProfileTaskCycle; label: string}> = [
|
||||
{value: 'daily', label: '每日'},
|
||||
const taskCycles: Array<{ value: ProfileTaskCycle; label: string }> = [
|
||||
{ value: 'daily', label: '每日' },
|
||||
];
|
||||
|
||||
const profileTaskScopeKind = 'user' satisfies TrackingScopeKind;
|
||||
@@ -43,6 +46,9 @@ export function AdminTaskConfigPage({
|
||||
const [description, setDescription] = useState('');
|
||||
const [eventKey, setEventKey] = useState('daily_login');
|
||||
const [eventKeySearch, setEventKeySearch] = useState('每日登录');
|
||||
const [eventKeys, setEventKeys] = useState<AdminTrackingEventKeyPayload[]>(
|
||||
[],
|
||||
);
|
||||
const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false);
|
||||
const [cycle, setCycle] = useState<ProfileTaskCycle>('daily');
|
||||
const [threshold, setThreshold] = useState('1');
|
||||
@@ -56,22 +62,53 @@ export function AdminTaskConfigPage({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
|
||||
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTaskConfigs();
|
||||
void refreshTrackingEventKeys();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const selectedEventDefinition = useMemo(
|
||||
() => findAdminTrackingEventDefinition(eventKey),
|
||||
[eventKey],
|
||||
const eventKeyOptions = useMemo(
|
||||
() =>
|
||||
buildAdminTrackingEventKeyOptions(eventKeys).filter((option) =>
|
||||
option.scopeKinds.includes(profileTaskScopeKind),
|
||||
),
|
||||
[eventKeys],
|
||||
);
|
||||
const selectedEventOption = useMemo(
|
||||
() =>
|
||||
eventKeyOptions.find((option) => option.key === eventKey) ??
|
||||
findAdminTrackingEventDefinition(eventKey),
|
||||
[eventKey, eventKeyOptions],
|
||||
);
|
||||
const filteredEventDefinitions = useMemo(
|
||||
() => filterAdminProfileTaskTrackingEventDefinitions(eventKeySearch),
|
||||
[eventKeySearch],
|
||||
() => filterAdminTrackingEventKeyOptions(eventKeyOptions, eventKeySearch),
|
||||
[eventKeyOptions, eventKeySearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventKeySearch !== eventKey) {
|
||||
return;
|
||||
}
|
||||
const nextOption =
|
||||
eventKeyOptions.find((option) => option.key === eventKey) ??
|
||||
findAdminTrackingEventDefinition(eventKey);
|
||||
if (nextOption) {
|
||||
setEventKeySearch(nextOption.title);
|
||||
}
|
||||
}, [eventKey, eventKeyOptions, eventKeySearch]);
|
||||
|
||||
async function refreshTrackingEventKeys() {
|
||||
try {
|
||||
const response = await listAdminTrackingEventKeys(token);
|
||||
setEventKeys(response.eventKeys);
|
||||
} catch {
|
||||
setEventKeys([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTaskConfigs() {
|
||||
setIsLoading(true);
|
||||
setListErrorMessage('');
|
||||
@@ -182,16 +219,20 @@ export function AdminTaskConfigPage({
|
||||
setSortOrder(String(entry.sortOrder));
|
||||
setEnabled(entry.enabled);
|
||||
setDisableTaskId(entry.taskId);
|
||||
const nextDefinition = findAdminTrackingEventDefinition(entry.eventKey);
|
||||
setEventKeySearch(nextDefinition?.title ?? entry.eventKey);
|
||||
const nextOption =
|
||||
eventKeyOptions.find((option) => option.key === entry.eventKey) ??
|
||||
findAdminTrackingEventDefinition(entry.eventKey);
|
||||
setEventKeySearch(nextOption?.title ?? entry.eventKey);
|
||||
setIsEventKeyPickerOpen(false);
|
||||
}
|
||||
|
||||
function selectEventKey(nextEventKey: string) {
|
||||
const nextDefinition = findAdminTrackingEventDefinition(nextEventKey);
|
||||
const nextOption =
|
||||
eventKeyOptions.find((option) => option.key === nextEventKey) ??
|
||||
findAdminTrackingEventDefinition(nextEventKey);
|
||||
setEventKey(nextEventKey);
|
||||
if (nextDefinition) {
|
||||
setEventKeySearch(nextDefinition.title);
|
||||
if (nextOption) {
|
||||
setEventKeySearch(nextOption.title);
|
||||
} else {
|
||||
setEventKeySearch(nextEventKey);
|
||||
}
|
||||
@@ -322,9 +363,9 @@ export function AdminTaskConfigPage({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedEventDefinition ? (
|
||||
{selectedEventOption ? (
|
||||
<small className="admin-field-note">
|
||||
{selectedEventDefinition.remark}
|
||||
{selectedEventOption.remark}
|
||||
</small>
|
||||
) : (
|
||||
<small className="admin-field-note">
|
||||
|
||||
@@ -1,46 +1,55 @@
|
||||
import {Download, Eye, RefreshCcw, Search, X} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useMemo, useState} from 'react';
|
||||
import { Download, Eye, RefreshCcw, Search, X } from 'lucide-react';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {listAdminTrackingEvents} from '../api/adminApiClient';
|
||||
import {
|
||||
listAdminTrackingEventKeys,
|
||||
listAdminTrackingEvents,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminTrackingEventEntryPayload,
|
||||
AdminTrackingEventKeyPayload,
|
||||
TrackingScopeKind,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
buildAdminTrackingEventKeyOptions,
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
filterAdminTrackingEventDefinitions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import {handlePageError} from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminTrackingEventsPageProps {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
const scopeKindOptions: Array<{value: TrackingScopeKind | ''; label: string}> = [
|
||||
{value: '', label: '全部'},
|
||||
{value: 'site', label: 'site'},
|
||||
{value: 'work', label: 'work'},
|
||||
{value: 'module', label: 'module'},
|
||||
{value: 'user', label: 'user'},
|
||||
const scopeKindOptions: Array<{
|
||||
value: TrackingScopeKind | '';
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'site', label: 'site' },
|
||||
{ value: 'work', label: 'work' },
|
||||
{ value: 'module', label: 'module' },
|
||||
{ value: 'user', label: 'user' },
|
||||
];
|
||||
|
||||
const exportColumns: Array<{
|
||||
key: keyof AdminTrackingEventEntryPayload;
|
||||
label: string;
|
||||
}> = [
|
||||
{key: 'eventId', label: '事件 ID'},
|
||||
{key: 'eventKey', label: 'Event Key'},
|
||||
{key: 'eventTitle', label: '事件名称'},
|
||||
{key: 'scopeKind', label: 'Scope Kind'},
|
||||
{key: 'scopeId', label: 'Scope ID'},
|
||||
{key: 'dayKey', label: 'Day Key'},
|
||||
{key: 'userId', label: 'User ID'},
|
||||
{key: 'ownerUserId', label: 'Owner User ID'},
|
||||
{key: 'profileId', label: 'Profile ID'},
|
||||
{key: 'moduleKey', label: 'Module Key'},
|
||||
{key: 'metadataJson', label: 'Metadata JSON'},
|
||||
{key: 'occurredAt', label: '发生时间'},
|
||||
{ key: 'eventId', label: '事件 ID' },
|
||||
{ key: 'eventKey', label: 'Event Key' },
|
||||
{ key: 'eventTitle', label: '事件名称' },
|
||||
{ key: 'scopeKind', label: 'Scope Kind' },
|
||||
{ key: 'scopeId', label: 'Scope ID' },
|
||||
{ key: 'dayKey', label: 'Day Key' },
|
||||
{ key: 'userId', label: 'User ID' },
|
||||
{ key: 'ownerUserId', label: 'Owner User ID' },
|
||||
{ key: 'profileId', label: 'Profile ID' },
|
||||
{ key: 'moduleKey', label: 'Module Key' },
|
||||
{ key: 'metadataJson', label: 'Metadata JSON' },
|
||||
{ key: 'occurredAt', label: '发生时间' },
|
||||
];
|
||||
|
||||
export function AdminTrackingEventsPage({
|
||||
@@ -52,21 +61,47 @@ export function AdminTrackingEventsPage({
|
||||
const [userId, setUserId] = useState('');
|
||||
const [scopeKind, setScopeKind] = useState<TrackingScopeKind | ''>('');
|
||||
const [scopeId, setScopeId] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [limit, setLimit] = useState('200');
|
||||
const [eventKeys, setEventKeys] = useState<AdminTrackingEventKeyPayload[]>(
|
||||
[],
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExportingAll, setIsExportingAll] = useState(false);
|
||||
const [detailEntry, setDetailEntry] =
|
||||
useState<AdminTrackingEventEntryPayload | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTrackingEventKeys();
|
||||
void refreshTrackingEvents();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const filteredEventDefinitions = useMemo(
|
||||
() => filterAdminTrackingEventDefinitions(eventKey),
|
||||
[eventKey],
|
||||
const eventKeyOptions = useMemo(
|
||||
() => buildAdminTrackingEventKeyOptions(eventKeys),
|
||||
[eventKeys],
|
||||
);
|
||||
const filteredEventDefinitions = useMemo(() => {
|
||||
const dynamicOptions = filterAdminTrackingEventKeyOptions(
|
||||
eventKeyOptions,
|
||||
eventKey,
|
||||
);
|
||||
if (dynamicOptions.length) {
|
||||
return dynamicOptions;
|
||||
}
|
||||
return filterAdminTrackingEventDefinitions(eventKey);
|
||||
}, [eventKey, eventKeyOptions]);
|
||||
|
||||
async function refreshTrackingEventKeys() {
|
||||
try {
|
||||
const response = await listAdminTrackingEventKeys(token);
|
||||
setEventKeys(response.eventKeys);
|
||||
} catch {
|
||||
setEventKeys([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTrackingEvents() {
|
||||
setIsLoading(true);
|
||||
@@ -77,6 +112,8 @@ export function AdminTrackingEventsPage({
|
||||
userId,
|
||||
scopeKind,
|
||||
scopeId,
|
||||
startDate,
|
||||
endDate,
|
||||
limit: parseLimit(limit),
|
||||
});
|
||||
setEntries(response.entries);
|
||||
@@ -100,6 +137,32 @@ export function AdminTrackingEventsPage({
|
||||
exportTrackingEventsAsExcel(entries);
|
||||
}
|
||||
|
||||
async function handleExportAll() {
|
||||
setIsExportingAll(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminTrackingEvents(token, {
|
||||
eventKey,
|
||||
userId,
|
||||
scopeKind,
|
||||
scopeId,
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 100_000,
|
||||
exportAll: true,
|
||||
});
|
||||
if (!response.entries.length) {
|
||||
setErrorMessage('当前没有可导出的埋点数据');
|
||||
return;
|
||||
}
|
||||
exportTrackingEventsAsExcel(response.entries);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsExportingAll(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
@@ -110,7 +173,7 @@ export function AdminTrackingEventsPage({
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isExportingAll}
|
||||
type="button"
|
||||
onClick={refreshTrackingEvents}
|
||||
>
|
||||
@@ -126,6 +189,15 @@ export function AdminTrackingEventsPage({
|
||||
<Download size={17} aria-hidden="true" />
|
||||
<span>导出 Excel</span>
|
||||
</button>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isExportingAll}
|
||||
type="button"
|
||||
onClick={handleExportAll}
|
||||
>
|
||||
<Download size={17} aria-hidden="true" />
|
||||
<span>{isExportingAll ? '导出中' : '导出全部'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +258,27 @@ export function AdminTrackingEventsPage({
|
||||
onChange={(event) => setLimit(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="admin-secondary-button" disabled={isLoading} type="submit">
|
||||
<label className="admin-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '查询中' : '查询'}</span>
|
||||
</button>
|
||||
@@ -353,9 +445,13 @@ function formatMetadataJson(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) {
|
||||
function exportTrackingEventsAsExcel(
|
||||
entries: AdminTrackingEventEntryPayload[],
|
||||
) {
|
||||
const tableRows = [
|
||||
exportColumns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join(''),
|
||||
exportColumns
|
||||
.map((column) => `<th>${escapeHtml(column.label)}</th>`)
|
||||
.join(''),
|
||||
...entries.map((entry) =>
|
||||
exportColumns
|
||||
.map(
|
||||
@@ -368,7 +464,9 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[])
|
||||
const html = `\uFEFF<html><head><meta charset="UTF-8" /></head><body><table>${tableRows
|
||||
.map((row) => `<tr>${row}</tr>`)
|
||||
.join('')}</table></body></html>`;
|
||||
const blob = new Blob([html], {type: 'application/vnd.ms-excel;charset=utf-8'});
|
||||
const blob = new Blob([html], {
|
||||
type: 'application/vnd.ms-excel;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
@@ -379,7 +477,10 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[])
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function formatExportCell(value: unknown, key?: keyof AdminTrackingEventEntryPayload) {
|
||||
function formatExportCell(
|
||||
value: unknown,
|
||||
key?: keyof AdminTrackingEventEntryPayload,
|
||||
) {
|
||||
if (value === null || typeof value === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -299,6 +299,10 @@ button:disabled {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.admin-dashboard-actions .admin-secondary-button {
|
||||
min-width: 86px;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs {
|
||||
display: inline-grid;
|
||||
width: fit-content;
|
||||
|
||||
+3
-1
@@ -30,10 +30,12 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
|
||||
|
||||
`/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP,采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。P1 先用确定性 mock Agent 生成结构化 patch、真实打通项目 / 快照 / 构建 / artifact / 预览闭环,落地拆分见 [【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md](./technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md)。
|
||||
|
||||
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
|
||||
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,采用前端 JSZip 打包画布中有效图层引用的上传图、生成图、修改结果和角色动作序列帧;动作图层右键“导出为”提供序列帧 ZIP(含前端生成的 `preview.gif`)与 Spine JSON ZIP 两个二级选项,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
|
||||
|
||||
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
|
||||
|
||||
React 组件测试的用户行为、稳定契约、hook / model 分层断言口径,以及避免内部 DOM 探针、图标 class 和完整对象快照式断言的规则见 [【前端测试】React组件测试准则-2026-06-26.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%B5%8B%E8%AF%95%E3%80%91React%E7%BB%84%E4%BB%B6%E6%B5%8B%E8%AF%95%E5%87%86%E5%88%99-2026-06-26.md)。
|
||||
|
||||
桌面端全站客服悬浮入口的全局接入点、固定层级、拖拽边界和移动端隐藏口径见 [【前端架构】全站客服悬浮入口接入约定-2026-06-23.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%85%A8%E7%AB%99%E5%AE%A2%E6%9C%8D%E6%82%AC%E6%B5%AE%E5%85%A5%E5%8F%A3%E6%8E%A5%E5%85%A5%E7%BA%A6%E5%AE%9A-2026-06-23.md)。
|
||||
|
||||
桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。
|
||||
|
||||
@@ -1810,6 +1810,12 @@
|
||||
"imageSrc": {
|
||||
"type": "string"
|
||||
},
|
||||
"thumbnailSrc": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"objectKey": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -2778,7 +2784,14 @@
|
||||
"properties": {
|
||||
"referenceImageSrc": {
|
||||
"type": "string",
|
||||
"description": "规范图/参考图 Data URL。"
|
||||
"description": "规范图/参考图 Data URL 或 objectKey;上传参考图优先提交 objectKey。"
|
||||
},
|
||||
"referenceImageSrcs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "额外图标素材参考图 Data URL 或 objectKey;上传参考图优先提交 objectKey。"
|
||||
}
|
||||
},
|
||||
"iconDescriptions": {
|
||||
"type": "array",
|
||||
@@ -2840,7 +2853,19 @@
|
||||
"properties": {
|
||||
"sourceImageSrc": {
|
||||
"type": "string",
|
||||
"description": "UI 设计图 Data URL。"
|
||||
"description": "UI 设计图 Data URL 或 objectKey。"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"default": "gemini-3.1-flash-image-preview",
|
||||
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时默认使用 nanobanana。"
|
||||
},
|
||||
"referenceImageSrcs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "额外 UI 素材参考图 Data URL 或 objectKey;上传参考图优先提交 objectKey。"
|
||||
}
|
||||
},
|
||||
"aspectRatio": {
|
||||
"type": "string",
|
||||
@@ -2930,7 +2955,6 @@
|
||||
"spritesheetImageSrc",
|
||||
"spritesheetWidth",
|
||||
"spritesheetHeight",
|
||||
"iconImageSrcs",
|
||||
"prompt",
|
||||
"model",
|
||||
"provider",
|
||||
@@ -2951,6 +2975,7 @@
|
||||
},
|
||||
"iconImageSrcs": {
|
||||
"type": "array",
|
||||
"description": "兼容旧客户端的切片素材列表;图标素材生成固定为空,UI 设计图提取素材可返回切片。",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
## 1. 目标
|
||||
|
||||
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块再按固定 `4列*3行` UV 网切成 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
|
||||
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块内部再用自适应 blob+gradient 算法提取 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
|
||||
|
||||
首版目标:
|
||||
|
||||
1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生;
|
||||
2. image2 只生成一张 `1024x1536` 地板 UV 展开图集,后端切成 18 组、共 108 张面贴图 PNG;
|
||||
3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG;
|
||||
4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块;
|
||||
5. 操作方式为长按屏幕蓄力并按拖拽方向起跳,松手后角色按前端提交的后端方向向量弹出;
|
||||
4. 运行态每屏只展示 2 个地块:当前地块、目标地块,不再展示下一预览地块;
|
||||
5. 操作方式为长按屏幕蓄力,松手后角色朝下一块地块中心方向弹出;
|
||||
6. 只要落点未命中下一个地块,本局立即失败并冻结计时;
|
||||
7. 成绩记录成功跳跃次数和游戏时长;
|
||||
8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
|
||||
@@ -21,12 +21,12 @@
|
||||
- 展示名:`跳一跳`
|
||||
- 工程域:`jump-hop`
|
||||
- 创作入口卡:`subtitle = 主题驱动平台跳跃`,`imageSrc = /creation-type-references/jump-hop.webp`
|
||||
- 运行态:`Three.js 标准 1x1x1 等比极小倒角立方体地板 + DOM 角色 + DOM HUD`
|
||||
- 运行态:`Three.js 标准 1x1x1 等比极小倒角立方体地板 + Three.js Sprite 角色 + DOM HUD`
|
||||
- 画面比例:移动端竖屏优先,桌面端居中承载 `9:16`
|
||||
- 素材策略:18 个立方体主题物体 UV 展开包装 + Three.js 复用标准 1x1x1 等比立方体几何 + 陶泥儿 logo 透明角色
|
||||
- 渲染分层:Three.js 平台层复用一份标准 `1x1x1` 等比极小倒角立方体几何体,`tileAssets[]` 切片只作为主题身份方块包装贴图;单块立方体必须正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,也不得用不同 x/y/z scale 压成扁盒子;运行态视角采用约 `1.3x` 近距相机和 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,后续两块向上展开且保持紧凑的纵向 / 横向间距;Three.js 平台层与 DOM 角色层必须保持屏幕 X 轴同向,禁止通过反向相机 `up` 或镜像容器把平台左右翻转;DOM 地块图片层只用于换签、预加载、WebGL 不可用和测试 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,退出地块只随相机推进自然离屏,不播放独立飞走动画,超过屏幕后再销毁,避免旧地块退出期露出被放大的平面 DOM 贴图;角色必须由 DOM 透明 PNG 层渲染并保持在 Three.js 平台层之上
|
||||
- 渲染分层:Three.js 场景层复用一份标准 `1x1x1` 等比极小倒角立方体几何体,`tileAssets[]` 切片只作为主题身份方块包装贴图;单块立方体统一绕玩法竖直 Z 轴自转 45°,让运行态稳定露出顶面和两个侧面,不得做 Y 轴偏航或把 x/y/z scale 压成扁盒子;Three.js 方块模型边长在当前基础上视觉放大 1 倍,只改变模型显示尺寸,不改变平台中心点、随机间距和蓄力换算;后端命中 footprint 必须同步等于当前视觉可见顶面,不得隐藏收缩;运行态视角采用约 `1.69x` 近距相机和 45° 下压视角,每屏只保留当前地块和目标地块,当前脚下地块会根据目标方向偏向场地反侧,给下一块留出足够视野;地块从出现开始就使用自身真实规格,不再按当前 / 目标 / 远近或预览状态叠加倍率缩放,视觉远近只由相机和 Three.js 投影决定;Three.js 平台层、Three.js Sprite 角色和 DOM fallback 层必须保持屏幕 X 轴同向,禁止通过反向相机 `up` 或镜像容器把平台左右翻转;DOM 地块图片层只用于换签、预加载、WebGL 不可用和测试 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,退出地块只随相机推进自然离屏,不播放独立飞走动画,超过屏幕后再销毁,避免旧地块退出期露出被放大的平面 DOM 贴图;角色主路径使用 Three.js Sprite 承载陶泥儿透明 PNG,Sprite 脚点必须落在当前方块顶面中心高度并且绘制顺序高于地块,DOM 角色层仅在 WebGL 或角色贴图加载失败时兜底
|
||||
|
||||
本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。
|
||||
本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块永远在当前脚下地块的正 45 度或负 45 度方向随机出现。
|
||||
|
||||
## 3. 创作工具平台接入声明
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
- 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色
|
||||
- 系列素材槽位:
|
||||
- `batchId = jump-hop-tile-atlas`
|
||||
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格4列*3行UV网 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG`
|
||||
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格内自适应blob+gradient提取六面 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG`
|
||||
- `slotSpecs = tile-01 ... tile-18`,每个 tile 再包含 `top/front/right/back/left/bottom` 六个面 slot,所有 slot 必须对应唯一 OSS path / `assetObjectId`
|
||||
- 切图规则:先按原图宽高均分为 3 列 6 行,从上到下、从左到右得到 18 个大单元;每个大单元内部固定 4 列 3 行 UV 网,`top` 在第 1 行第 2 列,`left/front/right/back` 在第 2 行第 1-4 列,`bottom` 在第 3 行第 2 列;每个面输出 `256x256` 不透明 PNG
|
||||
- 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位,后端不做透明化抠图,只把裁切后残留的洋红 key 色转为不透明材质底色,保留绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题纹理
|
||||
- 切图规则:先通过 density 种子点精修自适应检测 3 列 6 行大单元边界(`SeedRefinement`);每个大单元内部先用 BFS 连通域提取主 blob、清除非主 blob 噪点,再对行 density 和列 height profile 做 gradient 分析检测边界(y0/y1/y2/y3、x0/x1/x2/x3),按此边界划分为 3x3 block 并保留 5 个有效 block,将含 Right+Back 的 block 从中点拆分为两块,对每个 block 取最大不透明矩形后缩放为 `256x256` 不透明 PNG
|
||||
- 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位;后端先对图集做洋红去背(BFS 漫水 + 镂空洞检测),再对每个大单元内提取主 blob 后进行自适应面切分;切分后在 block 内取最大不透明矩形,消除透明边缘
|
||||
- 失败回写:生成失败时 session 保持 failed,可从生成页重试
|
||||
- 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存
|
||||
- API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`
|
||||
@@ -60,11 +60,11 @@
|
||||
|
||||
## 5. 地板贴图图集
|
||||
|
||||
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体,并在固定 `4列*3行` UV 网中提供六张面贴图;不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
|
||||
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体,并在固定 `4列*3行` UV 网中提供六张面贴图(AI prompt 侧不变);后端通过自适应 blob+gradient 算法检测面的实际位置并切图,不再依赖固定像素坐标均分。不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
|
||||
|
||||
图集要求:
|
||||
|
||||
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`;
|
||||
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`。以上为 AI 生图的 layout 要求(prompt 侧不变)。后端切图优先使用自适应 blob+gradient 算法检测面的实际像素区域,不依赖固定像素坐标均分;固定网格切片只作为测试对照和必要 fallback 参考。
|
||||
2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标;
|
||||
3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成;
|
||||
4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理;
|
||||
@@ -83,38 +83,37 @@ tile-13 tile-14 tile-15
|
||||
tile-16 tile-17 tile-18
|
||||
```
|
||||
|
||||
每个 `tile-XX` 再切出 `top/front/right/back/left/bottom` 六个面贴图并写入 `tileAssets[].faceAssets`。历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 保存 top 面,旧作品没有 `faceAssets` 时运行态仍可把单张旧贴图应用到立方体所有面。运行态随机使用这 18 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。
|
||||
每个 `tile-XX` 再切出 `top/front/right/back/left/bottom` 六个面贴图并写入 `tileAssets[].faceAssets`。历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 保存 top 面;旧作品没有完整 `faceAssets` 时只走 DOM 图片 / 原型兜底层,不再把单张旧贴图强行贴到 Three.js 立方体所有面,避免旧平面素材被误表现成 UV 贴歪。运行态随机使用这 18 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。
|
||||
|
||||
## 6. 运行态规则
|
||||
|
||||
### 6.1 平台流
|
||||
|
||||
运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 3 个地块可见:
|
||||
运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 2 个地块可见:
|
||||
|
||||
1. 当前地块;
|
||||
2. 目标地块;
|
||||
3. 下一预览地块。
|
||||
2. 目标地块。
|
||||
|
||||
服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。前端只展示服务端快照,不自行生成正式路径。
|
||||
服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。每个后续地块只能生成在当前地块的正 45 度或负 45 度方向上,世界坐标必须满足 `abs(next.x - current.x) == next.y - current.y`,左右方向由 seed 随机决定。当前版本已有的最远相邻地块间距作为各难度 `max_gap` 上限;每次新地块距离由 seed 在 `max_gap * 55%` 到 `max_gap` 之间随机,保证相对距离永远大于 0 且不会超过当前最大手感距离。前端只展示服务端快照,不自行生成正式路径;当前两块可见窗口必须按服务端真实相邻距离缩放屏幕投影,最大距离仍落在当前版本固定的左上 / 右上 45 度位置,较近距离则沿同一 45 度方向靠近当前脚下地块。当前脚下地块仍根据目标方向偏向目标反侧,避免前方同时暴露两块地块。角色开局时脚点必须锚定在初始地块顶面中心;Three.js 角色层通过立方体顶面高度定位脚点,DOM fallback 也使用同一顶面中心屏幕锚点,不得额外把角色吸附到地块侧面或阴影中心。
|
||||
|
||||
### 6.2 操作
|
||||
|
||||
1. 用户按住当前地块或画面开始蓄力;
|
||||
2. 长按时长形成蓄力值,达到 `maxChargeMs` 后封顶;
|
||||
3. 松手后角色按本次输入方向弹出;
|
||||
4. 蓄力值决定跳跃距离,拖拽方向决定跳跃方向;
|
||||
5. 前端必须同时提交 `dragDistance` 与换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端以这两个方向字段裁决真实落点;旧客户端缺失方向或方向非法时,后端才 fallback 到当前地块中心指向下一块地块中心。
|
||||
3. 松手后角色从当前真实脚点出发,朝下一块地块顶面中心方向弹出;
|
||||
4. 蓄力值决定跳跃距离,用户拖拽方向不决定跳跃方向;
|
||||
5. 前端提交 `dragDistance`,并为兼容后端契约提交由角色当前真实脚点指向下一块地块顶面中心推导出的 `dragVectorX/dragVectorY`;这些方向字段不得来自用户手指拖拽方向。
|
||||
|
||||
手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃距离的换算系数;旧作品运行时若仍携带其它系数,开局归一化为 `0.004`。契约中的 `dragDistance` 语义是前端提交的蓄力值;`dragVectorX/dragVectorY` 是正式方向输入契约,不能在前端提交或后端裁决中丢弃。
|
||||
手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃距离的换算系数;旧作品运行时若仍携带其它系数,开局归一化为 `0.004`。契约中的 `dragDistance` 语义是前端提交的蓄力值;`dragVectorX/dragVectorY` 仅用于兼容当前后端请求结构,玩法语义上不表示用户拖拽方向。
|
||||
|
||||
松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画;视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色从当前地块沿下一块地块中心方向弹向预测真实落点,蓄力阶段角色只做垂直压缩,不沿目标方向拉长。成功落地后必须保留 `lastJump.landedX/landedY` 对应的真实落点偏移,不得强制吸附回目标地块中心;落地后可以轻量回弹,但不能把角色位置拉离真实落点。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应先使用后端真实落点对齐显示态,再进入约 `1440ms` 的相机推进过渡,避免角色先飞过很远再瞬间拉回地块。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块只随相机推进保留在屏幕后方,不单独执行飞走动画,玩家继续向前跳时再被新的相机推进自然带出屏幕并销毁,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。
|
||||
松手后前端必须立即生成 `visualJump`,用当前角色真实脚点作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画;视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色从当前真实脚点沿下一块地块顶面中心方向弹向预测真实落点,蓄力阶段角色只做垂直压缩,不沿目标方向拉长。当前调参验证阶段,按住蓄力时允许显示一枚实时预测落点指示器,位置必须复用同一套前端预测结果:先按真实脚点到下一块顶面中心计算 `landedX/landedY`,再把该世界坐标投影到当前窗口和 Three.js 顶面脚点屏幕位置;不得用当前地块中心或屏幕线性插值替代。松手或取消时隐藏指示器,不参与后端裁决、不写入作品配置。成功落地后必须保留 `lastJump.landedX/landedY` 对应的真实落点偏移,不得强制吸附回目标地块中心;落地后可以轻量回弹,但不能把角色位置拉离真实落点。动画期间显示窗口保持在本次起跳前的 2 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应先使用后端真实落点对齐显示态,成功跳跃在飞行动画结束后保留约 `300ms` 落地停顿,再进入约 `1440ms` 的相机推进过渡,避免角色刚落地就立刻拉镜头。推进过渡中,地块层和角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块只随相机推进保留在屏幕后方,不单独执行飞走动画,玩家继续向前跳时再被新的相机推进自然带出屏幕并销毁,新目标地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块从出现开始保持自身真实尺寸,不得通过当前 / 目标 / 远近 / 预览状态附加 CSS `scale(...)` 或深度倍率;推进期只做统一相机层位移,远近变化交给相机和 Three.js 真实投影。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。
|
||||
|
||||
### 6.3 判定
|
||||
|
||||
1. 目标永远是当前地块后的下一个地块;
|
||||
2. 真实落点沿前端提交的 `dragVectorX/dragVectorY` 归一化方向计算;仅当方向缺失、非有限数或长度过小时,才沿当前地块中心到下一块地块中心方向兼容计算;
|
||||
3. 落点进入下一个地块可见顶面 footprint,则成功;footprint 使用当前路径里该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%;
|
||||
4. 落点未进入下一个地块可见顶面 footprint,则失败;旧 `landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定;
|
||||
2. 真实落点沿角色当前真实脚点到下一块地块顶面中心方向计算;开局脚点等于初始地块顶面中心,成功跳跃后脚点等于后端 `lastJump.landedX/landedY`,不得回退到当前地块中心;
|
||||
3. 落点进入下一个地块完整可见顶面 footprint,则成功;footprint 使用当前路径里该地块 `width/height` 按 45° 顶面投影得到的完整菱形区域,必须严格和当前视觉方块顶面一致,不得再额外收缩或放宽;
|
||||
4. 落点未进入下一个地块完整可见顶面 footprint,则失败;地块侧面、底面、投影阴影和旧半径范围都不算正确落点;旧 `landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定;
|
||||
5. 失败后状态改为 `failed`,计时冻结;
|
||||
6. v1 没有通关状态、combo、perfect 或生命数。
|
||||
|
||||
@@ -153,7 +152,7 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc
|
||||
|
||||
1. 陶泥儿 logo 透明角色预览;
|
||||
2. 18 个地块资源池预览;
|
||||
3. 首屏 3 块平台预览;
|
||||
3. 首屏 2 块平台预览;
|
||||
4. 试玩;
|
||||
5. 发布;
|
||||
6. 返回编辑;
|
||||
@@ -187,13 +186,13 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc
|
||||
|
||||
1. 创作页只显示主题输入;
|
||||
2. 生成链路只调用一次地板贴图图集 image2,不再调用角色生图;
|
||||
3. 地板贴图图集为 `1024x1536 / 3列*6行 / 每格4列*3行UV网`,后端切出 18 组、共 108 张面贴图 PNG;
|
||||
3. 地板贴图图集为 `1024x1536 / 3列*6行`,后端通过自适应 blob+gradient 算法切出 18 组、共 108 张面贴图 PNG;
|
||||
4. 结果页不依赖旧角色图片槽;
|
||||
5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
|
||||
6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向;
|
||||
5. 运行态为竖屏俯视角,首屏保持 2 个地块可见;
|
||||
6. 长按蓄力值影响落点距离,角色初始脚点在初始地块顶面中心,跳跃方向固定朝下一块地块中心,目标地块始终位于当前脚下地块的正 45 度或负 45 度方向;
|
||||
7. 未落到下一个地块立即失败;
|
||||
8. 成功跳跃次数累加,失败后计时冻结;
|
||||
9. 排行榜按成功跳跃次数优先排序;
|
||||
10. 作品可保存、发布、分享并从公开入口启动。
|
||||
11. 运行态 Three.js 地板必须优先把 `tileAssets[].faceAssets` 六面贴图按 right/left/top/bottom/front/back 材质顺序贴到标准 `1x1x1` 等比立方体上;旧作品没有 `faceAssets` 时才使用 `tileAssets[].imageSrc` 单贴图 fallback。六面贴图通过换签或 blob 异步解析时,Three.js 平台 mesh 的刷新签名必须包含 top/front/right/back/left/bottom 六个 texture URL,任一面 URL 变化都要触发材质重建,不能只监听旧单图 `imageSrc`。立方体正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,不得把 x/y/z 缩放成扁盒子;相机保持近距 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,可见三块地板之间的屏幕间距必须偏紧凑;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或 DOM 角色层。
|
||||
12. 同等世界距离的蓄力换算必须使用 `0.004` 系数,松手后必须先看到角色飞行动画,再看到地块窗口前移;成功落地显示必须保留真实落点偏移。
|
||||
11. 运行态 Three.js 地板必须只在 `tileAssets[].faceAssets` 六面贴图完整时启用 Three 平台层;玩法坐标把 Z 轴作为立方体竖直高度,因此材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom`,把逻辑 `top` 精确映射到 `+Z` 顶面,并按每面 UV 朝向做必要的翻转校正;六面贴图通过换签或 blob 异步解析时,Three.js 平台 mesh 的刷新签名必须包含 top/front/right/back/left/bottom 六个 texture URL,任一面 URL 变化都要触发材质重建,不能只监听旧单图 `imageSrc`。旧作品没有完整 `faceAssets` 时使用 DOM 图片 / 原型兜底层,不使用单图 3D 贴面 fallback。立方体统一绕玩法竖直 Z 轴自转 45°,让玩家稳定看到顶面和两个侧面,不做 Y 轴偏航,不得把 x/y/z 缩放成扁盒子;Three.js 方块模型边长视觉放大 1 倍,但平台中心点、随机间距和蓄力换算均保持原规则;后端命中 footprint 必须与当前视觉顶面完整对齐;地块材质使用 `alphaTest` 裁边但不得放进透明材质队列,避免透明排序把地块画到角色之上;角色主路径使用 Three.js Sprite 并与平台共用同一屏幕坐标投影,Sprite 脚点必须按当前方块半高抬到顶面中心高度,绘制顺序必须高于地块,DOM 角色仅作为 WebGL 或角色贴图加载失败兜底;相机保持约 `1.69x` 近距 45° 下压视角,当前脚下地块根据目标方向偏向场地反侧,可见当前 / 目标两块地板之间的屏幕间距必须形成正负 45 度关系;所有地块从出现开始保持真实规格,不按距离、深度或预览状态做倍率缩放;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或角色层。
|
||||
12. 同等世界距离的蓄力换算必须使用 `0.004` 系数,松手后必须先看到角色飞行动画,再保留约 `300ms` 落地停顿,随后看到地块窗口前移;成功落地显示必须保留真实落点偏移,且正确落点范围必须严格等于下一块地块完整可见顶面 footprint。
|
||||
|
||||
@@ -24,6 +24,22 @@
|
||||
- 验证方式:运行 `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-06-29 图片画布手动抠图走远端 BiRefNet BFF
|
||||
|
||||
- 背景:用户手动“去除背景”面对任意图片,前端 `chromaKey` 和标准绿幕后处理不适合复杂人物、自然背景或非纯色背景;远端 image host 已部署 BiRefNet 服务,需要让手动抠图走高质量模型,同时避免把服务令牌暴露到浏览器。
|
||||
- 决策:画布手动“去除背景”默认调用登录态同源 BFF `POST /api/editor/images/background-removals`。api-server 解析当前图片后代理到 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认指向 `http://58.87.105.82/remove-background`,可选 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只在服务端注入。api-server 对上游结果做字节和尺寸上限保护,并先落 OSS / asset object 再返回给前端。编辑器自己生成的标准绿幕资产不属于该决策,见 2026-06-30 绿幕契约收口。
|
||||
- 影响范围:api-server 编辑器图片接口、图片画布手动去背景、画布右上角任务侧栏、图片画布 MVP 技术文档。
|
||||
- 验证方式:运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server config::tests::from_env_reads_editor_background_removal_settings --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、定向画布 workflow 测试、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-26 React 组件测试按用户行为与稳定契约收敛
|
||||
|
||||
- 背景:部分 React 测试把组件内部状态、测试专用 DOM 探针、图标 class、完整按钮顺序或精确长文案当成契约,正常 UI 重构时容易误报,增加维护成本。
|
||||
- 决策:新增和重写 React 测试时,默认分成用户流程测试、稳定契约测试、hook / model 逻辑测试三层。用户流程测试优先断言 role / label / URL / 弹窗 / callback 等可感知结果;演化中的 DTO 和 callback payload 使用关键字段或 `expect.objectContaining(...)`;hook 测试使用 `renderHook` 验证公开返回契约,不再为读取内部状态制造 `data-testid` 仪表盘。
|
||||
- 影响范围:前端 React 组件测试、图片画布测试、平台入口测试、后续共享组件和 hook 测试新增 / 重写方式。
|
||||
- 验证方式:运行定向 React 测试、`npm run typecheck`、`npm run check:encoding` 和 `git diff --check`;出现正常重构引发测试破碎时,优先把测试改到用户行为或稳定契约层。
|
||||
- 关联文档:`docs/technical/【前端测试】React组件测试准则-2026-06-26.md`、`src/components/image-editor/useCanvasGenerationDialogs.test.tsx`、`src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`。
|
||||
|
||||
## 2026-06-26 AI 游戏创作 App 生成过程必须在聊天可见
|
||||
|
||||
- 背景:普通用户窗口只保留聊天入口,但如果生成确认后只显示“已生成草案”和本地产物路径,真实 LLM / Agent loop 会被误解成固定模板落盘。
|
||||
@@ -71,6 +87,7 @@
|
||||
|
||||
- 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。
|
||||
- 决策:`external_generation_job` 同时作为正式生成任务列表事实源,保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`;新增 `external_generation_job_event` 追加状态转换审计。BFF 新增当前账号任务列表和 acknowledge 接口;前端只展示后端任务状态,完成 / 失败提示关闭时由后端写确认时间,未确认终态任务在下次登录后按列表集中弹出。任务触发的钱包扣费 / 退款流水 metadata 必须写 `externalGenerationJobId`,本机退款 outbox 重放也保留该任务 ID。
|
||||
- 2026-06-25 追加:平台壳的当前账号任务列表只在登录、网络恢复、页面回到前台或已有 queued/running/未确认终态任务时刷新;空队列刷新一次后不保持 4 秒轮询,避免 `/api/runtime/external-generation/jobs` 在无任务时持续请求。
|
||||
- 影响范围:`spacetime-module` 外部生成 schema / procedure、`spacetime-client` bindings/facade、`api-server` 外部生成 BFF、worker 失败回写和资产计费退款链路、平台入口“我的”页任务卡和完成提示弹窗。
|
||||
- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wallet_refund_outbox --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
@@ -166,7 +183,7 @@
|
||||
## 2026-06-19 图片画布素材生成元数据上移到资源和素材
|
||||
|
||||
- 背景:角色、图标、UI 设计图、视频和音频等生成结果会在图片信息页展示用户可见输入快照;此前这些 `assetKind/generationInputs` 主要保存在画布 layer JSON 中,素材进入账号级素材库后跨项目复用和刷新恢复都依赖画布布局,不符合素材库作为账号级事实源的边界。
|
||||
- 决策:普通图层的新保存不再把 `assetKind/generationInputs` 写入 `editor_canvas.layers_json`。`editor_project_resource` 保存项目画布资源快照的 `asset_kind/generation_inputs_json`,`editor_asset` 保存账号级素材的同名元数据;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `projectId` / `assetFolderId` 时由后端创建新 resource / asset 并把快照回传前端,前端只用回包更新画布图层和素材栏,不再把同一生成结果二次调用保存接口。前端加载时优先从 resource / asset 恢复素材类别和生成输入快照,旧 layout 中的同名字段只作为历史兼容兜底。生成器对象本身仍作为 `itemType="generation-dialog"` 保存在画布布局中。
|
||||
- 决策:普通图层的新保存不再把 `assetKind/generationInputs` 写入 `editor_canvas.layers_json`。`editor_project_resource` 保存项目画布资源快照的 `asset_kind/generation_inputs_json`,`editor_asset` 保存账号级素材的同名元数据和可选封面 `thumbnail_src`;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `projectId` / `assetFolderId` 时由后端创建新 resource / asset 并把快照回传前端,前端只用回包更新画布图层和素材栏,不再把同一生成结果二次调用保存接口。生成视频由后端单独抽取首帧封面写入 `editor_asset.thumbnail_src` 和画布图层 `thumbnailSrc`,刷新素材库或从素材库拖回画布时继续作为视频 poster 使用。前端加载时优先从 resource / asset 恢复素材类别和生成输入快照,旧 layout 中的同名字段只作为历史兼容兜底。生成器对象本身仍作为 `itemType="generation-dialog"` 保存在画布布局中。
|
||||
- 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/mapper/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/services/image-editor/editorProjectClient.ts`、图片画布 hydrate / serialize / project persistence / asset library 代码和后端数据契约文档。
|
||||
- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`,并按需补充 `cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
@@ -182,11 +199,18 @@
|
||||
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
|
||||
|
||||
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,返回结构复用图标 spritesheet 响应。图标生成与 UI 提取都必须把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。
|
||||
- 决策:`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 图集,不再额外铺独立图标。
|
||||
- 影响范围:图片画布浮动工具栏、编辑器图片生成 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`。
|
||||
|
||||
## 2026-06-30 图片画布标准绿幕契约收口
|
||||
|
||||
- 背景:角色形象、图标 spritesheet、UI 提取 spritesheet 和角色动作帧都要求模型生成标准绿幕,但提示词片段和后处理入口分散在多个模块中,容易把标准绿幕资产误接到远端 BiRefNet。
|
||||
- 决策:编辑器标准绿幕提示词和本地确定性绿幕透明化统一收口到 `server-rs/crates/api-server/src/editor_green_screen.rs`。手动 `POST /api/editor/images/background-removals` 继续面向用户任意图片并走 BiRefNet;编辑器自己生成的标准绿幕资产统一复用 `platform-image::generated_asset_sheets` 的本地透明化能力,不再依赖 BiRefNet。角色图、图标 spritesheet、UI 提取 spritesheet 和角色动作抽帧源图必须在绿幕透明化前先保存一份带绿幕原图到 OSS,便于追溯和重处理。
|
||||
- 影响范围:`editor_project.rs` 的角色图 / 图标图集 / UI 提取图集、`character_animation_assets.rs` 的编辑器角色动作帧、编辑器绿幕相关文档。
|
||||
- 验证方式:运行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml prompt`,并单独按需过滤 `editor_canvas_green_screen_generation_uses_local_postprocess`、`editor_character_animation_frames_use_local_green_screen_postprocess`;同时运行 `npm run check:encoding` 和 `git diff --check`。
|
||||
|
||||
## 2026-06-18 `/creation` 独立为陶泥儿创作工具主页
|
||||
|
||||
- 背景:图片画布项目已经成为独立项目资产,旧“创作”站内 Tab 和一级“草稿”入口不能清晰表达桌面端创作工具主页与项目管理入口。
|
||||
@@ -198,7 +222,7 @@
|
||||
## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界
|
||||
|
||||
- 背景:`/editor/canvas` 生成视频需要严格对齐火山 Seedance 2.0 多模态参考输入;参考视频若继续走 Base64 / `data:video` 会超过请求体并被上游拒绝,参考音频单独输入和非 Seedance 模型携带参考字段也会违反文档契约。
|
||||
- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~9、视频 0~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset_object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference_*` role 构造,并显式发送 `generate_audio:false`。
|
||||
- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~9、视频 0~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset*object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference*\*`role 构造,并显式发送`generate_audio:false`。
|
||||
- 影响范围:图片画布生成视频面板、参考媒体上传工作流、`editorReferenceUploadClient`、`ImageCanvasGenerationSubmissionModel`、`shared-contracts`、`api-server` 编辑器视频 BFF、Lovart 生成类面板文档。
|
||||
- 验证方式:运行 `npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、火山 Seedance 2.0 任务创建文档。
|
||||
@@ -1082,6 +1106,7 @@
|
||||
|
||||
- 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。
|
||||
- 决策:OSS 继续作为 generated 私有资产源站,api-server 只签发短期读 URL,不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path,再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。
|
||||
- 2026-06-25 追加:前端需要读取 generated/private 资源字节时,也应先通过 `/api/assets/read-url` 获取 signed OSS URL 并由浏览器直接下载字节;`/api/assets/read-bytes` 只作为换签或 OSS 读取失败后的 fallback,不作为默认文件代理路径。
|
||||
- 影响范围:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss`、`shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。
|
||||
- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径、同一 `refreshKey` 版本且未临近过期时复用本地 signed URL;`platform-oss` 的 `PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/platform-oss/README.md`。
|
||||
@@ -2443,18 +2468,18 @@
|
||||
- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。
|
||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-06-01 跳一跳运行态地块视觉尺寸和命中半径统一放大一倍
|
||||
## 2026-06-01 跳一跳运行态地块视觉尺寸放大与命中 footprint 分离
|
||||
|
||||
- 背景:当前跳一跳运行态里地块视觉尺寸偏小,玩家反馈“很难跳上去”,但仅放大前端展示会造成画面和后端裁决脱节。
|
||||
- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2;前端平台渲染抽成统一尺寸 helper,保证单测可以直接校验放大结果。
|
||||
- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2;前端平台渲染抽成统一尺寸 helper,保证单测可以直接校验放大结果。后续校正:正式命中只看下一块可见顶面 footprint,不能让已 `2x` 归一化的 `width/height` 再把命中区二次放大;当前 footprint 使用归一化后宽度 28% / 高度 18% 的菱形,相当于旧未放大视觉规格的 56% / 36%,地块侧面、阴影和外沿不算正确落点。
|
||||
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、对应定向测试。
|
||||
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-02 跳一跳起跳距离减半并加入飞行动画缓冲
|
||||
|
||||
- 背景:用户反馈当前跳跃到目标位置需要拖得太远,且松手后缺少角色翻腾到目标地块的过渡动画,导致跳跃手感偏硬。
|
||||
- 决策:`jump-hop` 的 `chargeToDistanceRatio` 统一从 `0.004` 提升到 `0.008`,让同等跳跃距离所需拖动距离减半;前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测落点后若新 run 尚未返回,必须停在预测落点等待,再进入约 `1440ms` 的相机层推进过渡。推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。
|
||||
- 背景:用户反馈长按蓄力版本的跳跃手感偏硬,成功后角色容易被吸回地块中心,且后端回包或相机推进时会出现飞过很远再瞬间拉回的闪现。
|
||||
- 决策:`jump-hop` 当前长按蓄力统一使用 `chargeToDistanceRatio=0.004`,相同蓄力时间的世界跳跃距离比上一轮 `0.008` 降低一半。前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测真实落点后若新 run 尚未返回,必须停在预测真实落点等待。成功落地后角色位置必须保留 `lastJump.landedX/landedY` 映射出的真实偏移,不得吸附回目标地块中心;飞行动画结束后保留约 `300ms` 落地停顿,再启动相机推进。相机推进以旧窗口真实落点和新窗口真实落点为锚点,使用约 `1440ms` 过渡;推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。
|
||||
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。
|
||||
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
@@ -2467,6 +2492,14 @@
|
||||
- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。
|
||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-12 跳一跳地块间距以当前最远视觉距离为上限随机
|
||||
|
||||
- 背景:跳一跳服务端路径已有随机距离雏形,但前端可见窗口把目标地块固定投影到 `47%` 屏幕高度,导致用户看到的地块间距仍像固定值,无法调出“近到远”的节奏变化。
|
||||
- 决策:各难度当前 `max_gap` 保持为最大世界间距,最小间距固定为 `max_gap * 55%`,服务端按 seed 在该非零区间内随机生成下一块;前端 `buildJumpHopVisiblePlatforms` 必须用相邻地块真实世界距离缩放屏幕 X/Y 投影,最大距离沿用当前最远 45 度视觉位置,较近距离沿同一 45 度方向靠近当前块,不能再把目标块强制固定在同一屏幕坐标。
|
||||
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、跳一跳运行态测试、PRD 和平台玩法链路文档。
|
||||
- 验证方式:`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。
|
||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
|
||||
|
||||
- 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。
|
||||
@@ -2667,6 +2700,14 @@
|
||||
- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-12 跳一跳判定范围必须和视觉顶面对齐
|
||||
|
||||
- 背景:跳一跳切到 Three.js 立方体后,曾用收缩后的顶面 footprint 做成功判定,导致指示器和角色视觉上已经落在方块顶面内,但后端仍可能判失败。
|
||||
- 决策:跳一跳命中区必须严格等于当前视觉方块完整可见顶面 footprint,不论何时都不得隐藏收缩或额外放宽;如果后续调整方块视觉大小、顶面形状、相机角度、旋转或模型规格,后端裁决、前端落点指示器和 Three.js 顶面脚点投影必须同步更新。
|
||||
- 影响范围:`module-jump-hop` 后端裁决、`jumpHopRuntimeModel` 前端预测、运行态指示器、飞行动画、PRD 和平台链路文档。
|
||||
- 验证方式:边缘落点只要仍在完整视觉顶面内必须判成功;超出完整视觉顶面才失败。运行 `cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture` 与 `npm run test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。
|
||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-04 Platform Profile Wallet Delta Model 收口
|
||||
|
||||
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护钱包余额归一、本地 delta 乐观更新和服务端 dashboard 刷新后的 delta 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。
|
||||
@@ -2859,8 +2900,8 @@
|
||||
|
||||
## 2026-06-16 图片画布图标素材与角色生成支持双图片模型
|
||||
|
||||
- 背景:图片画布需要一次生成多枚 UI 图标素材,并保证生成后能按用户输入顺序命名、拆成独立透明素材铺回画布;角色形象生成也需要和图标素材共用同一套图片模型选择、比例和大小口径。
|
||||
- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"`。`生成角色形象` 与 `生成图标素材` 均支持 VectorEngine `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`,用户在两类面板中切换过模型后下一次打开继续沿用上次模型。前端提交 `model`、`aspectRatio`、`imageSize`;后端不再按图标数量分 `512x512/1024x1024`,而是按模型归一尺寸:`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,把参考图写成 `inline_data`,并在 `generationConfig.imageConfig` 写入比例和大小,`0.5K` 传 `"512"`;`gpt-image-2` 无参考图走 generations,有参考图走 edits,按文档支持的 `size` 字符串映射。图标素材仍先生成绿幕 spritesheet,再由 `platform-image` 绿幕去背并按 8 邻域连通域从上到下、从左到右拆分。成品图标图层写入 `assetKind="icon"`,角色图层写入 `assetKind="character"`。
|
||||
- 背景:图片画布需要一次生成多枚 UI 图标素材,并以一个透明图集回填画布;角色形象生成也需要和图标素材共用同一套图片模型选择、比例和大小口径。
|
||||
- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"`。`生成角色形象` 与 `生成图标素材` 均支持 VectorEngine `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`,用户在两类面板中切换过模型后下一次打开继续沿用上次模型。前端提交 `model`、`aspectRatio`、`imageSize`;后端不再按图标数量分 `512x512/1024x1024`,而是按模型归一尺寸:`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,把参考图写成 `inline_data`,并在 `generationConfig.imageConfig` 写入比例和大小,`0.5K` 传 `"512"`;`gpt-image-2` 无参考图走 generations,有参考图走 edits,按文档支持的 `size` 字符串映射。图标素材先生成绿幕 spritesheet,再由 `platform-image` 绿幕去背后作为 `assetKind="icon-spritesheet"` 图集回填;角色图层写入 `assetKind="character"`。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/vector_engine/*`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、图片画布技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p api-server editor_generation_dimensions_follow_model_options --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image nanobanana_generate_content --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
@@ -2876,7 +2917,7 @@
|
||||
## 2026-06-16 图片画布图片信息页不展示生图 Prompt
|
||||
|
||||
- 背景:图片画布中每张生成图片的信息页原来展示 `Prompt` 和复制 Prompt,但该字段可能是后端组装后的生图提示词,不适合作为用户可见的图片输入信息。
|
||||
- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色规范、常规参考图、图标规范和修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。
|
||||
- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色规范、常规参考图、图标规范和修改参考图等参考图卡片,并只允许“复制信息”复制当前可见字段。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout snapshot、图片画布技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` 应覆盖图片信息页无 `Prompt`、无 `复制Prompt`,并展示普通生成、角色生成、图标素材和修改结果的输入快照。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
@@ -2908,7 +2949,7 @@
|
||||
## 2026-06-17 图片画布生成器快照纳入画布布局
|
||||
|
||||
- 背景:生成占位图和生成器对话框里包含用户输入、参数、参考图、占位框位置和生成结果绑定,刷新后丢失会让已生成图片无法回到 Lovart 式跟随编辑状态。
|
||||
- 决策:生成器对象统一作为 `editor_canvas` 布局 JSON 的 `itemType: "generation-dialog"` 项保存,不新增表;成功生成后仍保留生成器快照和最后占位框位置,并通过 `generatedLayerId` 锚定到成品图层,渲染时不重复显示灰色占位框。图片类生成结果同步写入账号级素材库;视频结果当前只作为画布视频资源保存。
|
||||
- 决策:生成器对象统一作为 `editor_canvas` 布局 JSON 的 `itemType: "generation-dialog"` 项保存,不新增表;成功生成后仍保留生成器快照和最后占位框位置,并通过 `generatedLayerId` 锚定到成品图层,渲染时不重复显示灰色占位框。图片类和生成视频结果同步写入账号级素材库;生成视频素材当前没有独立 poster 字段,素材栏以视频图标叠层展示。
|
||||
- 影响范围:图片画布 layout 序列化 / hydrate、生成工作流、生成器渲染、项目自动保存、素材库回填和编辑器技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`、浏览器刷新 smoke。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
@@ -2952,7 +2993,8 @@
|
||||
## 2026-06-22 图片画布外部生成统一改走 worker 队列
|
||||
|
||||
- 背景:图片画布的图片、改图、图标素材、UI 素材提取、角色动作、视频和音频生成都可能长时间等待外部 provider;如果继续由 HTTP handler 同步执行,生产只能扩 API 进程,不能独立扩生成吞吐。
|
||||
- 决策:`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画板所有外部 provider 生成入口统一入 `external_generation_job`,job kind 使用 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。worker 成功后由后端写 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`;前端只轮询 BFF job 状态并重新读取项目快照,不从队列 payload 或本地临时状态重建完成图层。
|
||||
- 决策:`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画板所有外部 provider 生成入口统一入 `external_generation_job`,job kind 使用 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。worker 成功后由后端写 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`;前端只轮询 BFF job 状态并重新读取项目快照,不从队列 payload 或本地临时状态重建完成图层。
|
||||
- 2026-06-29 补充:手动点击图层“去除背景”也属于图片画布外部 provider 任务,`/api/editor/images/background-removals` 在 queue 模式只入队 `editor_background_removal`,worker 完成后用新 resource 原地替换目标 layer。任务列表只展示服务器 `external_generation_job` 返回的任务,禁止再用前端 local task 伪造抠图进度。
|
||||
- 补充:带 `dialogId` 的 `canvasCompletion` 必须读取后端当前 layout 中的最新 generation dialog placeholder;等待期间用户移动占位时,结果层要跟随最新占位。无 dialog 的重绘 / UI 素材提取等入口使用明确的右侧完成占位;生成器已删除时不把结果重新塞回画布。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_generation_queue.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`。
|
||||
- 验证方式:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
@@ -3763,7 +3805,7 @@
|
||||
|
||||
- 背景:后台需要默认进入运营数据面板,而不是服务 / 数据库状态页;看板要同时支持日 / 周 / 月筛选,并展示生产素材、泥点消耗、注册、访问和当前使用人数。
|
||||
- 决策:`apps/admin-web` 默认路由改为 `#dashboard`,原 `#overview` 保留为“服务总览”。Dashboard 统一通过 `GET /admin/api/dashboard` 读取 api-server 后端投影,不让前端绕过 BFF 直接访问 SpacetimeDB。后端不新增 SpacetimeDB schema,聚合现有 `editor_project_resource`、`profile_wallet_ledger`、`profile_dashboard_state`、`tracking_daily_stat` 和 `tracking_event`。
|
||||
- 2026-06-24 补充:Dashboard 日期控件改为起始日期 / 终止日期;`granularity=period` 使用 `startDate` / `endDate` 自定义闭区间,`day` / `week` / `month` 保留 `anchor` 兼容并由前端自动填充对应范围,页面周期切换展示为“本日 / 本周 / 本月 / 本时段”。
|
||||
- 2026-06-24 补充:Dashboard 日期控件改为起始日期 / 终止日期;`granularity=period` 使用 `startDate` / `endDate` 自定义闭区间,`day` / `week` / `month` 保留 `anchor` 兼容;前端展示“本日 / 本周 / 本月”快捷按钮,只修改起止日期并刷新,页面总计数据和时段数据分区展示。
|
||||
- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;总注册用户和新增用户数均来自 `profile_dashboard_state`,其中新增用户数按 `created_at` 落入当前时间窗统计;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计。
|
||||
- 影响范围:`/admin/api/dashboard`、`shared-contracts` admin DTO、`apps/admin-web` 默认路由和 Dashboard 页面、后台运营文档。
|
||||
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`、`npm run admin-web:typecheck`、`npx vitest run apps/admin-web/src/pages/AdminDashboardPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
## 记录格式
|
||||
|
||||
```md
|
||||
|
||||
## 问题标题
|
||||
|
||||
- 现象:看到什么错误或异常行为
|
||||
@@ -15,6 +14,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## React 测试因内部状态或实现细节正常重构就碎
|
||||
|
||||
- 现象:修改组件结构、按钮排序、图标库 class、提示文案或 hook 内部状态名后,React 测试大量失败,但真实用户流程和对外契约没有变化。
|
||||
- 原因:测试把 `data-testid` 仪表盘、`textContent` 拼接状态、完整对象 / 数组顺序、图标 class 或长文案当成契约;这些断言绑定的是实现形状,不是用户行为或稳定边界。
|
||||
- 处理:按 `React 组件测试准则` 重写到更稳定的层级。用户流程测试断言 role / label / URL / 弹窗 / callback;hook 逻辑用 `renderHook` 直接验证公开返回契约;DTO / payload 使用关键字段或 `expect.objectContaining(...)`。只有产品明确要求的可访问语义、固定顺序或渲染边界才保留精确断言。
|
||||
- 验证:运行触达文件的定向 `vitest`,必要时追加 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联:`docs/technical/【前端测试】React组件测试准则-2026-06-26.md`、`src/components/image-editor/useCanvasGenerationDialogs.test.tsx`、`src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`。
|
||||
|
||||
## 图片画布素材库删除要匹配 sourceResourceId
|
||||
|
||||
- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。
|
||||
@@ -31,14 +38,22 @@
|
||||
- 验证:`creationShowcaseModel.test.ts` 覆盖同源同媒体副本只展示原件;`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖拖拽生成素材到画布时继续提交 `sourceResourceId`;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 确认后端资源复用 / 精选过滤逻辑可编译。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`src/components/creation-home/creationShowcaseModel.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。
|
||||
|
||||
## 陶泥儿精选作者丢失先查公开资源 owner 字段
|
||||
## 陶泥儿精选作者丢失先查公开作者展示字段
|
||||
|
||||
- 现象:`/creation` 的 `陶泥儿精选` 卡片和预览弹窗中,素材下方作者 id 消失或只显示占位。
|
||||
- 原因:精选数据源来自公开 `editor_project_resource` 快照;如果 SpacetimeDB read model、`spacetime-client` mapper 或 `api-server` payload 任一层漏传 `owner_user_id` / `ownerUserId`,前端作者兜底就没有真实值可显示。
|
||||
- 处理:`EditorProjectResourceSnapshot`、`EditorProjectResourceRecord` 和 `EditorProjectResourcePayload` 需要一路保留 owner 字段;前端 `creationShowcaseModel` 在没有作者昵称字段时读取 `ownerUserId` 作为作者兜底。
|
||||
- 验证:`creationShowcaseModel.test.ts` 覆盖 owner id 作者兜底;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留 `ownerUserId`;后端改 read model 后运行 `npm run spacetime:generate`、`cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml` 和 `npm run check:spacetime-schema`。
|
||||
- 现象:`/creation` 的 `陶泥儿精选` 卡片和预览弹窗中,素材下方作者名消失、只显示占位,或错误显示内部用户 ID。
|
||||
- 原因:精选数据源来自公开 `editor_project_resource` 快照;如果 SpacetimeDB read model、`spacetime-client` mapper 或 `api-server` payload 任一层漏传 `authorDisplayName` / `display_name` 或 `authorPublicUserCode` / 陶泥号,前端没有可展示的公开作者字段。`owner_user_id` / `ownerUserId` / `user_id` 是内部归属字段,不是公开作者名兜底。
|
||||
- 处理:`EditorProjectResourceSnapshot`、`EditorProjectResourceRecord` 和 `EditorProjectResourcePayload` 需要一路保留公开作者展示字段;前端 `creationShowcaseModel` 优先显示 `authorDisplayName` / `display_name`,没有展示名时显示 `authorPublicUserCode` / 陶泥号,绝不能兜底到内部 `ownerUserId` / `user_id`。
|
||||
- 验证:`creationShowcaseModel.test.ts` 覆盖展示名优先、陶泥号兜底和内部 owner/user id 不展示;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留公开作者字段;若改动 SpacetimeDB read model,再运行 `npm run spacetime:generate`、`cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml` 和 `npm run check:spacetime-schema`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/mapper/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/components/creation-home/creationShowcaseModel.ts`。
|
||||
|
||||
## 陶泥儿精选瀑布流变宽先查 multi-column 容器宽度
|
||||
|
||||
- 现象:release `/creation` 桌面端精选卡片明显变宽,第三列被裁到屏幕外,页面内部可横向滑动;dev 看起来正常。
|
||||
- 原因:精选瀑布流使用 `column-count`,当它作为 grid item 时如果没有显式 `width: 100%` / `min-width: 0`,Chrome 会用多列内容的 intrinsic width 反向撑开 grid track。线上实测 1920 视口下 section 为 `1296px`,waterfall 被撑到约 `2048px`,单卡宽约 `672px`。
|
||||
- 处理:保留 multi-column 瀑布流时,`.creation-landing__asset-waterfall` 必须显式约束 `width: 100%` 和 `min-width: 0`;不要只看单张图片天然尺寸或改卡片宽度。
|
||||
- 验证:Playwright / CSSOM 检查 `.creation-landing__section`、`.creation-landing__asset-waterfall`、首张 `.creation-landing__asset-card` 的 `getBoundingClientRect()`,waterfall 宽度应等于 section 宽度。
|
||||
- 关联:`src/index.css`、`src/components/creation-home/CreationLandingView.tsx`。
|
||||
|
||||
## 画板外部生成排队超时不是失败
|
||||
|
||||
- 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。
|
||||
@@ -47,6 +62,14 @@
|
||||
- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。
|
||||
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`。
|
||||
|
||||
## 画板参考图 objectKey 必须先做归属校验
|
||||
|
||||
- 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。
|
||||
- 原因:Data URL 参考图可以直接解析,但 objectKey 是服务端私有对象引用;只校验 generated 前缀、mime 和大小不能证明它属于当前账号。
|
||||
- 处理:所有编辑器参考图入口统一走 `parse_editor_reference_image(state, owner_user_id, source)`;objectKey 分支必须先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key,再读取 OSS。快速编辑和图标素材额外参考图也必须真实传到 provider,不只写 metadata。
|
||||
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入快速编辑 / 图标生成请求。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。
|
||||
|
||||
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
|
||||
|
||||
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
|
||||
@@ -79,6 +102,14 @@
|
||||
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasExportModel.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 图片画布序列帧播放不要复用普通图片淡入样式
|
||||
|
||||
- 现象:角色动作序列帧播放时看起来像每帧之间在渐变或闪烁。
|
||||
- 原因:序列帧播放器每帧切换可低至 40ms,默认约 125ms 一帧;如果帧 `<img>` 复用普通图片的 `image-canvas-editor__layer-image--loading/--loaded`,其中 `opacity 180ms ease` 会跨过下一帧切换,形成类似交叉淡入淡出的视觉。
|
||||
- 处理:`ImageCanvasImageSequenceFrame` 只使用序列帧专属 class,帧显隐用同步 `opacity` 硬切,并显式 `transition: none`;保留“下一帧未加载时继续显示上一帧”的 readiness gate。
|
||||
- 验证:`ImageCanvasWorldView.test.tsx` 应断言序列帧 `<img>` 不带普通图片 loading/loaded class,且 style 中 `transition` 为 `none`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/index.css`、`src/components/image-editor/ImageCanvasWorldView.test.tsx`。
|
||||
|
||||
## Vidu 文生音频线上网关可能要求 sound 字段
|
||||
|
||||
- 现象:画板点击 `生成游戏音效` 后,请求返回 `Failed to deserialize the JSON body into the target type: missing field sound`。
|
||||
@@ -127,6 +158,14 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasInteractionModel.test.ts`,并在多素材画布拖拽时确认参考线仍能命中邻近图层且 pointermove 不再明显掉帧。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasInteractionModel.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片画布视口拖动卡顿先查自动保存和小地图合帧
|
||||
|
||||
- 现象:素材多或序列帧多时,拖动小地图视口框或手型平移明显卡顿,像是接口慢或 CSS 动画掉帧,但网络请求不一定异常。
|
||||
- 原因:`pointermove` 高频修改 `viewport` 会触发画布重渲染、小地图模型重算和工程持久化 effect;持久化链路会同步 `serializeCanvasLayout`、`JSON.stringify` 并写 sessionStorage。远端 PATCH 有防抖也挡不住本地同步缓存写入。
|
||||
- 处理:把 viewport 拖动标记为临时交互;拖动中只更新画布显示,不触发项目保存、session cache 写入或封面快照上传,`pointerup` / `pointercancel` 后保存最终 viewport。小地图拖动的 `updateViewportFromMinimapDrag` 必须用 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧。
|
||||
- 验证:`npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx --reporter verbose` 应覆盖小地图拖动合帧、平移 / 小地图 viewport 交互边界,以及拖动期间不写 sessionStorage / 不调用 `saveEditorProjectLayout`。
|
||||
- 关联:`src/components/image-editor/useImageCanvasViewportControls.ts`、`src/components/image-editor/useImageCanvasStageInteractions.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片编辑器宣发素材生成器刷新后不要丢快照
|
||||
|
||||
- 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。
|
||||
@@ -139,7 +178,7 @@
|
||||
|
||||
- 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`。
|
||||
- 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations` 的 `referenceImageSrcs`;默认示例图和部分持久化图层的 `src` 是 `/creation-type-references/*.webp`、`/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`。
|
||||
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 走 `/api/assets/read-bytes` 避免 CORS,普通 public 路径直接 fetch。
|
||||
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 先走 `/api/assets/read-url` 换签后由浏览器直读 OSS,直读失败时才 fallback 到 `/api/assets/read-bytes`,普通 public 路径直接 fetch。
|
||||
- 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`。
|
||||
- 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -183,6 +222,14 @@
|
||||
- 验证:Network 中 `/api/editor/projects*`、`PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/common/PlatformMediaFrame.tsx`、`src/services/assetReadUrlService.ts`。
|
||||
|
||||
## 图片画布项目封面上传失败要有本地展示兜底
|
||||
|
||||
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
|
||||
- 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。
|
||||
- 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。
|
||||
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
|
||||
- 关联:`src/services/image-editor/editorProjectCoverCache.ts`、`src/components/project/ProjectGalleryView.tsx`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。
|
||||
|
||||
## 图片画布框选预览要复用源图换签缓存
|
||||
|
||||
- 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。
|
||||
@@ -247,6 +294,14 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationPlacementModel.ts`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 图片画布生成类 composer 打开后必须自动进入可见安全区
|
||||
|
||||
- 现象:生成器、快速编辑、裁扩或角色动作面板打开后,面板可能在当前画布视口外,或被底部工具栏 / 左下 dock 盖住,用户只看到一部分甚至完全看不到输入框。
|
||||
- 原因:placement 只负责选择画布世界坐标里的占位落点,面板实际 DOM 宽高、`translateX(-50%)`、移动端 fixed 样式和工具栏覆盖区域没有反向修正 viewport。
|
||||
- 处理:所有画布内 composer / 面板渲染后统一走 `resolveViewportForOverlayVisibility(...)`,用真实 DOM 矩形和工具栏安全边界只平移 viewport;新增入口不要在各自按钮 handler 里写独立偏移。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts`、`src/components/image-editor/useImageCanvasGenerationSurface.tsx`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 图片画布图片改造也必须创建独立生成器占位
|
||||
|
||||
- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。
|
||||
@@ -270,6 +325,7 @@
|
||||
- 处理:Windows 本地 dev 脚本应把 `RUSTC_WRAPPER` 和 `CARGO_BUILD_RUSTC_WRAPPER` 显式设为空字符串,让 Cargo 覆盖项目配置并直连真实 rustc;Linux 保持 `/usr/bin/env` 绕过 sccache。
|
||||
- 验证:`npm run test -- scripts/dev.test.ts -t "Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache"`,并用 `npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。
|
||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Pingora 直连 80/443 不能只改 env
|
||||
|
||||
- 现象:`/etc/genarrative/pingora-gateway.env` 已把 `GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN` / `HTTP_REDIRECT_LISTEN` 改到 `0.0.0.0:443` / `0.0.0.0:80`,但 `genarrative-pingora-gateway.service` 启动失败,日志出现低端口绑定权限错误。
|
||||
@@ -343,6 +399,14 @@
|
||||
- 验证:重启 worker 后日志应先出现“非 HTTP 进程跳过 SpacetimeDB 认证快照恢复”,随后出现 `external generation worker 已启动`;同一时间窗口不应再因为 `export_auth_store_snapshot_from_tables` 缺表而阻止 job claim。HTTP `api-server` 的认证恢复日志和 503 降级语义保持不变。
|
||||
- 关联:`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/external_generation_worker_controller.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 本地旧 external-generation-worker 会抢队列并暴露成 procedure 超时
|
||||
|
||||
- 现象:角色 / 画布生成的外部 provider 与 OSS 上传已成功,但 worker 写回 `editor_project_resource` 等业务资源时报 `SpacetimeDB procedure 调用超时`,日志里可能还能看到旧 worker 二进制对 procedure 返回值做 BSATN 反序列化失败。
|
||||
- 原因:本地 `npm run dev` / `npm run dev:api-server` 默认 `GENARRATIVE_PROCESS_ROLE=all`,会自己消费队列;如果之前手动启动的同仓库、同 database `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程没有退出,旧二进制会继续 claim 新 job,schema / binding 已更新的当前进程反而没有拿到这次任务。
|
||||
- 处理:Linux 本地默认 `all` 角色启动前,`scripts/dev.mjs` 会扫描同仓库、同 SpacetimeDB server / database、同 `server-rs/target/debug/api-server` 的遗留 `external-generation-worker` 并停止;显式 `GENARRATIVE_PROCESS_ROLE=api` 做生产式拆分验证时不清理独立 worker。
|
||||
- 验证:`ps -eo pid,ppid,lstart,cmd | rg 'server-rs/target/debug/api-server'` 只应看到当前 `all` 或显式拆分下预期的进程;`/healthz` 和 `/readyz` 成功后,生成 job 应由当前进程消费并把业务资源写回。
|
||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 外部生成 worker 业务写回必须同事务校验 lease guard
|
||||
|
||||
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
|
||||
@@ -488,6 +552,14 @@
|
||||
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_send_retry_policy -- --nocapture`、`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。
|
||||
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 跳一跳 Three.js 地块 UV 顶面要映射到 Z 轴
|
||||
|
||||
- 现象:跳一跳地块使用六面 UV 贴图后,看起来像贴图位置贴歪,顶面显示侧面纹理,或者旧单张地块图被拉到立方体多个面上。
|
||||
- 原因:运行态以 `z` 作为立方体竖直高度和相机下压方向,但 Three.js `BoxGeometry` / `RoundedBoxGeometry` 的默认材质 group 顺序把 `+Y` 当 top;如果直接按 `right / left / top / bottom / front / back` 写材质,玩法逻辑的 `top` 会贴到侧面。旧作品没有完整 `faceAssets` 时,把单张旧贴图强行作为 3D 六面 fallback 也会被误认为 UV 贴歪。
|
||||
- 处理:Three 平台层只在 `tileAssets[].faceAssets` 六面完整时启用;材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom`,把逻辑 `top` 映射到 `+Z` 顶面,并按每面 UV 方向做翻转校正;旧单图作品继续走 DOM 图片 / 原型兜底层。
|
||||
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖材质顺序、UV 翻转和旧单图不启用 Three 贴面;`cargo test -p api-server jump_hop_tile_atlas_slicing --manifest-path server-rs/Cargo.toml -- --nocapture` 应覆盖 UV 安全边裁切。
|
||||
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
|
||||
|
||||
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
|
||||
@@ -734,11 +806,11 @@
|
||||
- 验证:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。
|
||||
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 图片画布图标素材切片不要把断开的高光阴影当独立图标
|
||||
## 图片画布 UI 提取素材切片不要把断开的高光阴影当独立图标
|
||||
|
||||
- 现象:图片画布生成图标素材或提取 UI 素材后,右侧素材库出现很小的废图;主体图标的阴影、反光、高光或小装饰不完整。
|
||||
- 现象:图片画布提取 UI 素材后,右侧素材库出现很小的废图;主体图标的阴影、反光、高光或小装饰不完整。
|
||||
- 原因:图标 spritesheet 切片按 alpha 连通域识别素材,模型常把软阴影、高光、小星星等画成与主体断开的透明块;如果直接逐连通域出图,小碎片会抢占图标顺序,主体也会缺边缘装饰。
|
||||
- 处理:在 `platform-image` 的 `sheet.rs` 里先合并靠近主体的辅助连通域,再过滤孤立小碎片,最后给裁剪框保留安全 padding。不要在前端素材卡或画布层里修已经切坏的 PNG。
|
||||
- 处理:在 `platform-image` 的 `sheet.rs` 里先合并靠近主体的辅助连通域,再过滤孤立小碎片,最后给裁剪框保留安全 padding。不要在前端素材卡或画布层里修已经切坏的 PNG。`生成图标素材` 入口只回填扣绿后的整张图集,不再拆分独立图标。
|
||||
- 验证:`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml` 覆盖断开的高光合并和孤立小碎片过滤;调用方补跑 `cargo test -p api-server editor_icon --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
@@ -1546,7 +1618,6 @@
|
||||
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
|
||||
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
|
||||
|
||||
|
||||
## 本地短信 smoke 先确认 SMS provider
|
||||
|
||||
- 现象:浏览器里短信验证码发送成功,但提交 `123456` 仍然报验证码错误,或者短信登录后又回到未登录态。
|
||||
@@ -1554,6 +1625,7 @@
|
||||
- 处理:本地只做 UI / 账号链路 smoke 时,把 `.env.local` 显式设为 `SMS_AUTH_PROVIDER=mock` 且配置 `SMS_AUTH_MOCK_VERIFY_CODE=123456`,然后重启 `npm run dev` 或 `npm run dev:api-server`。要做真实短信联调时,再切回 `SMS_AUTH_PROVIDER=aliyun` 并重启。
|
||||
- 验证:`POST /api/auth/phone/send-code` 应返回 `providerRequestId=mock-request-id`;`POST /api/auth/phone/login` 用 `123456` 应返回 `200` 且 `user.loginMethod=phone`。浏览器侧短信登录成功后,会先进入邀请码弹窗或我的页面,不应再提示“验证码错误”。
|
||||
- 关联:`scripts/dev-utils.mjs`、`scripts/dev-utils.test.ts`、`scripts/dev.mjs`、`server-rs/crates/api-server/src/config.rs`。
|
||||
|
||||
## 手机验证码登录成功后又瞬间回到未登录
|
||||
|
||||
- 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。
|
||||
@@ -1951,9 +2023,9 @@
|
||||
|
||||
## 抓大鹅难度配置的物品种类和消除次数必须分离
|
||||
|
||||
- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;或者把第 11 到 20 个物品持久化为第 11 到 20 行,触发“系列素材图集持久化的行列索引必须落在 n*n 范围内”。
|
||||
- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;或者把第 11 到 20 个物品持久化为第 11 到 20 行,触发“系列素材图集持久化的行列索引必须落在 n\*n 范围内”。
|
||||
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
|
||||
- 处理:生成和持久化固定使用 20 个物品素材;运行态物品种类口径为轻松 3、标准 9、进阶 15、硬核 20,历史 `clearCount=20` 且难度为硬核的运行态仍可升为 21 组三消,但类型池不超过 20。10*10 sheet 每行两种物品、每种五个形态,持久化行列为 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1`。发布前按 `image_ready` 且有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
|
||||
- 处理:生成和持久化固定使用 20 个物品素材;运行态物品种类口径为轻松 3、标准 9、进阶 15、硬核 20,历史 `clearCount=20` 且难度为硬核的运行态仍可升为 21 组三消,但类型池不超过 20。10*10 sheet 每行两种物品、每种五个形态,持久化行列为 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1`。发布前按 `image_ready`且有`imageViews[]`或`imageSrc/imageObjectKey`的生成素材数量阻断不足难度;试玩不阻断,但通过`itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
|
||||
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`。
|
||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
|
||||
@@ -2237,10 +2309,10 @@
|
||||
|
||||
## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影
|
||||
|
||||
- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
|
||||
- 现象:按住蓄力时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
|
||||
- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。
|
||||
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
|
||||
- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
|
||||
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;它只作为调参验证层随按下显示、松手或取消隐藏,不参与后端裁决和作品配置;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
|
||||
- 验证:半程蓄力时辅助点应落在当前地块和目标地块之间,完整蓄力时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
|
||||
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`。
|
||||
|
||||
## 跳一跳长按蓄力不能再消费拖拽方向
|
||||
@@ -2514,6 +2586,7 @@
|
||||
- 处理:开局和补牌后的重排必须先排除现成消除,再用真实交换 / 落位模拟判断是否会产生新消除;`1x2` 永远不进入半锁定组,半锁定只允许 `1x3`、`2x2`、`2x3`。
|
||||
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 与 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml -- --nocapture` 通过后,开局盘面不应直接出现 completed group。
|
||||
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
|
||||
|
||||
## 推荐页作品 key 漏玩法会导致运行内容和标题作者错位
|
||||
|
||||
- 现象:移动端推荐页进入跳一跳或敲木鱼等作品时,游戏运行内容已经切到当前作品,但下方标题、作者和头像仍显示第一条拼图或其它推荐作品。
|
||||
@@ -2526,8 +2599,8 @@
|
||||
|
||||
- 现象:跳一跳松手后如果后端很快返回下一帧 run,地块窗口会立刻前移,角色翻腾动画看起来像没播放;若同时刷新图片资产,还可能被误认为地块频闪。
|
||||
- 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。
|
||||
- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前显示窗口内飞向前端预测真实落点;视觉预测必须用当前显示窗口的 current/next 地块作为方向来源,不能拿已经提前返回的后端新 run 目标配旧窗口角色,否则下一跳会朝实际目标反方向飞。飞行动画完成后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。成功后的角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实偏移,不要吸附到目标地块中心。推进期间地块 DOM 层和 DOM 角色层必须统一包在同一个 camera layer 下移动,旧当前地块先跟随相机偏移离开主视野,之后只保留在屏幕后方;不要给旧地块加独立向上 / 向下飞走 keyframes,也不要因为旧地块还在保留列表里阻塞下一跳。玩家继续向前跳时,已完成旧地块继续被新的相机推进自然带离屏幕,超过离屏阈值后销毁。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,并以旧窗口真实落点和新窗口真实落点为锚点,避免先横向瞬切居中再纵向推进;运行态相机层当前为约 `1.3x` 近距缩放。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。相机推进期间角色自身也不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回;角色推进期只允许 transform / opacity transition。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。
|
||||
- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,成功落地保留真实落点偏移,动画结束后进入 `data-platform-advancing=true`,DOM 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态角色 transition 不包含 `left/top`、旧地块没有独立 `jump-hop-platform-exit-drift` keyframes 且下一跳不会被旧地块保留态阻塞。
|
||||
- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前显示窗口内飞向前端预测真实落点;视觉预测必须用当前显示窗口的 current/next 地块作为方向来源,不能拿已经提前返回的后端新 run 目标配旧窗口角色,否则下一跳会朝实际目标反方向飞。飞行动画完成后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。成功后的角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实偏移,不要吸附到目标地块中心。推进期间地块层和角色层必须统一包在同一个 camera layer 下移动,旧当前地块先跟随相机偏移离开主视野,之后只保留在屏幕后方;不要给旧地块加独立向上 / 向下飞走 keyframes,也不要因为旧地块还在保留列表里阻塞下一跳。玩家继续向前跳时,已完成旧地块继续被新的相机推进自然带离屏幕,超过离屏阈值后销毁。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,并以旧窗口真实落点和新窗口真实落点为锚点,避免先横向瞬切居中再纵向推进;运行态相机层当前为约 `1.3x` 近距缩放。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。Three.js Sprite 角色与平台共用同一套屏幕坐标投影,DOM 角色只作为 WebGL 或贴图加载失败 fallback;DOM fallback 在相机推进期间自身不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。
|
||||
- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,成功落地保留真实落点偏移,动画结束后进入 `data-platform-advancing=true`,角色 Three 帧沿真实预测落点插值并保留飞行弧线,DOM fallback 角色与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态 DOM fallback 角色 transition 不包含 `left/top`、旧地块没有独立 `jump-hop-platform-exit-drift` keyframes 且下一跳不会被旧地块保留态阻塞。
|
||||
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`server-rs/crates/module-jump-hop/src/application.rs`。
|
||||
|
||||
## 跳一跳相机推进不要让地块图片回退到原型方块
|
||||
@@ -2541,19 +2614,43 @@
|
||||
## 跳一跳 Three.js 平台层不能左右镜像 DOM 坐标
|
||||
|
||||
- 现象:视觉上下一块地块在角色右侧,但蓄力引导和角色飞行动画朝左侧;后端回包后地块窗口又闪现摆回正确位置,像是先按反方向飞、再由快照刷新纠正。
|
||||
- 原因:Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和 DOM 角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。
|
||||
- 处理:Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。DOM 角色、蓄力引导、落点预测和 Three 平台层必须共用同向屏幕坐标。
|
||||
- 原因:Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。
|
||||
- 处理:Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。Three.js Sprite 角色、DOM fallback 角色、蓄力引导、落点预测和 Three 平台层必须共用同向屏幕坐标。
|
||||
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖 `JUMP_HOP_THREE_CAMERA_UP_Y=1`,并断言 Three 投影与 DOM 屏幕坐标同向。
|
||||
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。
|
||||
|
||||
## 跳一跳 Three.js 角色不要被地块透明排序压住
|
||||
|
||||
- 现象:角色已经进 Three.js 场景后,看起来像落在地块内部或只露出头,角色没有站在方块顶面上。
|
||||
- 原因:地块材质如果设置 `transparent=true` 会进入 Three.js 透明物体排序队列,可能在 Sprite 角色之后绘制;同时角色脚点如果仍用固定 Z 高度,遇到标准 `1x1x1` 方块放大后的当前块时会落到顶面后方或方块体内。
|
||||
- 处理:地块贴图材质只使用 `alphaTest` 裁掉透明边,不放入透明材质队列;角色 Sprite 的 `renderOrder` 必须高于平台 mesh,脚点 Z 高度按最近方块半高加顶面偏移计算,确保角色站在当前方块顶面上方。
|
||||
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖平台材质不透明队列、角色 renderOrder 高于地块、角色脚点高度高于方块顶面。
|
||||
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
||||
|
||||
## 跳一跳立方体贴图不要走透明主体切片
|
||||
|
||||
- 现象:水果等主题生成成功后,运行态地块看起来像薄的纯水果 PNG、果切贴纸、透明 cutout;或者反过来六个面都是同一张平铺果皮 / 果肉材质,无法组合成方块苹果 / 方块香蕉这类完整主题对象表达。
|
||||
- 原因:跳一跳地板已经改为 Three.js 标准 `1x1x1` 等比极小倒角立方体复用几何体,运行态视角固定为近距相机和 45° 下压视角;image2 应生成 `1024x1536` 的 18 个 cube object UV unwrap,每个大单元内的 top/front/right/back/left/bottom 六面要共同包装同一个主题物体。只强调 full-bleed 容易让水果主题退化成果皮、果肉、叶脉等表面纹理;如果仍把一张图贴给六个面,模型也不需要理解正反和跨面连续特征。旧切图链路若把洋红 key 转 alpha、裁边、只保留最大 alpha 连通主体并补透明安全边,会把整格贴图重新抠成苹果 / 香蕉 / 果切等居中主体,贴到立方体上后四角和侧面都变透明。
|
||||
- 处理:跳一跳地板图集 prompt 固定要求 `cube object UV unwrap atlas / 立方体主题物体六面展开图集`,一张图只生成 18 个大单元,每个大单元固定 `4列*3行` UV 网:第 1 行第 2 列 top,第 2 行 left/front/right/back,第 3 行第 2 列 bottom;水果主题要明确生成能一眼说出名称的方块苹果、方块香蕉、方块橙子、方块西瓜等可识别对象,并要求果柄叶片、剥皮条带、放射切面、红瓤黑籽等身份特征跨面连续。禁止自然圆形水果、自然长条香蕉、非方块化完整水果、果切小贴纸、居中小物体、透明背景和留白,同时也禁止“单纯平铺材质 / 抽象纹理 / 只铺主题颜色 / 纯果皮材质 / 纯果肉纹理 / 纯叶脉纹理”。后端按 3x6 大单元和 4x3 UV 网切出 108 张 `256x256` 不透明面贴图,不再调用透明化、最大 alpha 连通主体保留或透明补边。洋红 `#FF00FF` 只作为图集安全缝 / UV 空位 / 外圈 key 色,裁切后若仍有极少残留则转成不透明材质底色;绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色必须完整保留。
|
||||
- 处理:跳一跳地板图集 prompt 固定要求 `cube object UV unwrap atlas / 立方体主题物体六面展开图集`,一张图只生成 18 个大单元,每个大单元固定 `4列*3行` UV 网:第 1 行第 2 列 top,第 2 行 left/front/right/back,第 3 行第 2 列 bottom;水果主题要明确生成能一眼说出名称的方块苹果、方块香蕉、方块橙子、方块西瓜等可识别对象,并要求果柄叶片、剥皮条带、放射切面、红瓤黑籽等身份特征跨面连续。禁止自然圆形水果、自然长条香蕉、非方块化完整水果、果切小贴纸、居中小物体、透明背景和留白,同时也禁止“单纯平铺材质 / 抽象纹理 / 只铺主题颜色 / 纯果皮材质 / 纯果肉纹理 / 纯叶脉纹理”。后端先对图集做洋红去背,再以 `jump_hop_atlas_slicing.rs` 的自适应 blob+gradient 算法检测 3x6 大单元和单元内六面区域,输出 108 张 `256x256` 不透明面贴图;固定 3x6 / 4x3 切片只作为测试对照和必要 fallback 参考,不作为优先生图切图路径。洋红 `#FF00FF` 只作为图集安全缝 / UV 空位 / 外圈 key 色;绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色必须完整保留。
|
||||
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳 UV unwrap prompt、18 个大单元、108 张不透明面贴图、绿色 / 白色材质不被透明化、洋红 key 残留不作为透明洞;前端 `JumpHopRuntimeShell` 测试覆盖新 UV 资产会解析六张面贴图,旧单贴图资产仍可 fallback。
|
||||
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/jump_hop.rs`。
|
||||
|
||||
## 跳一跳 UV 图集切片要防贴边矩形 u32 中间溢出
|
||||
|
||||
- 现象:跳一跳草稿在背景、返回按钮和地板图集 image2 都生成成功后,前端报“执行跳一跳共创操作失败”,Vite 代理日志出现 `socket hang up`,后端日志出现 `jump_hop_atlas_slicing.rs` 内 `attempt to subtract with overflow`。
|
||||
- 原因:blob gradient 切片的 histogram 最大不透明矩形在计算顶部坐标时写成 `by0 + ly - sh + 1`。当模型输出的 UV 面内容刚好贴到 cell 顶边,数学结果本应是 0,但 `u32` 会先执行中间步骤 `0 - 1` 并在 debug 运行时 panic。
|
||||
- 处理:顶部坐标先在局部坐标内用 `ly.saturating_add(1).saturating_sub(sh)` 计算,再加 block 偏移;不要恢复成连写减法。补充贴顶两行不透明矩形回归测试,保证贴边 UV 面不会打崩共创接口。
|
||||
- 验证:`RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop_atlas_slicing::tests::max_opaque_rect_handles_content_touching_top_edge`;整组再跑 `RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop`。
|
||||
- 关联:`server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs`、`server-rs/crates/api-server/src/jump_hop.rs`。
|
||||
|
||||
## 跳一跳生图切图主路径不要绕过自适应图集切片
|
||||
|
||||
- 现象:拉取 `fix/jump-hop-image-gen` 后,如果又把生成链路切回旧固定坐标裁切,容易和该分支解决的 AI 图集偏移、间距不均、UV 面位置漂移问题互相抵消,导致新生图链路的实际收益无法验证。
|
||||
- 原因:当前跳一跳 image2 prompt 仍要求 3x6 大单元和 4x3 UV 子网格,这是给模型和算法的结构约束;真实生产切图由自适应 `SeedRefinement + blob + gradient + max opaque rectangle` 链路消化 AI 输出偏差。固定网格切片只能验证理想图集,不适合覆盖新分支的主修复。
|
||||
- 处理:生产生成链路优先调用 `slice_tile_atlas_adaptive(...)`;旧固定 `slice_jump_hop_tile_atlas(...)` 只保留为对照测试、实验和必要 fallback 参考。若自适应切图出现具体误切,应优先修正自适应模块的边界检测、主 blob、透明/安全色处理和回归测试,而不是直接全局切回固定坐标。
|
||||
- 验证:新生成作品下载 `tile-01-top/front/right` 等面贴图时,单图应基本充满对应主题面内容,不应出现大块空背景、相邻面混入或纯色原型 cube;同时执行 `RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop_atlas_slicing -- --nocapture`。
|
||||
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
||||
|
||||
## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码
|
||||
|
||||
- 现象:本地用 `@'...'@ | node -` 跑 VectorEngine / gpt-image-2 live 验证时,`request.json` 里的中文 prompt 可能全部变成 `????`,生成图会变成完全不相关的 UI、建筑海报或其它随机内容,容易误判为模型不服从提示词。
|
||||
|
||||
@@ -64,7 +64,24 @@
|
||||
└─ manifest.txt
|
||||
```
|
||||
|
||||
角色动作单图层导出时,直接生成独立 ZIP:
|
||||
角色动作单图层右键“导出为”展开二级菜单:
|
||||
|
||||
- `序列帧导出(zip)` 生成普通序列帧 ZIP,保留完整 `frames/`,并由前端把成功读取的帧拼成 `preview.gif`。
|
||||
- `Spine 导出(zip)` 生成 Spine JSON ZIP,保留 `frames/`,并附带 `skeleton.json`。
|
||||
|
||||
普通序列帧 ZIP 结构:
|
||||
|
||||
```text
|
||||
角色动作-Sequence.zip
|
||||
├─ frames/
|
||||
│ ├─ frame-01.png
|
||||
│ └─ frame-02.png
|
||||
├─ preview.gif
|
||||
├─ metadata.json
|
||||
└─ manifest.txt
|
||||
```
|
||||
|
||||
Spine JSON ZIP 结构:
|
||||
|
||||
```text
|
||||
角色动作-SpineJSON.zip
|
||||
@@ -149,9 +166,10 @@ assetObjectId > objectKey > sourceAssetId > src
|
||||
4. 对每个素材源读取 Blob:
|
||||
- `data:image/...` 直接转换为 Blob。
|
||||
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
|
||||
- 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。
|
||||
- 私有 generated / OSS 素材先走 `/api/assets/read-url` 换签并由浏览器直接 `fetch` OSS 签名 URL;只有换签或 OSS 字节读取失败时,才 fallback 到同源 `/api/assets/read-bytes`。
|
||||
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。
|
||||
- `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。
|
||||
- 画布素材 ZIP 内的 `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。
|
||||
- 单图层普通序列帧导出写入 `preview.gif`,由前端基于成功读取的帧生成动画预览,不依赖压缩包读取时再临时播放 PNG。
|
||||
- 拉取失败时记录失败项,不中断整个导出。
|
||||
5. 使用 `JSZip` 写入 `images/`、`metadata.json` 和 `manifest.txt`。
|
||||
6. `zip.generateAsync({ type: 'blob' })` 生成文件。
|
||||
@@ -176,6 +194,8 @@ assetObjectId > objectKey > sourceAssetId > src
|
||||
- 图层的锁定、翻转、分组状态写入元数据。
|
||||
- `data:image` 图片不经过网络请求即可导出。
|
||||
- URL 图片 fetch 失败时不中断其他素材导出。
|
||||
- 动作图层右键菜单把 `导出为` 作为一级入口,二级菜单提供 `序列帧导出(zip)` 和 `Spine 导出(zip)`。
|
||||
- 单图层普通序列帧 ZIP 包含 `frames/`、`preview.gif`、`metadata.json` 和 `manifest.txt`,且不包含 `skeleton.json`。
|
||||
- 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。
|
||||
|
||||
## Spine JSON 验证器
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,54 @@
|
||||
# React 组件测试准则
|
||||
|
||||
更新时间:`2026-06-26`
|
||||
|
||||
## 背景
|
||||
|
||||
当前前端测试已经覆盖大量 React 组件、hook 和页面流程,但部分用例把组件内部状态、DOM 探针、按钮顺序、图标 class 或完整文案当成契约。这样的测试能快速发现改动,却也会让 UI 结构、交互文案和实现拆分变得很难迭代。
|
||||
|
||||
后续新增或重写 React 测试时,默认按本准则选择断言层级。已有测试不要求一次性批量迁移;当某个测试因正常重构频繁破碎,或本次任务正好修改该区域,就顺手收紧到稳定契约。
|
||||
|
||||
## 分层口径
|
||||
|
||||
1. 用户流程测试验证用户可感知结果。
|
||||
|
||||
优先使用 Testing Library 的 `userEvent`、role、label、可见弹窗、URL、提交结果、错误提示和外部 callback。测试名应描述业务行为,不描述组件内部状态机步骤。
|
||||
|
||||
2. 稳定契约测试验证对外边界。
|
||||
|
||||
对请求 DTO、callback payload、路由变化、持久化边界和后端回包映射,使用关键字段断言或 `expect.objectContaining(...)`。对象仍在演化时,不要断言完整对象、完整数组顺序或所有默认字段。
|
||||
|
||||
3. 模型和 hook 测试验证纯逻辑。
|
||||
|
||||
复杂状态机优先沉到 model 或 hook 的公开返回契约中测试。hook 测试使用 `renderHook` 直接调用公开方法和读取公开状态,不额外制造 `data-testid` 仪表盘组件来拼接内部字段。
|
||||
|
||||
## 避免的写法
|
||||
|
||||
- 不为了读取内部状态新建测试专用 DOM,例如 `<span data-testid="active-id">`、`textContent` 拼接内部数组或状态名。
|
||||
- 不把图标实现、第三方库 class、DOM 层级、完整按钮顺序当成稳定契约,除非产品明确要求该顺序或可访问语义本身就是行为。
|
||||
- 不用精确长文案锁死可变提示词、分享文案、错误文案或 UI 标签;需要断言时只断言稳定语义片段,或改断言 payload / 状态码 / 目标 callback。
|
||||
- 不在页面级测试里 mock 出一套与真实页面差异很大的“假壳”,再把假壳内部状态当作用户验收结果。页面壳测试可以保留,但应尽量断言 URL、标题、公开 callback 和真实可见行为。
|
||||
|
||||
## 推荐写法
|
||||
|
||||
- 交互优先用 `userEvent`,只有测试低层 pointer / wheel / drag 等浏览器事件细节时再用 `fireEvent`。
|
||||
- 查询优先用 role / label / alt / text 的用户语义;只有无可访问语义的画布参考线、不可见测量节点或渲染边界,才使用 `data-testid`。
|
||||
- `data-testid` 名称必须描述用户或稳定渲染边界,例如 `image-canvas-editor-snap-guide-vertical`;不要描述 React 私有 state 名称。
|
||||
- 测试用 fixture 只包含本行为需要的字段。演化中的 payload 使用 `expect.objectContaining(...)` 或 helper 生成默认对象,避免一处契约加字段导致大量无关用例碎裂。
|
||||
- 当测试是为防止历史回归,应在测试名或邻近注释中说明防的是什么行为,而不是记录实现步骤。
|
||||
|
||||
## 试点调整
|
||||
|
||||
`src/components/image-editor/useCanvasGenerationDialogs.test.tsx` 已从测试专用 DOM 仪表盘改为 `renderHook`,直接验证 hook 公开契约:打开、归档、激活、更新、删除、恢复和 ID 递增。
|
||||
|
||||
`src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx` 已去掉图标 class 和完整按钮顺序快照式断言,保留用户可操作按钮、工具切换 callback、可访问 pressed 状态和 hover / focus 打开选项的契约。
|
||||
|
||||
## 验证
|
||||
|
||||
修改 React 测试后,优先运行触达文件的定向测试,例如:
|
||||
|
||||
```bash
|
||||
npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx --reporter=dot
|
||||
```
|
||||
|
||||
涉及共享组件、路由壳或跨页面交互时,再追加对应页面测试、`npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。
|
||||
@@ -13,7 +13,8 @@
|
||||
- `granularity` 默认为 `day`。
|
||||
- `anchor` 使用北京时间日历日期,保留给 `day` / `week` / `month` 兼容旧查询口径。`week` 和 `month` 选择包含该日期的自然周 / 自然月。
|
||||
- `period` 使用 `startDate` / `endDate` 作为闭区间自定义时段,最多 366 天。
|
||||
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”会按当前终止日期自动填充对应自然日 / 自然周 / 自然月范围,手动修改起止日期或点击“本时段”后按当前日期范围查询。
|
||||
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”按当天真实日期自动填充对应自然日 / 自然周 / 自然月范围并刷新,手动修改起止日期后按当前日期范围查询。
|
||||
- 前端默认日期和“本日 / 本周 / 本月”快捷入口都按 `Asia/Shanghai` 计算,不使用浏览器本地时区。
|
||||
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
|
||||
|
||||
## 指标口径
|
||||
@@ -21,7 +22,7 @@
|
||||
- 生产素材数:`editor_project_resource` 中 `source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。
|
||||
- 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。
|
||||
- 总注册用户:`profile_dashboard_state` 行数。
|
||||
- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月 / 本时段切换。
|
||||
- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。
|
||||
- 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
|
||||
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user` 的 `scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。
|
||||
- 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
|
||||
@@ -35,6 +36,13 @@
|
||||
- 前端页面:`apps/admin-web/src/pages/AdminDashboardPage.tsx`
|
||||
- 后台路由:`apps/admin-web/src/app/adminRoutes.ts`
|
||||
|
||||
## 埋点后台查询
|
||||
|
||||
- `GET /admin/api/tracking/events` 读取 `tracking_event` 原始事实,支持 `eventKey`、`userId`、`scopeKind`、`scopeId`、`startDate`、`endDate` 和 `limit` 筛选。日期使用北京时间日历日期 `YYYY-MM-DD`,后端转换为 `day_key` 闭区间。
|
||||
- 日常列表查询仍限制最多 1000 条;后台“导出全部”传 `exportAll=true`,上限放宽到 100000 条,仍复用当前筛选条件和日期范围。
|
||||
- `GET /admin/api/tracking/event-keys` 从已落库的 `tracking_event` 中扫描真实 `event_key` 和出现过的 `scope_kind`,由 api-server 去重后返回,前端只用静态清单补中文标题和备注。
|
||||
- 任务配置页的 Event Key 候选使用同一个真实 key 列表,并只默认展示出现过 `user` scope 的 key;自定义 key 入口保留给提前配置未写入的新埋点。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table:
|
||||
|
||||
- `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。
|
||||
- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务。
|
||||
- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。
|
||||
- `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。
|
||||
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
|
||||
|
||||
|
||||
@@ -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/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。
|
||||
- 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。
|
||||
- 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。
|
||||
- 拼图:`/api/runtime/puzzle/*`。
|
||||
@@ -202,6 +202,7 @@ npm run check:server-rs-ddd
|
||||
2. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;实际扣费真相以后端运行时模型定价配置为准,前端按钮泥点只作为展示。
|
||||
3. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
||||
4. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
5. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKey;objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。快速编辑、图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,快速编辑 / 图标素材上限 8 张额外参考图。
|
||||
|
||||
## 外部服务与资产
|
||||
|
||||
@@ -245,7 +246,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ExternalGenerationJob`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 用途:外部生成 worker 的持久任务队列和用户可见生成任务列表;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行同时保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`,BFF 通过 `GET /api/runtime/external-generation/jobs` 返回当前账号的正式生成任务列表、价格、状态和未确认终态数量;前端只能展示该后端事实,完成 / 失败提示展示后后台调用 `POST /api/runtime/external-generation/jobs/acknowledge` 由后端写确认时间,关闭按钮只收起本地弹窗,未确认终态任务会在下次登录后再次集中弹出。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 用途:外部生成 worker 的持久任务队列和用户可见生成任务列表;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行同时保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`,BFF 通过 `GET /api/runtime/external-generation/jobs` 返回当前账号的正式生成任务列表、价格、状态和未确认终态数量;前端只能展示该后端事实,完成 / 失败提示展示后后台调用 `POST /api/runtime/external-generation/jobs/acknowledge` 由后端写确认时间,关闭按钮只收起本地弹窗,未确认终态任务会在下次登录后再次集中弹出。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
|
||||
### `external_generation_job_event`
|
||||
|
||||
@@ -468,7 +469,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`EditorProjectResource`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开响应必须透传 `ownerUserId`,供精选卡片在缺少作者昵称时显示作者 id。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
|
||||
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开响应的作者展示字段优先提供 `authorDisplayName` / `display_name`,没有展示名时提供 `authorPublicUserCode` / 陶泥号作为展示兜底,前端公开展示绝不能兜底到内部 `ownerUserId` / `user_id`。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
|
||||
- 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。
|
||||
|
||||
### `editor_asset_folder`
|
||||
@@ -482,7 +483,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`EditorAsset`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json` 和可选 `source_resource_id`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。素材库快照通过 `source_resource_id` 回查对应 project resource 的 `public_showcase_enabled`,供左侧素材菜单展示和切换公开状态;公开开关本身仍只落在 `editor_project_resource`,不在账号素材表复制真相。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
|
||||
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json` 和可选 `source_resource_id`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `source_resource_id` 回查对应 project resource 的 `public_showcase_enabled`,供左侧素材菜单展示和切换公开状态;公开开关本身仍只落在 `editor_project_resource`,不在账号素材表复制真相。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
|
||||
- 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。
|
||||
|
||||
### `inventory_slot`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -63,7 +63,7 @@
|
||||
|
||||
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
|
||||
|
||||
精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。公开 BFF 必须返回资源 `ownerUserId`,前端在没有作者昵称字段时用该 id 作为作者兜底。若现有数据缺少提示词、作者标识或成本字段,v1 显示保守占位,不伪造内容。
|
||||
精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。公开 BFF 必须返回资源作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
|
||||
|
||||
Tab:
|
||||
|
||||
@@ -100,7 +100,7 @@ Tab:
|
||||
|
||||
数据来源:
|
||||
|
||||
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,快照包含 `ownerUserId` 作为作者展示兜底;前端只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。
|
||||
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,快照包含 `authorDisplayName` / `display_name` 和 `authorPublicUserCode` / 陶泥号用于作者展示;前端只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`。
|
||||
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。
|
||||
- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
|
||||
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -40,7 +40,7 @@
|
||||
- 生成视频:`你希望生成什么视频?`
|
||||
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
|
||||
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
|
||||
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张隐式参考图提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`。
|
||||
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`。
|
||||
11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。
|
||||
|
||||
## 参数交互
|
||||
@@ -99,6 +99,7 @@
|
||||
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。
|
||||
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
|
||||
- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;该占位必须复用新建图片的 placement 避让逻辑,和已有素材 / 生成占位至少保留 32px 画布间距,不允许固定放到原图右侧后压住其它素材;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。
|
||||
- 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。
|
||||
|
||||
## 画布悬浮信息
|
||||
|
||||
@@ -174,16 +175,16 @@
|
||||
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
|
||||
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为隐式最后参考图参与提交但不在参考图条里固定显示 `图x`。
|
||||
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为 `sourceImageSrc` 直接编辑,不在参考图条里固定显示 `图x`。
|
||||
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。
|
||||
- 快速编辑提交前必须把提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`。
|
||||
- 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后;若用户已完成红色圈选,最后一张隐式原图必须替换为绘制了红框和序号的标注图。
|
||||
- 快速编辑提交前保留提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用,不再改写成 `图N`。
|
||||
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`;额外参考图进入输入快照,并作为图片编辑请求的 `referenceImageSrcs` 一起提交。
|
||||
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
|
||||
- 快速编辑生成中的占位图同样只支持键盘 `Delete` / `Backspace` 删除,不新增 UI 删除按钮;删除后异步结果不得再落回画布。
|
||||
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
|
||||
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
|
||||
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
|
||||
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。` 生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
|
||||
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。
|
||||
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
|
||||
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
- `从画布中选择` 只接受 `assetKind: "icon-spec"` 的图层;普通图片、其他类别图层和不携带标签的图片不绑定。
|
||||
- `新建图标规范` 复用现有图标规范生成表单。
|
||||
- `上传图片` 仅绑定到当前 UI 设计面板的图标规范参考,不自动添加为画布图层。
|
||||
- 图标规范之外可继续上传普通参考图,用于约束 UI 设计图的布局风格、配色或材质;普通参考图不替代图标规范。
|
||||
|
||||
## 生成参数
|
||||
|
||||
@@ -44,20 +45,22 @@
|
||||
```
|
||||
|
||||
生成请求固定使用 `gpt-image-2`。有参考图时走图片编辑请求;无参考图时走图片生成请求。
|
||||
前端提交时会把图标规范作为第一张参考图,并把用户上传的普通参考图按顺序追加到同一个 `referenceImageSrcs` 数组;上传参考图优先提交 `objectKey`。
|
||||
|
||||
## 提取素材
|
||||
|
||||
- 点击浮动工具栏 `提取素材` 后,进入 UI 设计图素材框选编辑状态,不立即提交提取接口。
|
||||
- 框选状态参考微信截图工具栏布局,默认矩形框选,并支持矩形框选、椭圆框选和画笔自由框选三种工具。
|
||||
- 用户可在同一张 UI 设计图上多次框选;未至少框选一个区域时,工具栏 `提取` 按钮不可点击。
|
||||
- 可上传普通参考图辅助 UI 素材提取,用于约束被框选素材的风格、配色或材质;普通参考图会以 `objectKey` 随合成后的红框 UI 设计图一起提交。
|
||||
- 点击 `提取` 后,前端把所有框选的红色轮廓绘入原 UI 设计图,生成合成图片 Data URL,再提交到 `POST /api/editor/ui-designs/assets/extractions`。
|
||||
- 后端固定使用 `gpt-image-2` 图片编辑链路,并固定提示词:
|
||||
|
||||
```text
|
||||
仅提取被红色框框选的素材并整理成spritesheet
|
||||
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。
|
||||
```
|
||||
|
||||
- 后端收到 spritesheet 后复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。
|
||||
- 后端收到 spritesheet 后先把带绿幕源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。
|
||||
- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。
|
||||
|
||||
## 验收点
|
||||
@@ -66,5 +69,6 @@
|
||||
- 面板第一模块为图标规范,来源菜单包含三个动作。
|
||||
- 从画布选择时只能绑定图标规范图片。
|
||||
- 请求参数包含 `kind: "ui-design"`、`model: "gpt-image-2"`、比例、大小与可选参考图。
|
||||
- 生成图层信息面板展示 `用户输入` 与 `图标规范`。
|
||||
- 上传普通参考图后,请求参考图数组同时包含图标规范和普通参考图,生成图层信息面板展示 `用户输入`、`图标规范` 与普通参考图。
|
||||
- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,画布同时出现 spritesheet 图集和拆分后的独立素材。
|
||||
- UI 素材提取面板上传普通参考图后,提取请求参考图数组同时包含红框 UI 设计图和普通参考图,生成图层信息面板展示 `UI设计图` 与普通参考图。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user