合并画板素材导出体验

# Conflicts:
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	src/components/image-editor/ImageCanvasExportModel.test.ts
#	src/components/image-editor/ImageCanvasExportModel.ts
This commit is contained in:
2026-06-25 00:33:17 +08:00
62 changed files with 4323 additions and 377 deletions
+2
View File
@@ -441,6 +441,8 @@ 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}` : '';
}
+4 -1
View File
@@ -54,11 +54,13 @@ export interface AdminOverviewResponse {
database: AdminDatabaseOverviewPayload;
}
export type AdminDashboardGranularity = 'day' | 'week' | 'month';
export type AdminDashboardGranularity = 'day' | 'week' | 'month' | 'period';
export interface AdminDashboardQuery {
granularity?: AdminDashboardGranularity;
anchor?: string;
startDate?: string;
endDate?: string;
}
export interface AdminDashboardResponse {
@@ -82,6 +84,7 @@ export interface AdminDashboardMetricsPayload {
generatedAssets: number;
consumedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
visitUsers: number;
totalVisitUsers: number;
visitCount: number;
@@ -1,12 +1,12 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import {getAdminDashboard} from '../api/adminApiClient';
import type {AdminDashboardResponse} from '../api/adminApiTypes';
import {AdminDashboardPage} from './AdminDashboardPage';
import { getAdminDashboard } from '../api/adminApiClient';
import type { AdminDashboardResponse } from '../api/adminApiTypes';
import { AdminDashboardPage } from './AdminDashboardPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -28,6 +28,7 @@ const dashboardResponse: AdminDashboardResponse = {
generatedAssets: 12,
consumedMudPoints: 88,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
visitUsers: 34,
totalVisitUsers: 456,
visitCount: 98,
@@ -40,7 +41,7 @@ const dashboardResponse: AdminDashboardResponse = {
title: '生产素材',
unit: '个',
total: 12,
buckets: [{key: '2026-06-23', label: '2026-06-23', value: 12}],
buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }],
},
],
operations: {
@@ -53,9 +54,9 @@ const dashboardResponse: AdminDashboardResponse = {
},
],
assetKindBreakdown: [
{key: 'editor_generated_image', label: '画板图片', value: 9},
{ key: 'editor_generated_image', label: '画板图片', value: 9 },
],
moduleVisitBreakdown: [{key: 'auth', label: '认证', value: 30}],
moduleVisitBreakdown: [{ key: 'auth', label: '认证', value: 30 }],
},
warnings: [],
generatedAt: '2026-06-23T12:00:00Z',
@@ -70,8 +71,9 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('日总生产素材数')).toBeTruthy();
expect(await screen.findByText('日总生产素材数')).toBeTruthy();
expect(screen.getByText('总注册用户')).toBeTruthy();
expect(screen.getByText('本日新增用户数')).toBeTruthy();
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
expect(screen.getByText('生产素材')).toBeTruthy();
@@ -82,38 +84,64 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
expect(screen.getByText('访问模块分布')).toBeTruthy();
});
test('Dashboard 选择周时带上 week granularity 和周一 anchor', async () => {
test('Dashboard 选择周时按当前终止日期填充整周范围', async () => {
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('日总生产素材数');
await user.click(screen.getByRole('button', { name: '周' }));
fireEvent.change(screen.getByLabelText('周'), {
target: {value: '2026-W27'},
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-06-29',
anchor: '2026-07-05',
startDate: '2026-06-29',
endDate: '2026-07-05',
});
});
});
test('Dashboard 选择月份时带上 month granularity 和月初 anchor', async () => {
test('Dashboard 选择本月时按当前终止日期填充整月范围', async () => {
const user = userEvent.setup();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('日总生产素材数');
await user.click(screen.getByRole('button', { name: '月' }));
fireEvent.change(screen.getByLabelText('月份'), {
target: {value: '2026-07'},
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-01',
anchor: '2026-07-31',
startDate: '2026-07-01',
endDate: '2026-07-31',
});
});
});
test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
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();
});
+124 -105
View File
@@ -1,14 +1,14 @@
import {RefreshCw} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {getAdminDashboard} from '../api/adminApiClient';
import { getAdminDashboard } from '../api/adminApiClient';
import type {
AdminDashboardBreakdownRowPayload,
AdminDashboardChartPayload,
AdminDashboardGranularity,
AdminDashboardResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminDashboardPageProps {
token: string;
@@ -21,9 +21,10 @@ const granularityOptions: Array<{
id: AdminDashboardGranularity;
label: string;
}> = [
{id: 'day', label: '日'},
{id: 'week', label: '周'},
{id: 'month', label: '月'},
{ id: 'day', label: '日' },
{ id: 'week', label: '周' },
{ id: 'month', label: '月' },
{ id: 'period', label: '本时段' },
];
export function AdminDashboardPage({
@@ -35,11 +36,13 @@ export function AdminDashboardPage({
);
const [granularity, setGranularity] =
useState<AdminDashboardGranularity>('day');
const [anchor, setAnchor] = useState(() => formatDateInput(new Date()));
const [dateRange, setDateRange] = useState(() => {
const today = formatDateInput(new Date());
return { startDate: today, endDate: today };
});
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const anchorInput = buildAnchorInput(granularity, anchor);
const loadDashboard = useCallback(async () => {
setIsLoading(true);
@@ -47,7 +50,9 @@ export function AdminDashboardPage({
try {
const response = await getAdminDashboard(token, {
granularity,
anchor,
anchor: granularity === 'period' ? undefined : dateRange.endDate,
startDate: dateRange.startDate,
endDate: dateRange.endDate,
});
setDashboard(response);
} catch (error: unknown) {
@@ -55,7 +60,13 @@ export function AdminDashboardPage({
} finally {
setIsLoading(false);
}
}, [anchor, granularity, onUnauthorized, token]);
}, [
dateRange.endDate,
dateRange.startDate,
granularity,
onUnauthorized,
token,
]);
useEffect(() => {
void loadDashboard();
@@ -92,6 +103,12 @@ export function AdminDashboardPage({
value: metrics?.totalRegisteredUsers ?? 0,
unit: '人',
},
{
id: 'new-registered-users',
label: `${rangePrefix(granularity)}新增用户数`,
value: metrics?.newRegisteredUsers ?? 0,
unit: '人',
},
{
id: 'visit-users',
label: `${rangePrefix(granularity)}访问人数`,
@@ -134,6 +151,28 @@ export function AdminDashboardPage({
<p>{dashboard?.range.periodLabel ?? '运营指标'}</p>
</div>
<div className="admin-dashboard-actions">
<div className="admin-dashboard-period-fields">
<label className="admin-field admin-dashboard-date-field">
<span></span>
<input
type="date"
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
}
/>
</label>
<label className="admin-field admin-dashboard-date-field">
<span></span>
<input
type="date"
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
}
/>
</label>
</div>
<div
className="admin-segmented-control admin-dashboard-granularity"
role="tablist"
@@ -144,29 +183,12 @@ export function AdminDashboardPage({
key={option.id}
type="button"
data-active={option.id === granularity}
onClick={() => setGranularity(option.id)}
onClick={() => handleGranularityChange(option.id)}
>
{option.label}
</button>
))}
</div>
<label className="admin-field admin-dashboard-date-field">
<span>{anchorInput.label}</span>
<input
key={granularity}
type={anchorInput.type}
value={anchorInput.value}
onChange={(event) => {
const nextAnchor = normalizeAnchorInputValue(
granularity,
event.target.value,
);
if (nextAnchor) {
setAnchor(nextAnchor);
}
}}
/>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
@@ -276,6 +298,28 @@ export function AdminDashboardPage({
)}
</section>
);
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({
@@ -301,7 +345,7 @@ function MetricCard({
);
}
function ChartPanel({chart}: {chart: AdminDashboardChartPayload}) {
function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
return (
<section className="admin-panel admin-dashboard-chart-card">
@@ -381,41 +425,6 @@ function formatDateInput(date: Date) {
return `${year}-${month}-${day}`;
}
function buildAnchorInput(granularity: AdminDashboardGranularity, anchor: string) {
if (granularity === 'week') {
return {
label: '周',
type: 'week',
value: dateToWeekInputValue(anchor),
};
}
if (granularity === 'month') {
return {
label: '月份',
type: 'month',
value: anchor.slice(0, 7),
};
}
return {
label: '日期',
type: 'date',
value: anchor,
};
}
function normalizeAnchorInputValue(
granularity: AdminDashboardGranularity,
value: string,
) {
if (granularity === 'week') {
return weekInputValueToMondayDate(value);
}
if (granularity === 'month') {
return monthInputValueToFirstDate(value);
}
return parseDateValueAsUtc(value) ? value : '';
}
function rangePrefix(granularity: AdminDashboardGranularity) {
if (granularity === 'week') {
return '本周';
@@ -423,55 +432,65 @@ function rangePrefix(granularity: AdminDashboardGranularity) {
if (granularity === 'month') {
return '本月';
}
return '今日';
if (granularity === 'period') {
return '本时段';
}
return '本日';
}
function dateToWeekInputValue(dateValue: string) {
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 '';
return { startDate: dateValue, endDate: dateValue };
}
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - day);
const weekYear = date.getUTCFullYear();
const yearStart = new Date(Date.UTC(weekYear, 0, 1));
const week = Math.ceil(
((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7,
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),
);
return `${weekYear}-W${String(week).padStart(2, '0')}`;
const lastDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
);
return {
startDate: formatUtcDate(firstDate),
endDate: formatUtcDate(lastDate),
};
}
function weekInputValueToMondayDate(value: string) {
const match = /^(\d{4})-W(\d{2})$/.exec(value);
if (!match) {
return '';
}
const year = Number(match[1]);
const week = Number(match[2]);
if (!Number.isInteger(year) || week < 1 || week > 53) {
return '';
}
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Day = jan4.getUTCDay() || 7;
const monday = new Date(jan4);
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7);
const mondayDate = formatUtcDate(monday);
return dateToWeekInputValue(mondayDate) === value ? mondayDate : '';
}
function monthInputValueToFirstDate(value: string) {
const match = /^(\d{4})-(\d{2})$/.exec(value);
if (!match) {
return '';
}
const year = Number(match[1]);
const month = Number(match[2]);
const firstDate = `${year}-${String(month).padStart(2, '0')}-01`;
return parseDateValueAsUtc(firstDate) ? firstDate : '';
function normalizeDateRange(range: { startDate: string; endDate: string }) {
return range.startDate > range.endDate
? { startDate: range.endDate, endDate: range.startDate }
: range;
}
function parseDateValueAsUtc(value: string) {
+17 -1
View File
@@ -284,10 +284,17 @@ button:disabled {
}
.admin-dashboard-granularity {
width: 188px;
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;
}
@@ -1279,11 +1286,16 @@ button:disabled {
}
.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));
@@ -1351,6 +1363,10 @@ button:disabled {
width: 100%;
}
.admin-dashboard-period-fields {
grid-template-columns: 1fr;
}
.admin-dashboard-metric-grid,
.admin-dashboard-operation-grid {
grid-template-columns: 1fr;
@@ -527,7 +527,7 @@
- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。
- 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”,不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。
- 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。
- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz``/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。
- 验证方式:本地 `npm run dev` `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程监听 HTTP 并消费外部生成队列;验证生产式拆分角色、lease 或扩缩容时分别启动 `api``external-generation-worker``external-generation-controller`,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz``/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板
@@ -3710,6 +3710,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`
- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计
- 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`
@@ -31,6 +31,14 @@
- 验证:`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`
## 画板外部生成排队超时不是失败
- 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。
@@ -239,6 +247,22 @@
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`
- 关联:`src/components/image-editor/ImageCanvasGenerationPlacementModel.ts``src/components/image-editor/useImageCanvasGenerationWorkflow.ts``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 图片画布图片改造也必须创建独立生成器占位
- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。
- 原因:图片改造复用了旧 `QuickEditPanelState` / redraw 面板路径,只把源图选中并在原图附近打开快速编辑框,没有进入统一的 `CanvasGenerationDialogState` 占位链路。
- 处理:图片和用户快照图层的“改造”统一创建 `mode="quick-edit"` 的 generation dialog,占位仍走 `ImageCanvasGenerationPlacementModel`;源图作为隐式最后一张参考图提交,并把“当前图”提示词归一到对应参考图编号。音频改造继续走音频生成器路径,非图片 fallback 才保留旧面板。参考图 Data URL 提交前可压缩,但浏览器图片解码卡住时必须超时透传原图,不能阻塞生成请求。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorImageReference.test.ts -- --runInBand`,以及 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "hides quick edit and redraw panels|opens generated image info|shows the quick edit generator" -- --runInBand`
- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorImageReference.ts`
## 图片画布生成完成应用项目快照后也要刷新素材库
- 现象:部分素材生成成功后画布上已经出现结果,但左侧素材库没有立刻出现新素材,刷新页面后才显示。
- 原因:生成接口带 `project` 快照时,前端只调用 `applyProjectSnapshot(...)` 刷新画布布局;左侧素材库状态仍停留在首次 `loadEditorAssetLibrary()` 的结果。只有少数图片分支手动 `upsertGeneratedAsset`,图标素材图集、视频、音频、排队完成后重新 `loadEditorProject` 等分支不会统一更新素材库。
- 处理:素材库 hook 必须提供显式 `refreshAssetLibrary()`;传给生成工作流的项目快照应用函数应包装为“先应用项目快照,再刷新素材库”。新增生成分支不要在各自分支散落刷新逻辑,除非是无项目快照的本地图层回填,才继续使用 `generatedAssetSnapshot` / `upsertGeneratedAsset`
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "refreshes the asset library after an icon generation project snapshot is applied" -- --runInBand``npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx -- --runInBand`
- 关联:`src/components/image-editor/useImageCanvasAssetLibrary.ts``src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`
## Windows 本地 dev 不要把 RUSTC_WRAPPER 绕过写成 rustc
- 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)``/healthz` 无法访问。
@@ -56,12 +56,29 @@
│ ├─ frames/
│ │ ├─ frame-01.png
│ │ └─ frame-02.png
│ ├─ skeleton.json
│ ├─ README.md
│ ├─ metadata.json
│ └─ manifest.txt
├─ metadata.json
└─ manifest.txt
```
角色动作单图层导出时,直接生成独立 ZIP:
```text
角色动作-SpineJSON.zip
├─ frames/
│ ├─ frame-01.png
│ └─ frame-02.png
├─ skeleton.json
├─ README.md
├─ metadata.json
└─ manifest.txt
```
`skeleton.json` 使用 Spine JSON 4.2 结构表达序列帧:一个 `root` bone、一个 `character` slot、每帧一个 region attachment`animations.animation.slots.character.attachment` 按时间切换 attachment。它不是 `.spine` 编辑器工程,也不包含 mesh、权重、IK 或拆件绑定。
## 元数据结构
`metadata.json` 记录项目、导出时间、图层和图片文件映射:
@@ -134,6 +151,7 @@ assetObjectId > objectKey > sourceAssetId > src
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
- 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`
- `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。
- 拉取失败时记录失败项,不中断整个导出。
5. 使用 `JSZip` 写入 `images/``metadata.json``manifest.txt`
6. `zip.generateAsync({ type: 'blob' })` 生成文件。
@@ -158,6 +176,32 @@ assetObjectId > objectKey > sourceAssetId > src
- 图层的锁定、翻转、分组状态写入元数据。
- `data:image` 图片不经过网络请求即可导出。
- URL 图片 fetch 失败时不中断其他素材导出。
- 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。
## Spine JSON 验证器
导出产物验证项目位于 `tools/spine-json-export-validator/`,独立于主站构建。它用 `@esotericsoftware/spine-webgl``SkeletonJson`、自定义 `AttachmentLoader``GLTexture``SceneRenderer` 直接验证导出的 `skeleton.json + frames/`,不要求 atlas。
首次使用安装验证器自己的依赖:
```bash
npm --prefix tools/spine-json-export-validator install
```
本地启动:
```bash
npm run spine-export-validator:dev
```
检查命令:
```bash
npm run spine-export-validator:typecheck
npm run spine-export-validator:build
```
验证器支持根目录 `skeleton.json`,也支持画布素材 ZIP 内 `sequences/<编号-标题>/skeleton.json`。当前只验证 region attachment;如果未来导出 mesh、sequence attachment 或多动画命名,需要同步扩展验证器的 loader 和 UI 摘要。
## 后续扩展
@@ -8,11 +8,12 @@
## 查询参数
`GET /admin/api/dashboard?granularity=day|week|month&anchor=YYYY-MM-DD`
`GET /admin/api/dashboard?granularity=day|week|month|period&anchor=YYYY-MM-DD&startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`
- `granularity` 默认为 `day`
- `anchor` 使用北京时间日历日期。`week``month` 选择包含该日期的自然周 / 自然月。
- 前端在日 / 周 / 月模式分别展示日期、周、月份选择器;周选择器会把选中的周转换为该周周一作为 `anchor`,月份选择器会转换为当月 1 日作为 `anchor`
- `anchor` 使用北京时间日历日期,保留给 `day` / `week` / `month` 兼容旧查询口径`week``month` 选择包含该日期的自然周 / 自然月。
- `period` 使用 `startDate` / `endDate` 作为闭区间自定义时段,最多 366 天
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”会按当前终止日期自动填充对应自然日 / 自然周 / 自然月范围,手动修改起止日期或点击“本时段”后按当前日期范围查询。
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
## 指标口径
@@ -20,6 +21,7 @@
- 生产素材数:`editor_project_resource``source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。
- 消耗泥点数:`profile_wallet_ledger``source_type = asset_operation_consume``amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。
- 总注册用户:`profile_dashboard_state` 行数。
- 新增用户数:`profile_dashboard_state``created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月 / 本时段切换。
- 访问次数:`tracking_daily_stat``scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat``scope_kind = user``scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。
- 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
@@ -208,11 +208,11 @@ npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.tes
本地 smoke
```bash
GENARRATIVE_PROCESS_ROLE=all npm run dev
npm run dev
curl -f http://127.0.0.1:<api-port>/healthz
```
本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。
本地 `npm run dev` `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api``external-generation-worker``external-generation-controller`,也可以使用隔离容器 smoke。
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
@@ -468,7 +468,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`EditorProjectResource`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind``generation_inputs_json``public_showcase_enabled``asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation``陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot``source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind``generation_inputs_json``public_showcase_enabled``asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation``陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot``source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开响应必须透传 `ownerUserId`,供精选卡片在缺少作者昵称时显示作者 id。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
- 索引:`by_editor_project_resource_project_id``by_editor_project_resource_owner_user_id`
### `editor_asset_folder`
File diff suppressed because one or more lines are too long
@@ -63,7 +63,7 @@
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
精选内容只使用 `editor_project_resource``sourceType="generated"``public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。若现有数据缺少提示词、作者或成本字段,v1 显示保守占位,不伪造内容。
精选内容只使用 `editor_project_resource``sourceType="generated"``public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。公开 BFF 必须返回资源 `ownerUserId`,前端在没有作者昵称字段时用该 id 作为作者兜底。若现有数据缺少提示词、作者标识或成本字段,v1 显示保守占位,不伪造内容。
Tab
@@ -100,7 +100,7 @@ Tab
数据来源:
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,只保留 `sourceType="generated"``publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,快照包含 `ownerUserId` 作为作者展示兜底;前端只保留 `sourceType="generated"``publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。
- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。
- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
+3
View File
@@ -66,6 +66,9 @@
"deploy:pingora-direct-rollback": "bash scripts/deploy/pingora-direct-rollback.sh",
"assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs",
"assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs",
"spine-export-validator:dev": "npm --prefix tools/spine-json-export-validator run dev --",
"spine-export-validator:typecheck": "npm --prefix tools/spine-json-export-validator run typecheck",
"spine-export-validator:build": "npm --prefix tools/spine-json-export-validator run build",
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
"check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs",
+1
View File
@@ -2253,6 +2253,7 @@ function buildApiServerProcessEnv({
...baseEnv,
// 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true',
GENARRATIVE_PROCESS_ROLE: baseEnv.GENARRATIVE_PROCESS_ROLE || 'all',
GENARRATIVE_API_HOST: options.apiHost,
GENARRATIVE_API_PORT: String(options.apiPort),
GENARRATIVE_API_LOG: options.apiLog,
+12
View File
@@ -211,10 +211,22 @@ describe('dev scheduler api-server env', () => {
});
expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe('true');
expect(env.GENARRATIVE_PROCESS_ROLE).toBe('all');
expect(env.GENARRATIVE_API_PORT).toBe('9091');
expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199');
});
test('dev 脚本保留显式 api-server 进程角色', () => {
const {options} = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: {GENARRATIVE_PROCESS_ROLE: 'api'},
options,
state: {spacetimeServer: 'http://127.0.0.1:3199'},
});
expect(env.GENARRATIVE_PROCESS_ROLE).toBe('api');
});
test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-'));
try {
+166 -2
View File
@@ -62,6 +62,7 @@ const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000;
const ADMIN_DATABASE_TABLE_DEFAULT_LIMIT: u32 = 100;
const ADMIN_DATABASE_TABLE_MAX_LIMIT: u32 = 500;
const ADMIN_DASHBOARD_ROW_LIMIT: u32 = 50_000;
const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366;
const ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000;
#[derive(Clone, Debug)]
@@ -633,6 +634,7 @@ enum AdminDashboardGranularity {
Day,
Week,
Month,
Period,
}
struct AdminDashboardRangeResolved {
@@ -661,6 +663,12 @@ struct AdminDashboardWalletStats {
warnings: Vec<String>,
}
#[derive(Default)]
struct AdminDashboardRegistrationStats {
new_registered_users: AdminDashboardSeries,
warnings: Vec<String>,
}
#[derive(Default)]
struct AdminDashboardTrackingStats {
visit_count: AdminDashboardSeries,
@@ -698,15 +706,20 @@ async fn build_admin_dashboard(
let wallet_stats = fetch_admin_dashboard_wallet_stats(state, &range).await;
warnings.extend(wallet_stats.warnings.iter().cloned());
let registration_stats = fetch_admin_dashboard_registration_stats(state, &range).await;
warnings.extend(registration_stats.warnings.iter().cloned());
let tracking_stats = fetch_admin_dashboard_tracking_stats(state, &range).await;
warnings.extend(tracking_stats.warnings.iter().cloned());
let generated_assets = asset_stats.generated_assets.total();
let consumed_mud_points = wallet_stats.consumed_mud_points.total();
let new_registered_users = registration_stats.new_registered_users.total();
let metrics = AdminDashboardMetricsPayload {
generated_assets,
consumed_mud_points,
total_registered_users,
new_registered_users,
visit_users: tracking_stats.visit_users,
total_visit_users: tracking_stats.total_visit_users,
visit_count: tracking_stats.visit_count.total(),
@@ -775,11 +788,16 @@ fn resolve_admin_dashboard_range(
"day" => AdminDashboardGranularity::Day,
"week" => AdminDashboardGranularity::Week,
"month" => AdminDashboardGranularity::Month,
"period" => AdminDashboardGranularity::Period,
_ => {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("dashboard granularity 只支持 day/week/month"));
.with_message("dashboard granularity 只支持 day/week/month/period"));
}
};
if matches!(granularity, AdminDashboardGranularity::Period) {
return resolve_admin_dashboard_period_range(query);
}
let anchor_date_key = match query
.anchor
.as_deref()
@@ -805,6 +823,7 @@ fn resolve_admin_dashboard_range(
AdminDashboardGranularity::Month => {
(dimension.month_start_date_key, dimension.month_end_date_key)
}
AdminDashboardGranularity::Period => unreachable!("period range is resolved above"),
};
Ok(AdminDashboardRangeResolved {
granularity,
@@ -814,6 +833,52 @@ fn resolve_admin_dashboard_range(
})
}
fn resolve_admin_dashboard_period_range(
query: AdminDashboardQuery,
) -> Result<AdminDashboardRangeResolved, AppError> {
let start_date_key = parse_admin_dashboard_date_param(
query.start_date.as_deref(),
"dashboard startDate 必须是合法 YYYY-MM-DD 日期",
)?;
let end_date_key = parse_admin_dashboard_date_param(
query.end_date.as_deref(),
"dashboard endDate 必须是合法 YYYY-MM-DD 日期",
)?;
if start_date_key > end_date_key {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("dashboard startDate 不能晚于 endDate"));
}
if end_date_key - start_date_key + 1 > ADMIN_DASHBOARD_MAX_PERIOD_DAYS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_message(format!(
"dashboard 自定义时段最多支持 {}",
ADMIN_DASHBOARD_MAX_PERIOD_DAYS
)),
);
}
Ok(AdminDashboardRangeResolved {
granularity: AdminDashboardGranularity::Period,
anchor_date_key: end_date_key,
start_date_key,
end_date_key,
})
}
fn parse_admin_dashboard_date_param(
value: Option<&str>,
error_message: &str,
) -> Result<i64, AppError> {
let date = value
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error_message.to_string())
})?;
module_runtime::parse_analytics_calendar_date_key(date).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error_message.to_string())
})
}
fn build_admin_dashboard_range_payload(
range: &AdminDashboardRangeResolved,
) -> AdminDashboardRangePayload {
@@ -829,6 +894,7 @@ fn build_admin_dashboard_range_payload(
let month_key = dimension.month_key;
format!("{:04}-{:02}", month_key / 100, month_key % 100)
}
AdminDashboardGranularity::Period => format!("{start}{end}"),
};
AdminDashboardRangePayload {
granularity: range.granularity.as_str().to_string(),
@@ -932,6 +998,50 @@ async fn fetch_admin_dashboard_wallet_stats(
stats
}
async fn fetch_admin_dashboard_registration_stats(
state: &AppState,
range: &AdminDashboardRangeResolved,
) -> AdminDashboardRegistrationStats {
let mut stats = AdminDashboardRegistrationStats::default();
let sql = format!(
"SELECT created_at FROM profile_dashboard_state LIMIT {}",
ADMIN_DASHBOARD_ROW_LIMIT
);
let rows = match fetch_admin_dashboard_rows(state, &sql).await {
Ok(rows) => rows,
Err(message) => {
stats
.warnings
.push(format!("新增用户统计读取失败:{message}"));
return stats;
}
};
warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "新增用户", rows.len());
apply_admin_dashboard_registration_rows(&mut stats, range, rows.iter());
stats
}
fn apply_admin_dashboard_registration_rows<'a, I>(
stats: &mut AdminDashboardRegistrationStats,
range: &AdminDashboardRangeResolved,
rows: I,
) where
I: IntoIterator<Item = &'a Value>,
{
for row in rows {
let Some(columns) = row.as_array() else {
continue;
};
let Some(created_at_micros) = columns.first().and_then(timestamp_value_to_micros) else {
continue;
};
let day_key = module_runtime::runtime_profile_beijing_day_key(created_at_micros);
if range.contains_day_key(day_key) {
stats.new_registered_users.add(day_key, 1);
}
}
}
async fn fetch_admin_dashboard_tracking_stats(
state: &AppState,
range: &AdminDashboardRangeResolved,
@@ -1197,6 +1307,7 @@ fn admin_dashboard_bucket_label(day_key: i64, granularity: AdminDashboardGranula
.next()
.map(ToOwned::to_owned)
.unwrap_or(date),
AdminDashboardGranularity::Period => date.split('-').skip(1).collect::<Vec<_>>().join("/"),
}
}
@@ -1210,6 +1321,7 @@ impl AdminDashboardGranularity {
Self::Day => "day",
Self::Week => "week",
Self::Month => "month",
Self::Period => "period",
}
}
}
@@ -2339,7 +2451,8 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
#[cfg(test)]
mod tests {
use super::{
AdminDashboardGranularity, apply_admin_database_table_filters,
AdminDashboardGranularity, AdminDashboardRegistrationStats, AdminDashboardSeries,
apply_admin_dashboard_registration_rows, apply_admin_database_table_filters,
build_admin_database_table_row, build_admin_tracking_events_sql, build_body_preview,
build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit,
clamp_admin_tracking_event_limit, is_safe_spacetime_table_name, normalize_debug_path,
@@ -2520,6 +2633,7 @@ mod tests {
let day = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("day".to_string()),
anchor: Some("2026-06-23".to_string()),
..Default::default()
})
.expect("day range should resolve");
assert_eq!(day.granularity, AdminDashboardGranularity::Day);
@@ -2532,6 +2646,7 @@ mod tests {
let week = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("week".to_string()),
anchor: Some("2026-06-23".to_string()),
..Default::default()
})
.expect("week range should resolve");
assert_eq!(
@@ -2546,6 +2661,7 @@ mod tests {
let month = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("month".to_string()),
anchor: Some("2026-06-23".to_string()),
..Default::default()
})
.expect("month range should resolve");
assert_eq!(
@@ -2558,6 +2674,54 @@ mod tests {
);
}
#[test]
fn resolve_admin_dashboard_range_supports_custom_period() {
let period = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("period".to_string()),
start_date: Some("2026-06-10".to_string()),
end_date: Some("2026-06-18".to_string()),
..Default::default()
})
.expect("period range should resolve");
assert_eq!(period.granularity, AdminDashboardGranularity::Period);
assert_eq!(
period.start_date_key,
module_runtime::parse_analytics_calendar_date_key("2026-06-10").unwrap()
);
assert_eq!(
period.end_date_key,
module_runtime::parse_analytics_calendar_date_key("2026-06-18").unwrap()
);
assert_eq!(period.anchor_date_key, period.end_date_key);
}
#[test]
fn apply_admin_dashboard_registration_rows_counts_created_accounts_in_range() {
let range = resolve_admin_dashboard_range(AdminDashboardQuery {
granularity: Some("period".to_string()),
start_date: Some("2026-06-10".to_string()),
end_date: Some("2026-06-18".to_string()),
..Default::default()
})
.expect("period range should resolve");
let rows = vec![
json!(["2026-06-09T15:59:59Z"]),
json!(["2026-06-10T00:30:00Z"]),
json!(["2026-06-18T15:59:59Z"]),
json!(["2026-06-18T16:00:00Z"]),
json!(["bad-timestamp"]),
];
let mut stats = AdminDashboardRegistrationStats {
new_registered_users: AdminDashboardSeries::default(),
warnings: Vec::new(),
};
apply_admin_dashboard_registration_rows(&mut stats, &range, rows.iter());
assert_eq!(stats.new_registered_users.total(), 2);
}
#[test]
fn timestamp_value_to_micros_accepts_sql_shapes() {
assert_eq!(
@@ -478,6 +478,7 @@ pub struct EditorCanvasPayload {
pub struct EditorProjectResourcePayload {
resource_id: String,
project_id: String,
owner_user_id: String,
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
@@ -2480,6 +2481,7 @@ pub(crate) fn editor_project_resource_payload_from_record(
EditorProjectResourcePayload {
resource_id: record.resource_id,
project_id: record.project_id,
owner_user_id: record.owner_user_id,
image_src,
object_key: record.object_key,
asset_object_id: record.asset_object_id,
@@ -3530,7 +3532,7 @@ fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool
fn build_editor_character_image_prompt(role_setting: &str) -> String {
vec![
"严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止生成美术视觉规范出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
"按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
format!("角色设定:{}", role_setting.trim()),
]
.join("\n")
@@ -3852,11 +3854,42 @@ mod tests {
);
}
#[test]
fn editor_project_resource_payload_includes_owner_user_id() {
let payload = editor_project_resource_payload_from_record(EditorProjectResourceRecord {
resource_id: "resource-1".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-author-1".to_string(),
asset_object_id: None,
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: None,
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: None,
generation_inputs: None,
public_showcase_enabled: true,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
});
let value = serde_json::to_value(payload).expect("payload should serialize");
assert_eq!(value["ownerUserId"], json!("user-author-1"));
}
#[test]
fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() {
let resources = vec![EditorProjectResourcePayload {
resource_id: "resource-1".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: Some("generated-character-drafts/editor/spec.png".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
@@ -3928,6 +3961,7 @@ mod tests {
let existing_resources = vec![EditorProjectResourcePayload {
resource_id: "resource-spec".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: Some("generated-character-drafts/editor/spec.png".to_string()),
asset_object_id: Some("asset-object-spec".to_string()),
@@ -3995,6 +4029,7 @@ mod tests {
let generated_resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
image_src: "/generated-character-drafts/editor/character.png".to_string(),
object_key: Some("generated-character-drafts/editor/character.png".to_string()),
asset_object_id: Some("asset-object-character".to_string()),
@@ -4452,6 +4487,7 @@ mod tests {
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
image_src: "data:image/png;base64,character".to_string(),
object_key: Some("generated-character-drafts/editor/character.png".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
@@ -4552,6 +4588,7 @@ mod tests {
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
image_src: "data:image/png;base64,character".to_string(),
object_key: None,
asset_object_id: None,
@@ -4633,6 +4670,7 @@ mod tests {
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
image_src: "data:image/png;base64,character".to_string(),
object_key: None,
asset_object_id: None,
@@ -4755,6 +4793,7 @@ mod tests {
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
image_src: "data:image/png;base64,character".to_string(),
object_key: None,
asset_object_id: None,
@@ -157,12 +157,17 @@ pub struct AdminOverviewResponse {
pub database: AdminDatabaseOverviewPayload,
}
/// 后台 dashboard 查询参数;anchor 使用北京时间日历日期 YYYY-MM-DD。
/// 后台 dashboard 查询参数;日期均使用北京时间日历日期 YYYY-MM-DD。
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardQuery {
pub granularity: Option<String>,
/// day / week / month 兼容旧查询口径,表示被选中的日期。
pub anchor: Option<String>,
/// period 自定义时段起始日期,闭区间。
pub start_date: Option<String>,
/// period 自定义时段终止日期,闭区间。
pub end_date: Option<String>,
}
/// 后台 dashboard 返回运营指标、趋势图和读取告警。
@@ -195,6 +200,7 @@ pub struct AdminDashboardMetricsPayload {
pub generated_assets: u64,
pub consumed_mud_points: u64,
pub total_registered_users: u64,
pub new_registered_users: u64,
pub visit_users: u64,
pub total_visit_users: u64,
pub visit_count: u64,
@@ -43,6 +43,7 @@ pub struct EditorProjectLayoutSaveAckRecord {
pub struct EditorProjectResourceRecord {
pub resource_id: String,
pub project_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
@@ -677,6 +678,7 @@ fn map_editor_project_resource_snapshot(
Ok(EditorProjectResourceRecord {
resource_id: snapshot.resource_id,
project_id: snapshot.project_id,
owner_user_id: snapshot.owner_user_id,
asset_object_id: snapshot.asset_object_id,
image_src: snapshot.image_src,
object_key: snapshot.object_key,
@@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub struct EditorProjectResourceSnapshot {
pub resource_id: String,
pub project_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
@@ -229,6 +229,7 @@ pub struct EditorProjectResourcePublicShowcaseListInput {
pub struct EditorProjectResourceSnapshot {
pub resource_id: String,
pub project_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
@@ -1956,6 +1957,7 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour
EditorProjectResourceSnapshot {
resource_id: row.resource_id,
project_id: row.project_id,
owner_user_id: row.owner_user_id,
asset_object_id: row.asset_object_id,
image_src: row.image_src,
object_key: row.object_key,
@@ -76,6 +76,23 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.label).toBe('公开素材');
});
it('uses the public project resource owner user id as showcase author fallback', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
projectResources: [
createResource({
resourceId: 'owner-resource',
label: '作者兜底素材',
assetKind: 'character',
ownerUserId: 'user-author-1',
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.author).toBe('user-author-1');
});
it('deduplicates canvas copies that point back to the same generated resource', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
@@ -68,6 +68,7 @@ const SHOWCASE_AUTHOR_KEYS = [
'ownerLabel',
'ownerName',
'authorDisplayName',
'ownerUserId',
] as const;
export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [
@@ -409,6 +409,7 @@ export function ImageCanvasAssetRowView({
<button
type="button"
role="menuitemcheckbox"
className="image-canvas-editor__asset-context-menu-toggle"
aria-checked={publicShowcaseEnabled}
disabled={!canShowcaseAsset}
onClick={() => {
@@ -419,8 +420,17 @@ export function ImageCanvasAssetRowView({
closeMenu();
}}
>
<span aria-hidden="true">{publicShowcaseEnabled ? '✓' : ''}</span>
<span className="image-canvas-editor__asset-context-menu-label">
</span>
{publicShowcaseEnabled ? (
<span
aria-hidden="true"
className="image-canvas-editor__asset-context-menu-check"
>
</span>
) : null}
</button>
{asset.sourceKind === 'uploaded' ? (
<button

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