diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index c12cc7b1e..e5da99613 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -5,6 +5,7 @@ name = "Genarrative" [setup] script = ''' cp "$CODEX_SOURCE_TREE_PATH/.env.secrets.local" "$CODEX_WORKTREE_PATH/.env.secrets.local" +git reset --hard npm install npm run codegraph:init npm run codegraph:index diff --git a/TRACKING.md b/TRACKING.md index 9d577ebd0..40ab2e64d 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -1,6 +1,6 @@ # 图片画布编辑器 Lovart 化执行跟踪 -更新时间:`2026-06-17` +更新时间:`2026-06-23` ## 目标 @@ -73,6 +73,7 @@ - `npm run check:encoding` - `git diff --check` - Headless Playwright smoke:`http://127.0.0.1:10000/editor` 可展示画布、缩放菜单、底部工具栏和图片工具栏;只启动 `dev:web` 时 `/api/*` 代理 500 属于未启动后端的预期现象。 +- 2026-06-23 规范图选择回归修正:角色规范槽只接受画布中的规范图,图标素材与 UI 设计规范槽只接受图标规范图;选择普通角色图、图标图或其它不合格图片时不写回引用,并在画板顶部显示 warning toast。已验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 2026-06-12 Lovart 布局修正 smoke:`http://127.0.0.1:10000/editor` 已移除左侧竖向工具栏和右侧独立图层栏;素材、已生成文件、图层统一收在左侧可折叠面板,中央画布和周边面板保持浅色一体布局;截图留存于 `output/playwright/editor-left-integrated-light.png`。 - 2026-06-12 外圈背景修正 smoke:`http://127.0.0.1:10000/editor` 的编辑器宿主和画布根容器已铺成同一块白色工作台,不再通过圆角边框露出底部平台背景;截图留存于 `output/playwright/editor-no-outer-background.png`。 - 2026-06-12 画布背景与原生菜单修正 smoke:`http://127.0.0.1:10000/editor` 已拦截编辑器区域右键菜单,禁用长按文本选择 / iOS callout,并移除画布网格线与棋盘格底纹;截图留存于 `output/playwright/editor-plain-background.png`。 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index dcd5340d4..5df358f76 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -2,6 +2,8 @@ import type { AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest, AdminCreationEntryConfigResponse, + AdminDashboardQuery, + AdminDashboardResponse, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDisableProfileRedeemCodeRequest, @@ -20,6 +22,7 @@ import type { AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, + AdminUpsertProfileWalletConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, ApiErrorEnvelope, @@ -34,6 +37,7 @@ import type { ProfileRedeemCodeAdminResponse, ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, + ProfileWalletConfigAdminResponse, } from './adminApiTypes'; const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; @@ -143,6 +147,16 @@ export function getAdminOverview(token: string) { return request('/admin/api/overview', { token }); } +export function getAdminDashboard( + token: string, + query: AdminDashboardQuery = {}, +) { + return request( + `/admin/api/dashboard${buildDashboardQuery(query)}`, + { token }, + ); +} + export function getAdminDatabaseTables(token: string) { return request('/admin/api/database/tables', { token, @@ -359,6 +373,27 @@ export function disableProfileTaskConfig( ); } +export function getProfileWalletConfig(token: string) { + return request( + '/admin/api/profile/wallet-config', + { token }, + ); +} + +export function upsertProfileWalletConfig( + token: string, + payload: AdminUpsertProfileWalletConfigRequest, +) { + return request( + '/admin/api/profile/wallet-config', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listProfileRechargeProducts(token: string) { return request( '/admin/api/profile/recharge-products', @@ -402,6 +437,16 @@ function buildQueryString(query: AdminTrackingEventListQuery) { return queryString ? `?${queryString}` : ''; } +function buildDashboardQuery(query: AdminDashboardQuery) { + const params = new URLSearchParams(); + appendQueryParam(params, 'granularity', query.granularity); + appendQueryParam(params, 'anchor', query.anchor); + appendQueryParam(params, 'startDate', query.startDate); + appendQueryParam(params, 'endDate', query.endDate); + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) { const params = new URLSearchParams(); appendQueryParam(params, 'search', query.search); diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index c876dcc40..7e4617954 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -54,6 +54,77 @@ export interface AdminOverviewResponse { database: AdminDatabaseOverviewPayload; } +export type AdminDashboardGranularity = 'day' | 'week' | 'month' | 'period'; + +export interface AdminDashboardQuery { + granularity?: AdminDashboardGranularity; + anchor?: string; + startDate?: string; + endDate?: string; +} + +export interface AdminDashboardResponse { + range: AdminDashboardRangePayload; + metrics: AdminDashboardMetricsPayload; + charts: AdminDashboardChartPayload[]; + operations: AdminDashboardOperationsPayload; + warnings: string[]; + generatedAt: string; +} + +export interface AdminDashboardRangePayload { + granularity: AdminDashboardGranularity; + anchorDate: string; + periodStartDate: string; + periodEndDate: string; + periodLabel: string; +} + +export interface AdminDashboardMetricsPayload { + generatedAssets: number; + consumedMudPoints: number; + totalRegisteredUsers: number; + newRegisteredUsers: number; + visitUsers: number; + totalVisitUsers: number; + visitCount: number; + totalVisitCount: number; + currentUsers: number; +} + +export interface AdminDashboardChartPayload { + id: string; + title: string; + unit: string; + total: number; + buckets: AdminDashboardChartBucketPayload[]; +} + +export interface AdminDashboardChartBucketPayload { + key: string; + label: string; + value: number; +} + +export interface AdminDashboardOperationsPayload { + cards: AdminDashboardOperationMetricPayload[]; + assetKindBreakdown: AdminDashboardBreakdownRowPayload[]; + moduleVisitBreakdown: AdminDashboardBreakdownRowPayload[]; +} + +export interface AdminDashboardOperationMetricPayload { + id: string; + label: string; + value: number; + unit: string; +} + +export interface AdminDashboardBreakdownRowPayload { + key: string; + label: string; + value: number; +} + export interface AdminServiceOverviewPayload { bindHost: string; bindPort: number; @@ -325,6 +396,10 @@ export interface AdminUpsertProfileRechargeProductRequest { sortOrder: number; } +export interface AdminUpsertProfileWalletConfigRequest { + initialMudPoints: number; +} + export interface ProfileRedeemCodeAdminResponse { code: string; mode: ProfileRedeemCodeMode; @@ -401,6 +476,15 @@ export interface ProfileRechargeProductConfigAdminListResponse { entries: ProfileRechargeProductConfigAdminResponse[]; } +export interface ProfileWalletConfigAdminResponse { + configId: string; + initialMudPoints: number; + createdBy: string; + createdAt: string; + updatedBy: string; + updatedAt: string; +} + export interface AdminTrackingEventEntryPayload { eventId: string; eventKey: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 8a30a7afd..1386c61b4 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -12,6 +12,7 @@ import type { ProfileRechargeProductConfigAdminResponse, ProfileRedeemCodeAdminResponse, ProfileTaskConfigAdminResponse, + ProfileWalletConfigAdminResponse, } from '../api/adminApiTypes'; import { clearStoredAdminToken, @@ -19,12 +20,14 @@ import { setStoredAdminToken, } from '../auth/adminAuthStore'; import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage'; +import {AdminDashboardPage} from '../pages/AdminDashboardPage'; import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage'; import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; +import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage'; import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; @@ -51,6 +54,8 @@ export function AdminApp() { useState(null); const [taskConfigResult, setTaskConfigResult] = useState(null); + const [profileWalletConfigResult, setProfileWalletConfigResult] = + useState(null); const [rechargeProductResult, setRechargeProductResult] = useState(null); @@ -61,6 +66,7 @@ export function AdminApp() { setRedeemResult(null); setInviteResult(null); setTaskConfigResult(null); + setProfileWalletConfigResult(null); setRechargeProductResult(null); setStatus('guest'); setLoginNotice(message); @@ -131,6 +137,7 @@ export function AdminApp() { setRedeemResult(null); setInviteResult(null); setTaskConfigResult(null); + setProfileWalletConfigResult(null); setRechargeProductResult(null); setLoginNotice(''); setStatus('authenticated'); @@ -167,6 +174,9 @@ export function AdminApp() { onLogout={handleLogout} onRouteChange={handleRouteChange} > + {routeId === 'dashboard' ? ( + + ) : null} {routeId === 'overview' ? ( ) : null} @@ -228,6 +238,14 @@ export function AdminApp() { onResultChange={setTaskConfigResult} /> ) : null} + {routeId === 'profile-wallet' ? ( + + ) : null} {routeId === 'recharge-products' ? ( { + expect(adminRoutes[0]).toEqual({ + id: 'dashboard', + label: 'Dashboard', + hash: '#dashboard', + }); + expect(resolveAdminRoute('')).toBe('dashboard'); + expect(resolveAdminRoute('#unknown')).toBe('dashboard'); + expect(routeHash('dashboard')).toBe('#dashboard'); +}); + // 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。 test('后台入口公告路由可通过导航和 hash 访问', () => { expect(adminRoutes).toContainEqual({ diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index d6a0769af..870e31a19 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -1,11 +1,13 @@ /** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */ export type AdminRouteId = + | 'dashboard' | 'overview' | 'tables' | 'debug' | 'tracking' | 'redeem' | 'invite' + | 'profile-wallet' | 'tasks' | 'recharge-products' | 'editor-generation-pricing' @@ -21,12 +23,14 @@ export interface AdminRouteDefinition { } export const adminRoutes: AdminRouteDefinition[] = [ - {id: 'overview', label: '总览', hash: '#overview'}, + {id: 'dashboard', label: 'Dashboard', hash: '#dashboard'}, + {id: 'overview', label: '服务总览', hash: '#overview'}, {id: 'tables', label: '表查询', hash: '#tables'}, {id: 'debug', label: 'API 调试', hash: '#debug'}, {id: 'tracking', label: '埋点数据', hash: '#tracking'}, {id: 'redeem', label: '兑换码', hash: '#redeem'}, {id: 'invite', label: '邀请码', hash: '#invite'}, + {id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'}, {id: 'tasks', label: '任务配置', hash: '#tasks'}, {id: 'recharge-products', label: '充值商品', hash: '#recharge-products'}, {id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'}, @@ -35,12 +39,12 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'}, ]; -/** 根据地址栏 hash 解析后台路由,未知 hash 回落到总览页。 */ +/** 根据地址栏 hash 解析后台路由,未知 hash 回落到 Dashboard。 */ export function resolveAdminRoute(hash: string): AdminRouteId { const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? ''; return ( adminRoutes.find((route) => route.hash === normalizedHash)?.id ?? - 'overview' + 'dashboard' ); } @@ -49,6 +53,6 @@ export function routeHash(routeId: AdminRouteId) { return ( adminRoutes.find((route) => route.id === routeId)?.hash ?? adminRoutes[0]?.hash ?? - '#overview' + '#dashboard' ); } diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx new file mode 100644 index 000000000..46c47f20e --- /dev/null +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -0,0 +1,147 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { getAdminDashboard } from '../api/adminApiClient'; +import type { AdminDashboardResponse } from '../api/adminApiTypes'; +import { AdminDashboardPage } from './AdminDashboardPage'; + +vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + getAdminDashboard: vi.fn(), + isAdminApiError: vi.fn(() => false), +})); + +const dashboardResponse: AdminDashboardResponse = { + range: { + granularity: 'day', + anchorDate: '2026-06-23', + periodStartDate: '2026-06-23', + periodEndDate: '2026-06-23', + periodLabel: '2026-06-23', + }, + metrics: { + generatedAssets: 12, + consumedMudPoints: 88, + totalRegisteredUsers: 1200, + newRegisteredUsers: 16, + visitUsers: 34, + totalVisitUsers: 456, + visitCount: 98, + totalVisitCount: 9876, + currentUsers: 7, + }, + charts: [ + { + id: 'generated-assets', + title: '生产素材', + unit: '个', + total: 12, + buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }], + }, + ], + operations: { + cards: [ + { + id: 'period-generated-assets', + label: '生产素材', + value: 12, + unit: '个', + }, + ], + assetKindBreakdown: [ + { key: 'editor_generated_image', label: '画板图片', value: 9 }, + ], + moduleVisitBreakdown: [{ key: 'auth', label: '认证', value: 30 }], + }, + warnings: [], + generatedAt: '2026-06-23T12:00:00Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAdminDashboard).mockResolvedValue(dashboardResponse); +}); + +test('Dashboard 默认加载今日指标并支持运营汇总页签', async () => { + const user = userEvent.setup(); + render(); + + expect(await screen.findByText('本日总生产素材数')).toBeTruthy(); + expect(screen.getByText('总注册用户')).toBeTruthy(); + expect(screen.getByText('本日新增用户数')).toBeTruthy(); + expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy(); + expect(screen.getByText('生产素材')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '运营汇总' })); + + expect(screen.getByText('素材类型分布')).toBeTruthy(); + expect(screen.getByText('画板图片')).toBeTruthy(); + expect(screen.getByText('访问模块分布')).toBeTruthy(); +}); + +test('Dashboard 选择本周时按当前终止日期填充整周范围', async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-07-02' }, + }); + await user.click(screen.getByRole('button', { name: '本周' })); + + await waitFor(() => { + expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { + granularity: 'week', + anchor: '2026-07-05', + startDate: '2026-06-29', + endDate: '2026-07-05', + }); + }); +}); + +test('Dashboard 选择本月时按当前终止日期填充整月范围', async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-07-12' }, + }); + await user.click(screen.getByRole('button', { name: '本月' })); + + await waitFor(() => { + expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { + granularity: 'month', + anchor: '2026-07-31', + startDate: '2026-07-01', + endDate: '2026-07-31', + }); + }); +}); + +test('Dashboard 手动选择起止日期时使用本时段查询', async () => { + render(); + + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('起始日期'), { + target: { value: '2026-06-01' }, + }); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-06-12' }, + }); + + await waitFor(() => { + expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { + granularity: 'period', + anchor: undefined, + startDate: '2026-06-01', + endDate: '2026-06-12', + }); + }); + expect(screen.getByText('本时段新增用户数')).toBeTruthy(); +}); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx new file mode 100644 index 000000000..e3277a236 --- /dev/null +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -0,0 +1,521 @@ +import { RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { getAdminDashboard } from '../api/adminApiClient'; +import type { + AdminDashboardBreakdownRowPayload, + AdminDashboardChartPayload, + AdminDashboardGranularity, + AdminDashboardResponse, +} from '../api/adminApiTypes'; +import { handlePageError } from './pageUtils'; + +interface AdminDashboardPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +type AdminDashboardTab = 'metrics' | 'operations'; + +const granularityOptions: Array<{ + id: AdminDashboardGranularity; + label: string; +}> = [ + { id: 'day', label: '本日' }, + { id: 'week', label: '本周' }, + { id: 'month', label: '本月' }, + { id: 'period', label: '本时段' }, +]; + +export function AdminDashboardPage({ + token, + onUnauthorized, +}: AdminDashboardPageProps) { + const [dashboard, setDashboard] = useState( + null, + ); + const [granularity, setGranularity] = + useState('day'); + const [dateRange, setDateRange] = useState(() => { + const today = formatDateInput(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 () => { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await getAdminDashboard(token, { + granularity, + anchor: granularity === 'period' ? undefined : dateRange.endDate, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + }); + setDashboard(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + }, [ + dateRange.endDate, + dateRange.startDate, + granularity, + onUnauthorized, + token, + ]); + + useEffect(() => { + void loadDashboard(); + }, [loadDashboard]); + + useEffect(() => { + const timer = window.setInterval( + () => { + void loadDashboard(); + }, + 5 * 60 * 1000, + ); + return () => window.clearInterval(timer); + }, [loadDashboard]); + + const metrics = dashboard?.metrics; + const metricCards = 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: 'new-registered-users', + label: `${rangePrefix(granularity)}新增用户数`, + value: metrics?.newRegisteredUsers ?? 0, + unit: '人', + }, + { + id: 'visit-users', + label: `${rangePrefix(granularity)}访问人数`, + 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], + ); + + return ( +
+
+
+

Dashboard

+

{dashboard?.range.periodLabel ?? '运营指标'}

+
+
+
+ + +
+
+ {granularityOptions.map((option) => ( + + ))} +
+ +
+
+ +
+ + +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {dashboard?.warnings.length ? ( +
+
+

读取告警

+ {dashboard.warnings.length} +
+
    + {dashboard.warnings.map((message) => ( +
  • {message}
  • + ))} +
+
+ ) : null} + + {activeTab === 'metrics' ? ( + <> +
+ {metricCards.map((metric) => ( + + ))} +
+ +
+ {(dashboard?.charts ?? []).map((chart) => ( + + ))} + {dashboard && dashboard.charts.length === 0 ? ( +
暂无图表
+ ) : null} +
+ + ) : ( +
+
+
+

运营指标

+ {dashboard?.range.periodLabel ?? '-'} +
+
+ {(dashboard?.operations.cards ?? []).map((card) => ( + + ))} +
+
+ + + +
+ )} +
+ ); + + function handleGranularityChange(nextGranularity: AdminDashboardGranularity) { + setGranularity(nextGranularity); + setDateRange((current) => + nextGranularity === 'period' + ? normalizeDateRange(current) + : buildPresetDateRange(nextGranularity, current.endDate), + ); + } + + function handleDateRangeChange( + field: 'startDate' | 'endDate', + value: string, + ) { + if (!parseDateValueAsUtc(value)) { + return; + } + setGranularity('period'); + setDateRange((current) => + normalizeDateRange({ ...current, [field]: value }), + ); + } +} + +function MetricCard({ + label, + value, + unit, + compact = false, +}: { + label: string; + value: number; + unit: string; + compact?: boolean; +}) { + return ( +
+ {label} + {formatNumber(value)} + {unit} +
+ ); +} + +function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) { + const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value)); + return ( +
+
+

{chart.title}

+ + {formatNumber(chart.total)} {chart.unit} + +
+
+ {chart.buckets.map((bucket) => ( +
+
+ +
+ {bucket.label} +
+ ))} +
+
+ ); +} + +function BreakdownPanel({ + title, + rows, + unit, +}: { + title: string; + rows: AdminDashboardBreakdownRowPayload[]; + unit: string; +}) { + const maxValue = Math.max(1, ...rows.map((row) => row.value)); + return ( +
+
+

{title}

+ {rows.length} +
+
+ {rows.map((row) => ( +
+
+ {row.label} + + {formatNumber(row.value)} {unit} + +
+
+ +
+
+ ))} + {rows.length === 0 ? ( +
暂无数据
+ ) : null} +
+
+ ); +} + +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 rangePrefix(granularity: AdminDashboardGranularity) { + if (granularity === 'week') { + return '本周'; + } + if (granularity === 'month') { + return '本月'; + } + if (granularity === 'period') { + return '本时段'; + } + return '本日'; +} + +function buildPresetDateRange( + granularity: AdminDashboardGranularity, + selectedDate: string, +) { + const fallbackDate = formatDateInput(new Date()); + const anchor = parseDateValueAsUtc(selectedDate) + ? selectedDate + : fallbackDate; + if (granularity === 'week') { + return buildWeekDateRange(anchor); + } + if (granularity === 'month') { + return buildMonthDateRange(anchor); + } + return { startDate: anchor, endDate: anchor }; +} + +function buildWeekDateRange(dateValue: string) { + const date = parseDateValueAsUtc(dateValue); + if (!date) { + return { startDate: dateValue, endDate: dateValue }; + } + + const weekday = date.getUTCDay() || 7; + const monday = new Date(date); + monday.setUTCDate(date.getUTCDate() - weekday + 1); + const sunday = new Date(monday); + sunday.setUTCDate(monday.getUTCDate() + 6); + return { startDate: formatUtcDate(monday), endDate: formatUtcDate(sunday) }; +} + +function buildMonthDateRange(dateValue: string) { + const date = parseDateValueAsUtc(dateValue); + if (!date) { + return { startDate: dateValue, endDate: dateValue }; + } + + const firstDate = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1), + ); + const lastDate = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0), + ); + return { + startDate: formatUtcDate(firstDate), + endDate: formatUtcDate(lastDate), + }; +} + +function normalizeDateRange(range: { startDate: string; endDate: string }) { + return range.startDate > range.endDate + ? { startDate: range.endDate, endDate: range.startDate } + : range; +} + +function parseDateValueAsUtc(value: string) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() + 1 !== month || + date.getUTCDate() !== day + ) { + return null; + } + return date; +} + +function formatUtcDate(date: Date) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx new file mode 100644 index 000000000..1f19ed0f2 --- /dev/null +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx @@ -0,0 +1,174 @@ +import {RefreshCcw, Save} from 'lucide-react'; +import {FormEvent, useEffect, useState} from 'react'; + +import { + getProfileWalletConfig, + upsertProfileWalletConfig, +} from '../api/adminApiClient'; +import type {ProfileWalletConfigAdminResponse} from '../api/adminApiTypes'; +import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; +import {handlePageError} from './pageUtils'; + +interface AdminProfileWalletConfigPageProps { + token: string; + result: ProfileWalletConfigAdminResponse | null; + onUnauthorized: (message?: string) => void; + onResultChange: (result: ProfileWalletConfigAdminResponse) => void; +} + +export function AdminProfileWalletConfigPage({ + token, + result, + onUnauthorized, + onResultChange, +}: AdminProfileWalletConfigPageProps) { + const [initialMudPoints, setInitialMudPoints] = useState('100'); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [loadErrorMessage, setLoadErrorMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); + + useEffect(() => { + void refreshConfig(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshConfig() { + setIsLoading(true); + setLoadErrorMessage(''); + try { + const response = await getProfileWalletConfig(token); + onResultChange(response); + setInitialMudPoints(String(response.initialMudPoints)); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setLoadErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + if (isSaving) { + return; + } + + const normalizedInitialMudPoints = parsePositiveInteger(initialMudPoints); + if (!normalizedInitialMudPoints) { + setErrorMessage('账号初始泥点数必须是大于 0 的整数'); + return; + } + + setErrorMessage(''); + const confirmed = await confirmWrite({ + action: '保存账号配置', + target: `${normalizedInitialMudPoints}泥点`, + }); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + const response = await upsertProfileWalletConfig(token, { + initialMudPoints: normalizedInitialMudPoints, + }); + onResultChange(response); + setInitialMudPoints(String(response.initialMudPoints)); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSaving(false); + } + } + + return ( +
+
+
+

账号配置

+

泥点钱包

+
+ +
+ + {loadErrorMessage ? ( +
+ {loadErrorMessage} +
+ ) : null} + +
+
+ + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + +
+ +
+
+

当前配置

+ {result?.configId ?? '-'} +
+ {result ? ( +
+
+
初始泥点
+
{result.initialMudPoints}
+
+
+
更新人
+
{result.updatedBy || '-'}
+
+
+
更新
+
{result.updatedAt}
+
+
+ ) : ( +
+ {isLoading ? '加载中' : '暂无记录'} +
+ )} +
+
+ {confirmDialog} +
+ ); +} + +function parsePositiveInteger(value: string) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 6aa13364f..d826c2eff 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -271,6 +271,223 @@ button:disabled { gap: 16px; } +.admin-dashboard-heading { + align-items: flex-start; +} + +.admin-dashboard-actions { + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: flex-end; + gap: 10px; +} + +.admin-dashboard-granularity { + width: 250px; + flex: 0 0 auto; +} + +.admin-dashboard-period-fields { + display: grid; + width: 352px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.admin-dashboard-date-field { + width: 170px; +} + +.admin-dashboard-tabs { + display: inline-grid; + width: fit-content; + grid-template-columns: repeat(2, minmax(96px, 1fr)); + gap: 6px; + border: 1px solid #e1ccbb; + border-radius: 8px; + background: #ffffff; + padding: 4px; +} + +.admin-dashboard-tabs button { + min-height: 38px; + border: 0; + border-radius: 6px; + color: #755a49; + background: transparent; + font-weight: 800; +} + +.admin-dashboard-tabs button[data-active="true"] { + color: #8f3f27; + background: #f4e5d7; +} + +.admin-dashboard-metric-grid, +.admin-dashboard-chart-grid, +.admin-dashboard-operation-grid { + display: grid; + gap: 14px; +} + +.admin-dashboard-metric-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.admin-dashboard-operation-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.admin-dashboard-chart-grid, +.admin-dashboard-operations { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.admin-dashboard-operations { + display: grid; + gap: 16px; + align-items: start; +} + +.admin-dashboard-operations > .admin-panel:first-child { + grid-column: 1 / -1; +} + +.admin-dashboard-metric-card { + gap: 8px; + min-height: 132px; +} + +.admin-dashboard-metric-card[data-compact="true"] { + min-height: 112px; +} + +.admin-dashboard-metric-card span { + color: #8f7868; + font-size: 13px; + font-weight: 750; + line-height: 1.35; +} + +.admin-dashboard-metric-card strong { + color: #3d1f10; + font-size: 30px; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.admin-dashboard-metric-card small { + color: #a38f80; + font-size: 12px; + font-weight: 700; +} + +.admin-dashboard-chart-card { + min-height: 286px; +} + +.admin-dashboard-bars { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(18px, 1fr); + align-items: end; + gap: 8px; + min-height: 196px; + overflow-x: auto; + padding: 4px 2px 0; +} + +.admin-dashboard-bar-item { + display: grid; + min-width: 18px; + gap: 7px; + align-items: end; + justify-items: center; +} + +.admin-dashboard-bar-track { + position: relative; + display: flex; + width: 100%; + min-width: 18px; + height: 160px; + align-items: flex-end; + overflow: hidden; + border-radius: 7px; + background: #f4e5d7; +} + +.admin-dashboard-bar-track span { + display: block; + width: 100%; + border-radius: 7px 7px 0 0; + background: linear-gradient(180deg, #c87955, #8f3f27); +} + +.admin-dashboard-bar-item small { + max-width: 48px; + overflow: hidden; + color: #8f7868; + font-size: 11px; + font-weight: 700; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-dashboard-breakdown-panel { + min-height: 300px; +} + +.admin-dashboard-breakdown-list { + display: grid; + gap: 12px; +} + +.admin-dashboard-breakdown-row { + display: grid; + gap: 7px; +} + +.admin-dashboard-breakdown-row > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.admin-dashboard-breakdown-row strong, +.admin-dashboard-breakdown-row span { + min-width: 0; + overflow-wrap: anywhere; +} + +.admin-dashboard-breakdown-row strong { + color: #3d1f10; + font-size: 13px; +} + +.admin-dashboard-breakdown-row span { + color: #8f7868; + font-size: 12px; + font-weight: 700; +} + +.admin-dashboard-breakdown-track { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: #f4e5d7; +} + +.admin-dashboard-breakdown-track span { + display: block; + height: 100%; + border-radius: inherit; + background: #8f3f27; +} + .admin-two-column { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr); @@ -1048,6 +1265,8 @@ button:disabled { } .admin-overview-grid, + .admin-dashboard-chart-grid, + .admin-dashboard-operations, .admin-two-column, .admin-two-column-wide, .admin-pricing-grid, @@ -1058,6 +1277,30 @@ button:disabled { grid-template-columns: 1fr; } + .admin-dashboard-heading { + display: grid; + } + + .admin-dashboard-actions { + justify-content: stretch; + } + + .admin-dashboard-granularity, + .admin-dashboard-period-fields, + .admin-dashboard-date-field, + .admin-dashboard-actions .admin-secondary-button { + width: 100%; + } + + .admin-dashboard-period-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-dashboard-metric-grid, + .admin-dashboard-operation-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .admin-field-compact { max-width: none; } @@ -1116,6 +1359,27 @@ button:disabled { gap: 3px; } + .admin-dashboard-tabs { + width: 100%; + } + + .admin-dashboard-period-fields { + grid-template-columns: 1fr; + } + + .admin-dashboard-metric-grid, + .admin-dashboard-operation-grid { + grid-template-columns: 1fr; + } + + .admin-dashboard-metric-card { + min-height: 112px; + } + + .admin-dashboard-chart-card { + min-height: 250px; + } + .admin-header-row { grid-template-columns: 1fr; } diff --git a/deploy/systemd/genarrative-database-backup.service b/deploy/systemd/genarrative-database-backup.service index 18c5b7e7c..276b9f9ab 100644 --- a/deploy/systemd/genarrative-database-backup.service +++ b/deploy/systemd/genarrative-database-backup.service @@ -9,9 +9,10 @@ User=root Group=root WorkingDirectory=/opt/genarrative/current EnvironmentFile=/etc/genarrative/api-server.env -ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service +ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service # 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。 +# 停止 SpacetimeDB 会连带停止 Requires 它的 API / worker / controller,冷备份后必须显式拉起。 PrivateTmp=true ProtectSystem=full ReadWritePaths=/stdb /var/lib/genarrative diff --git a/docs/README.md b/docs/README.md index e991bca52..9f4736622 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,7 +40,7 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView 本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。 -生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 +生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md),Dashboard 默认入口、运营指标和统计口径见 [【后台管理】Dashboard运营看板方案-2026-06-23.md](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。 SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a1dc00759..8fbf4c8ce 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-24 外部生成队列升级为正式生成任务列表 + +- 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。 +- 决策:`external_generation_job` 同时作为正式生成任务列表事实源,保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`;新增 `external_generation_job_event` 追加状态转换审计。BFF 新增当前账号任务列表和 acknowledge 接口;前端只展示后端任务状态,完成 / 失败提示关闭时由后端写确认时间,未确认终态任务在下次登录后按列表集中弹出。任务触发的钱包扣费 / 退款流水 metadata 必须写 `externalGenerationJobId`,本机退款 outbox 重放也保留该任务 ID。 +- 影响范围:`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`。 + ## 2026-06-23 编辑器宣发素材固定 gpt-image-2 - 背景:画板宣发素材的游戏首图、详情五图和运营海报只应使用稳定的宣发图生成链路,不能被图片模型上次选择或旧请求切到 `nanobanana2`。 @@ -32,12 +40,12 @@ - 验证方式:运行品牌标识、绑定手机号页和平台首页相关前端测试,确认品牌图 `src` 为 `/branding/taonier-product-ip.png`;执行 `npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`。 -## 2026-06-22 创作主页精选只展示项目内画布生成资源 +## 2026-06-22 创作主页精选展示全站公开画布生成资源 - 背景:`/creation` 的 `陶泥儿精选` 曾从账号级素材库读取,并在素材为空时用公开作品图片补充,导致新创作页出现不属于任何当前图片画布项目的素材。 -- 决策:`陶泥儿精选` 只从用户图片画布项目中的 `editor_project_resource` 构建素材包和素材,且只保留 `sourceType="generated"` 的画布生成资源;账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。 -- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、最近项目列表资源读取、创作主页改版计划和精选素材相关测试。 -- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts` 与 `CreationLandingView.test.tsx`,确认项目内生成资源展示、上传素材过滤、公开作品不再 fallback。 +- 决策:`陶泥儿精选` 从全站 `editor_project_resource` 构建素材包和素材,读取路径固定为 `GET /api/editor/showcase/resources`;只保留 `sourceType="generated"`、图片源非空且 `public_showcase_enabled = true` 的画布生成资源,按创建时间倒序展示。账号级 `editor_asset`、上传素材、公开作品图片和 `mock_generated` 都不作为精选来源。任意画布左侧素材列表中,单素材右键菜单承接文字 `删除` 和 `公开展示该素材` 勾选项;勾选项默认打开,修改时写回对应 `editor_project_resource.public_showcase_enabled`。 +- 影响范围:`/creation` 创作主页、`creationShowcaseModel`、公开精选 BFF、图片画布素材列表右键菜单、账号素材库快照、创作主页改版计划和精选素材相关测试。 +- 验证方式:运行 `src/components/creation-home/creationShowcaseModel.test.ts`、`CreationLandingView.test.tsx`、`ImageCanvasAssetRowView.test.tsx`、`useImageCanvasAssetLibrary.test.tsx` 与 `src/services/image-editor/editorProjectClient.test.ts`,确认公开生成资源展示、上传素材过滤、公开开关隐藏资源、删除入口在右键菜单中、公开作品不再 fallback。 - 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-06-22 编辑器生成模型默认定价调整 @@ -72,6 +80,14 @@ - 验证方式:运行角色动作图层工厂、画布展示、画布持久化、生成提交和素材导出相关前端测试,执行 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 +## 2026-06-24 图片画布项目封面使用静态快照资源 + +- 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。 +- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。 +- 影响范围:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/project/ProjectGalleryView.tsx`、`src/components/creation-home/CreationLandingView.tsx` 和图片画布数据契约文档。 +- 验证方式:运行项目页、封面快照模型、图片画布项目持久化和媒体上传相关前端测试,执行 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-21 图片画布生成完成态由后端写入画布布局 - 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。 @@ -3689,3 +3705,12 @@ - 2026-06-22 调整:release 打包资源在 Windows WebView 内可能以 `http://tauri.localhost/index.html` 出现,这仍是 Tauri 内部资源,不允许被导航拦截交给系统浏览器;`shell/navigation.rs` 必须允许 `http` / `https` 的 `*.localhost` 留在 WebView。Windows release 二进制必须使用 GUI subsystem,避免正式包启动时额外弹出控制台窗口。 - 影响范围:`apps/desktop-shell/src-tauri/tauri.conf.json`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/dev.test.ts`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_main_window_config_uses_dev_url_in_dev_builds desktop_webview_navigation_stays_on_packaged_or_same_origin_pages`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-23 后台默认入口切到 Dashboard 运营看板 + +- 背景:后台需要默认进入运营数据面板,而不是服务 / 数据库状态页;看板要同时支持日 / 周 / 月筛选,并展示生产素材、泥点消耗、注册、访问和当前使用人数。 +- 决策:`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` 兼容并由前端自动填充对应范围,页面周期切换展示为“本日 / 本周 / 本月 / 本时段”。 +- 指标口径:生产素材数统计 `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 1864502e1..51a1c1dd6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -15,6 +15,38 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 图片画布素材库删除要匹配 sourceResourceId + +- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。 +- 原因:生成素材进入账号级素材库时可能通过 `editor_asset.sourceResourceId` 指向原项目资源;如果前端素材库映射和级联删除只比较 `sourceAssetId`、`assetObjectId`、`objectKey` 或 `src`,就会漏掉只靠项目资源 ID 关联的历史 / 后端生成图层。 +- 处理:`EditorAsset` 必须保留 `sourceResourceId`;从素材库添加到画布时继续写入图层;删除素材时同时比较 `layer.resourceId` / `layer.sourceResourceId` 与 `asset.sourceResourceId`。 +- 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。 +- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasAssetCanvasBridge.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 + +## 陶泥儿精选重复先查同源同媒体画布副本 + +- 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。 +- 原因:素材拖入画布会为图层实例准备 `editor_project_resource`;如果该素材本来带 `sourceResourceId` 指向原始生成资源,而新资源仍按普通 generated 资源公开,精选就会把原件和每次拖拽产生的同源同媒体副本都展示出来。 +- 处理:创建项目资源时保留 `source_resource_id`,并在同项目已有同源同媒体资源时复用已有 resource;确需创建同源同媒体副本时默认 `public_showcase_enabled = false`。公开精选读取和前端精选模型都跳过 `sourceResourceId` 指回同一媒体原件的副本,但不要按图片地址全局去重,避免不同生成步骤共享占位图时被误合并。 +- 验证:`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`。 +- 关联:`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`。 + +## 画板外部生成排队超时不是失败 + +- 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。 +- 原因:画板生成已经接入后端外部生成任务队列,`queued` / `running` 是正式任务状态;旧前端轮询等待窗口到期时直接抛错,导致正常排队被提交流程 catch 成失败 UI。 +- 处理:`waitForEditorGenerationQueue` 等待超时只返回“仍在后端继续执行”,调用方停止本次前端等待并保留生成中状态;只有后端任务终态为 `failed` 才展示失败。 +- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。 +- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`。 + ## 编辑器生成按钮显示泥点后仍要查真实钱包预扣 - 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。 @@ -151,6 +183,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`。 +## 图片画布框选预览要复用源图换签缓存 + +- 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。 +- 原因:预览图从原生 `img` 改成 `ResolvedAssetImage` 后,如果没有传入源图同一套 `objectKey` / `refreshKey`,它会另起一条 `/api/assets/read-url` 缓存维度;画布主图已经显示时,预览仍可能处于空签名或失败缓存状态。 +- 处理:框选预览继续用 `ResolvedAssetImage` 承接私有资源换签,但必须传源图 `objectKey`,并使用 `taskId ?? resourceId` 作为 `refreshKey`,和主画布图片保持同一签名缓存版本。只允许对 `data:`、`blob:` 或已带签名参数的 URL 设置 `fallbackSrc`;不要把裸 `/generated...` 私有路径作为 fallback 写进 `img`。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx --reporter=dot` 应断言私有框选预览带 `objectKey`、`refreshKey`,且裸 generated 路径没有 fallback。 +- 关联:`src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx`、`src/components/ResolvedAssetImage.tsx`、`src/hooks/useResolvedAssetReadUrl.ts`、`src/services/assetReadUrlService.ts`。 + ## 图片画布发布入口 429 先查自动保存 PATCH 并发 - 现象:发布域名访问画板时出现短时间密集 `429`,Nginx access log 中 `PATCH /api/editor/projects/`、生成接口和资料接口混杂,429 行常见 `request_time=0.000`、`upstream_status=-`,error log 写 `limiting connections by zone "genarrative_api_conn"`。 @@ -159,6 +199,14 @@ - 验证:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx -t "serializes project layout saves" --reporter verbose` 应覆盖慢保存期间不启动第二个 PATCH,首个保存完成后只发送最新待保存快照;排查发布现场时 429 行应从 `upstream_status=-` / Nginx `limit_conn` 收敛。 - 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.test.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +## 图片画布发布入口 429 也要查 read-url 换签爆发 + +- 现象:发布域名刚上线或刷新画板后出现短时间 `429`,Nginx access log 中集中为同一 IP / 同一 `editor/canvas?projectid=...` referrer 的 `GET /api/assets/read-url?objectKey=generated-character-drafts/editor/ui-design-assets/.../asset-001.png` 到几十上百个 UI 设计切片;429 行常见 `request_time=0.000`、`upstream_status=-`,error log 写 `limiting requests ... zone "genarrative_api_rps"`。 +- 原因:这类 429 是入口 Nginx `limit_req` 在转发前按 RPS burst 快拒,不是 api-server、SpacetimeDB、worker 或 VectorEngine 的业务 429。UI 设计提取、角色动画帧或大量私有素材恢复会让多个 `ResolvedAssetImage` 同时挂载;如果 `/api/assets/read-url` 只有同 key pending 去重和缓存,没有跨 objectKey 节流,一个页面能在同一秒内发出数百个不同 objectKey 换签请求并打满 `genarrative_api_rps` burst。 +- 处理:不要先放大 Nginx 通用 API 限流;先按 access log 聚合 `read-url` 数量、状态和 referrer,确认是否同一画板页面触发。`assetReadUrlService` 必须统一承接私有 generated 资源换签,并在真实请求前做跨组件轻量节流;画板、素材库、运行态和结果页不得直接绕过该服务调用 `/api/assets/read-url`。 +- 验证:`npm run test -- src/services/assetReadUrlService.test.ts --reporter verbose` 应覆盖大量不同 objectKey 同时换签时首批限量放行、后续按间隔派发;发布现场同类页面刷新时,Nginx `GET /api/assets/read-url` 429 应从 `upstream_status=-` / `genarrative_api_rps` 收敛。 +- 关联:`src/services/assetReadUrlService.ts`、`src/hooks/useResolvedAssetReadUrl.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL - 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。 @@ -295,12 +343,12 @@ - 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。 - 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。 -## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复 +## 生产冷备份后 API 和外部生成 worker 不能只依赖 SpacetimeDB 自恢复 -- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped。 -- 原因:`genarrative-api.service` 配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时 API 会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service` 只传 `--stop-service spacetimedb.service` 而漏掉 `--restart-service-after genarrative-api.service`,备份脚本只会恢复数据库,不会再拉起 API。 -- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。 -- 验证:`systemctl cat genarrative-database-backup.service` 应包含该参数;`systemctl is-active spacetimedb.service genarrative-api.service nginx.service` 全为 `active`;`curl -fsS http://127.0.0.1:3101/v1/ping`、`/healthz`、`/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功。 +- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped;或图片画布生成请求返回队列态后长期显示排队,`external_generation_job` 有 claimable pending,但 `genarrative-external-generation-worker@1.service` / controller 是 inactive。 +- 原因:`genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时这些服务会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service` 只恢复数据库或只重启 API,外部生成队列就不会被消费。 +- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`、`--restart-service-after genarrative-external-generation-worker@1.service` 和 `--restart-service-after genarrative-external-generation-controller.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。 +- 验证:`systemctl cat genarrative-database-backup.service` 应包含这些参数;`systemctl is-active spacetimedb.service genarrative-api.service genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service nginx.service` 全为 `active`;`curl -fsS http://127.0.0.1:3101/v1/ping`、`/healthz`、`/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功;`get_external_generation_queue_stats_and_return` 不应长期出现 claimable pending。 - 关联:`deploy/systemd/genarrative-database-backup.service`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## Pingora Brotli 不能只看 Content-Encoding @@ -2595,6 +2643,14 @@ - 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。 - 关联:`src/services/jump-hop/jumpHopClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 画布生成完成态不能被旧 autosave 覆盖 + +- 现象:release 外部生成 worker 补跑完成后,生成图已进入素材库或项目资源,但画布生成器仍显示 `generating`;刷新后可能仍看到历史生成框卡住。 +- 原因:画布前端在提交生成后会把 `generating` layout 放入 450ms 自动保存队列;worker 完成后后端会写入 `idle + generatedLayerId + 生成层`,但旧的 pending / in-flight layout save 可能晚到并覆盖完成态。另有历史 inline 请求在 api-server 重启时只留下前端已保存的 `generating` 框,没有终态任务或生成资源。 +- 处理:前端 `applyProjectSnapshot` 必须取消 pending layout save,并跳过一次由后端快照恢复触发的 autosave;后端 `save_editor_project_layout` 要保护已完成的 generation dialog,如果传入旧 `generating` 且无 `generatedLayerId`,而当前 layout 已有同一 dialog 的完成态,则保留完成态和生成层。线上脏数据只在确认无任务 / 无资源时标成 `failed` 并保留原 prompt 供用户重试。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx`;`cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml editor_project_storage --lib`;release 排障用 `list_editor_projects_and_return` / `get_editor_project_and_return` 查 `generation-dialog` 状态,不要只看素材库。 +- 关联:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`。 + ## Pingora 静态缓存不能只写 Cache-Control - 现象:直连 Pingora 后,HTML 入口虽然是 `Cache-Control: no-cache`,但浏览器每次都重新下载完整入口页或普通静态文件;或者 Vite 指纹资源长期缓存正常,但旧标签页刷新时协商缓存行为和 Nginx 直连不同。 diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md index 395aae8ca..bac119faa 100644 --- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md +++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md @@ -132,6 +132,7 @@ assetObjectId > objectKey > sourceAssetId > src 4. 对每个素材源读取 Blob: - `data:image/...` 直接转换为 Blob。 - 同源或可访问 URL 使用 `fetch` 拉取 Blob。 + - 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。 - `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。 - 拉取失败时记录失败项,不中断整个导出。 5. 使用 `JSZip` 写入 `images/`、`metadata.json` 和 `manifest.txt`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 441a1dddd..baee4e762 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -46,7 +46,8 @@ - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 - 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 -- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。 +- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 +- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。视频结果当前只保存为画布视频资源,不进入图片素材库。 @@ -111,6 +112,7 @@ - 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。 - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;仅在无法解析尺寸时才使用上传兜底尺寸。 - 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `