diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 5df358f76..c08b03d85 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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( + '/admin/api/tracking/event-keys', + { token }, + ); +} + export function getAdminCreationEntryConfig(token: string) { return request( '/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}` : ''; } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 7e4617954..6cc8bd9f2 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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[]; +} diff --git a/apps/admin-web/src/config/trackingEventDefinitions.test.ts b/apps/admin-web/src/config/trackingEventDefinitions.test.ts index 0c04ba473..77523d548 100644 --- a/apps/admin-web/src/config/trackingEventDefinitions.test.ts +++ b/apps/admin-web/src/config/trackingEventDefinitions.test.ts @@ -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( + '每日登录', + ); }); }); diff --git a/apps/admin-web/src/config/trackingEventDefinitions.ts b/apps/admin-web/src/config/trackingEventDefinitions.ts index db7cf8fd2..43de6e7e5 100644 --- a/apps/admin-web/src/config/trackingEventDefinitions.ts +++ b/apps/admin-web/src/config/trackingEventDefinitions.ts @@ -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(); + 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(); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index 46c47f20e..2c5164ecf 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -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(); - 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(); - 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(); + + 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(); - 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(); + + 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(); - 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 }); +} diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index e3277a236..d4f393504 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -18,13 +18,12 @@ interface AdminDashboardPageProps { type AdminDashboardTab = 'metrics' | 'operations'; const granularityOptions: Array<{ - id: AdminDashboardGranularity; + id: Exclude; 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('day'); const [dateRange, setDateRange] = useState(() => { - const today = formatDateInput(new Date()); + const today = formatBeijingDateInput(new Date()); return { startDate: today, endDate: today }; }); const [activeTab, setActiveTab] = useState('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' ? ( <> -
- {metricCards.map((metric) => ( - - ))} -
+
+
+

总计数据

+ 全站累计 +
+
+ {totalMetricCards.map((metric) => ( + + ))} +
+
+ +
+
+

时段数据

+ {dashboard?.range.periodLabel ?? '-'} +
+
+ {periodMetricCards.map((metric) => ( + + ))} +
+
{(dashboard?.charts ?? []).map((chart) => ( @@ -299,13 +322,22 @@ export function AdminDashboardPage({ ); - function handleGranularityChange(nextGranularity: AdminDashboardGranularity) { - setGranularity(nextGranularity); - setDateRange((current) => - nextGranularity === 'period' - ? normalizeDateRange(current) - : buildPresetDateRange(nextGranularity, current.endDate), + function handleGranularityChange( + nextGranularity: Exclude, + ) { + 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; diff --git a/apps/admin-web/src/pages/AdminTaskConfigPage.tsx b/apps/admin-web/src/pages/AdminTaskConfigPage.tsx index cb146085a..1c34d6a6c 100644 --- a/apps/admin-web/src/pages/AdminTaskConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminTaskConfigPage.tsx @@ -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( + [], + ); const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false); const [cycle, setCycle] = useState('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({
) : null} - {selectedEventDefinition ? ( + {selectedEventOption ? ( - {selectedEventDefinition.remark} + {selectedEventOption.remark} ) : ( diff --git a/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx b/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx index a0b79c5fc..3c9f5d2e7 100644 --- a/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx +++ b/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx @@ -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(''); const [scopeId, setScopeId] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); const [limit, setLimit] = useState('200'); + const [eventKeys, setEventKeys] = useState( + [], + ); const [errorMessage, setErrorMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isExportingAll, setIsExportingAll] = useState(false); const [detailEntry, setDetailEntry] = useState(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 (
@@ -110,7 +173,7 @@ export function AdminTrackingEventsPage({
+
@@ -186,7 +258,27 @@ export function AdminTrackingEventsPage({ onChange={(event) => setLimit(event.target.value)} /> - @@ -353,9 +445,13 @@ function formatMetadataJson(value: string) { } } -function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) { +function exportTrackingEventsAsExcel( + entries: AdminTrackingEventEntryPayload[], +) { const tableRows = [ - exportColumns.map((column) => `${escapeHtml(column.label)}`).join(''), + exportColumns + .map((column) => `${escapeHtml(column.label)}`) + .join(''), ...entries.map((entry) => exportColumns .map( @@ -368,7 +464,9 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) const html = `\uFEFF${tableRows .map((row) => `${row}`) .join('')}
`; - 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 ''; } diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index d826c2eff..b71b17acc 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -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; diff --git a/docs/README.md b/docs/README.md index 9f4736622..0ceca3f52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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)。 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 9225908cf..0326fca0d 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -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" } diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index 34f39319f..1df121a4c 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -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。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cb43b7c34..3c6cb5205 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9f2bf1792..870e7bfeb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `