合并画板素材导出体验
# 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:
@@ -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}` : '';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 资源和假组合都不进入精选。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
@@ -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
Reference in New Issue
Block a user