合并最新master
保留外部生成 processing 阶段与 warning 降级契约 合入图集快速编辑校验、素材分组与后台统计更新 解决图片画布文档和外部生成状态映射冲突
This commit is contained in:
@@ -14,20 +14,40 @@ if (hookInput && !isGitCommitCommand(extractShellCommand(hookInput))) {
|
||||
}
|
||||
|
||||
const validationSteps = [
|
||||
{
|
||||
label: 'Rust format check',
|
||||
command: npmCommand,
|
||||
args:
|
||||
process.platform === 'win32'
|
||||
? ['/d', '/s', '/c', 'npm run check:rustfmt']
|
||||
: ['run', 'check:rustfmt'],
|
||||
},
|
||||
{
|
||||
label: 'TypeScript typecheck',
|
||||
command: npmCommand,
|
||||
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run typecheck'] : ['run', 'typecheck'],
|
||||
args:
|
||||
process.platform === 'win32'
|
||||
? ['/d', '/s', '/c', 'npm run typecheck']
|
||||
: ['run', 'typecheck'],
|
||||
},
|
||||
{
|
||||
label: 'Admin web typecheck',
|
||||
command: npmCommand,
|
||||
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] : ['run', 'admin-web:typecheck'],
|
||||
args:
|
||||
process.platform === 'win32'
|
||||
? ['/d', '/s', '/c', 'npm run admin-web:typecheck']
|
||||
: ['run', 'admin-web:typecheck'],
|
||||
},
|
||||
{
|
||||
label: 'Rust api-server compile check',
|
||||
command: 'cargo',
|
||||
args: ['check', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
|
||||
args: [
|
||||
'check',
|
||||
'-p',
|
||||
'api-server',
|
||||
'--manifest-path',
|
||||
'server-rs/Cargo.toml',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -66,7 +86,9 @@ function runStep(step) {
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[codex-hook] ${step.label} 启动失败:${result.error.message}`);
|
||||
console.error(
|
||||
`[codex-hook] ${step.label} 启动失败:${result.error.message}`,
|
||||
);
|
||||
return { ok: false, status: 1 };
|
||||
}
|
||||
|
||||
@@ -104,12 +126,15 @@ function extractShellCommand(input) {
|
||||
input?.command,
|
||||
];
|
||||
|
||||
const command = candidates.find(value => typeof value === 'string' && value.trim().length > 0);
|
||||
const command = candidates.find(
|
||||
(value) => typeof value === 'string' && value.trim().length > 0,
|
||||
);
|
||||
if (command) {
|
||||
return command;
|
||||
}
|
||||
|
||||
const shellCommand = input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
|
||||
const shellCommand =
|
||||
input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
|
||||
if (Array.isArray(shellCommand)) {
|
||||
return shellCommand.join(' ');
|
||||
}
|
||||
|
||||
@@ -398,10 +398,11 @@ export function getAdminAssetReadUrl(
|
||||
export function listAdminEditorAssets(
|
||||
token: string,
|
||||
query: AdminEditorAssetListQuery = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return request<AdminEditorAssetListResponse>(
|
||||
`/admin/api/editor-assets${buildEditorAssetListQuery(query)}`,
|
||||
{ token },
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface AdminDashboardMetricsPayload {
|
||||
consumedMudPoints: number;
|
||||
totalRegisteredUsers: number;
|
||||
newRegisteredUsers: number;
|
||||
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
|
||||
day1Retention: AdminDashboardRetentionMetricPayload;
|
||||
day7Retention: AdminDashboardRetentionMetricPayload;
|
||||
visitUsers: number;
|
||||
@@ -137,6 +138,12 @@ export interface AdminDashboardMetricsPayload {
|
||||
currentUsers: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardPaymentConversionPayload {
|
||||
paidUsers: number;
|
||||
newRegisteredUsers: number;
|
||||
rateBasisPoints: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardRetentionMetricPayload {
|
||||
eligibleUsers: number;
|
||||
retainedUsers: number;
|
||||
@@ -460,6 +467,7 @@ export interface AdminEditorAssetPayload {
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
groupTaskId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: Record<string, unknown> | null;
|
||||
sourceResourceId?: string | null;
|
||||
|
||||
@@ -35,6 +35,11 @@ const dashboardResponse: AdminDashboardResponse = {
|
||||
consumedMudPoints: 88,
|
||||
totalRegisteredUsers: 1200,
|
||||
newRegisteredUsers: 16,
|
||||
newUserPaymentConversion: {
|
||||
paidUsers: 5,
|
||||
newRegisteredUsers: 16,
|
||||
rateBasisPoints: 3125,
|
||||
},
|
||||
day1Retention: {
|
||||
eligibleUsers: 12,
|
||||
retainedUsers: 3,
|
||||
@@ -101,7 +106,20 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
|
||||
expect(screen.getByText('本日生产素材数')).toBeTruthy();
|
||||
expect(screen.getByText('总注册用户')).toBeTruthy();
|
||||
expect(screen.getByText('本日新增用户数')).toBeTruthy();
|
||||
expect(screen.getByText('新增用户留存')).toBeTruthy();
|
||||
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
|
||||
const paymentRateCard = screen
|
||||
.getByText('本日新增用户付费率')
|
||||
.closest('article');
|
||||
expect(paymentRateCard).toBeTruthy();
|
||||
expect(
|
||||
within(paymentRateCard as HTMLElement).getByText('31.25%'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(paymentRateCard as HTMLElement).getByText('付费人数 / 新增人数'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(paymentRateCard as HTMLElement).getByText('5 / 16 人'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('次日留存')).toBeTruthy();
|
||||
expect(screen.getByText('25%')).toBeTruthy();
|
||||
expect(screen.getByText('3 / 12 人')).toBeTruthy();
|
||||
@@ -218,6 +236,33 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
|
||||
expect(screen.getByText('本时段新增用户付费率')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Dashboard 新增用户付费率分母为零时显示横线', async () => {
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue({
|
||||
...dashboardResponse,
|
||||
metrics: {
|
||||
...dashboardResponse.metrics,
|
||||
newRegisteredUsers: 0,
|
||||
newUserPaymentConversion: {
|
||||
paidUsers: 0,
|
||||
newRegisteredUsers: 0,
|
||||
rateBasisPoints: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
const paymentRateCard = (
|
||||
await screen.findByText('本日新增用户付费率')
|
||||
).closest('article');
|
||||
expect(paymentRateCard).toBeTruthy();
|
||||
expect(within(paymentRateCard as HTMLElement).getByText('-')).toBeTruthy();
|
||||
expect(
|
||||
within(paymentRateCard as HTMLElement).getByText('0 / 0 人'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
AdminDashboardBreakdownRowPayload,
|
||||
AdminDashboardChartPayload,
|
||||
AdminDashboardGranularity,
|
||||
AdminDashboardRetentionMetricPayload,
|
||||
AdminDashboardResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { handlePageError } from './pageUtils';
|
||||
@@ -292,12 +291,38 @@ export function AdminDashboardPage({
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>新增用户留存</h3>
|
||||
<h3>新增用户转化与留存</h3>
|
||||
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-retention-grid">
|
||||
<RetentionCard label="次日留存" metric={metrics?.day1Retention} />
|
||||
<RetentionCard label="七日留存" metric={metrics?.day7Retention} />
|
||||
<RateCard
|
||||
label={`${rangePrefix(granularity)}新增用户付费率`}
|
||||
numeratorLabel="付费人数"
|
||||
denominatorLabel="新增人数"
|
||||
numerator={metrics?.newUserPaymentConversion?.paidUsers}
|
||||
denominator={
|
||||
metrics?.newUserPaymentConversion?.newRegisteredUsers
|
||||
}
|
||||
rateBasisPoints={
|
||||
metrics?.newUserPaymentConversion?.rateBasisPoints
|
||||
}
|
||||
/>
|
||||
<RateCard
|
||||
label="次日留存"
|
||||
numeratorLabel="留存人数"
|
||||
denominatorLabel="可观察新增人数"
|
||||
numerator={metrics?.day1Retention.retainedUsers}
|
||||
denominator={metrics?.day1Retention.eligibleUsers}
|
||||
rateBasisPoints={metrics?.day1Retention.rateBasisPoints}
|
||||
/>
|
||||
<RateCard
|
||||
label="七日留存"
|
||||
numeratorLabel="留存人数"
|
||||
denominatorLabel="可观察新增人数"
|
||||
numerator={metrics?.day7Retention.retainedUsers}
|
||||
denominator={metrics?.day7Retention.eligibleUsers}
|
||||
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -406,27 +431,37 @@ function MetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionCard({
|
||||
function RateCard({
|
||||
label,
|
||||
metric,
|
||||
numeratorLabel,
|
||||
denominatorLabel,
|
||||
numerator,
|
||||
denominator,
|
||||
rateBasisPoints,
|
||||
}: {
|
||||
label: string;
|
||||
metric?: AdminDashboardRetentionMetricPayload;
|
||||
numeratorLabel: string;
|
||||
denominatorLabel: string;
|
||||
numerator?: number;
|
||||
denominator?: number;
|
||||
rateBasisPoints?: number;
|
||||
}) {
|
||||
const hasEligibleUsers = Boolean(metric?.eligibleUsers);
|
||||
const hasDenominator = Boolean(denominator);
|
||||
return (
|
||||
<article className="admin-dashboard-retention-card">
|
||||
<span>{label}</span>
|
||||
<strong>
|
||||
{hasEligibleUsers && metric
|
||||
? formatRateBasisPoints(metric.rateBasisPoints)
|
||||
{hasDenominator && rateBasisPoints !== undefined
|
||||
? formatRateBasisPoints(rateBasisPoints)
|
||||
: '-'}
|
||||
</strong>
|
||||
<div>
|
||||
<small>留存人数 / 可观察新增人数</small>
|
||||
<small>
|
||||
{numeratorLabel} / {denominatorLabel}
|
||||
</small>
|
||||
<b>
|
||||
{metric
|
||||
? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)} 人`
|
||||
{numerator !== undefined && denominator !== undefined
|
||||
? `${formatNumber(numerator)} / ${formatNumber(denominator)} 人`
|
||||
: '-'}
|
||||
</b>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react';
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
@@ -24,6 +24,7 @@ const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
|
||||
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
|
||||
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
|
||||
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
|
||||
const ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS = 300;
|
||||
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
|
||||
let adminAssetReadDispatchTail = Promise.resolve();
|
||||
|
||||
@@ -37,6 +38,9 @@ export function AdminEditorAssetQueryPage({
|
||||
const [createdAfter, setCreatedAfter] = useState('');
|
||||
const [createdBefore, setCreatedBefore] = useState('');
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [committedQueryKey, setCommittedQueryKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
@@ -51,49 +55,147 @@ export function AdminEditorAssetQueryPage({
|
||||
title: string;
|
||||
prompt: string;
|
||||
} | null>(null);
|
||||
const listRequestGenerationRef = useRef(0);
|
||||
const listRequestAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const automaticQueryInitializedRef = useRef(false);
|
||||
const automaticQueryTokenRef = useRef(token);
|
||||
const activeQuery = buildListQuery();
|
||||
const activeQueryKey = editorAssetListQueryKey(token, activeQuery);
|
||||
const hasCurrentQuerySnapshot = committedQueryKey === activeQueryKey;
|
||||
const visibleEntries = hasCurrentQuerySnapshot ? entries : [];
|
||||
const visibleNextCursor = hasCurrentQuerySnapshot ? nextCursor : null;
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPage();
|
||||
const runImmediately =
|
||||
!automaticQueryInitializedRef.current ||
|
||||
automaticQueryTokenRef.current !== token;
|
||||
automaticQueryInitializedRef.current = true;
|
||||
automaticQueryTokenRef.current = token;
|
||||
|
||||
if (runImmediately) {
|
||||
void refreshPage();
|
||||
return;
|
||||
}
|
||||
|
||||
invalidateListRequest();
|
||||
setIsLoading(true);
|
||||
setIsLoadingMore(false);
|
||||
setErrorMessage('');
|
||||
const timer = setTimeout(
|
||||
() => void refreshPage(),
|
||||
ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, ownerUserId, keyword, createdAfter, createdBefore]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
automaticQueryInitializedRef.current = false;
|
||||
invalidateListRequest();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
async function refreshPage() {
|
||||
const query = buildListQuery();
|
||||
const queryKey = editorAssetListQueryKey(token, query);
|
||||
const { requestGeneration, abortController } = beginListRequest();
|
||||
setIsLoading(true);
|
||||
setIsLoadingMore(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminEditorAssets(token, buildListQuery());
|
||||
const response = await listAdminEditorAssets(
|
||||
token,
|
||||
query,
|
||||
abortController.signal,
|
||||
);
|
||||
if (listRequestGenerationRef.current !== requestGeneration) {
|
||||
return;
|
||||
}
|
||||
setEntries(response.entries);
|
||||
setNextCursor(response.nextCursor ?? null);
|
||||
setCommittedQueryKey(queryKey);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
listRequestGenerationRef.current !== requestGeneration ||
|
||||
isAbortError(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (listRequestGenerationRef.current === requestGeneration) {
|
||||
if (listRequestAbortControllerRef.current === abortController) {
|
||||
listRequestAbortControllerRef.current = null;
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor || isLoadingMore) {
|
||||
if (!visibleNextCursor || isLoading || isLoadingMore) {
|
||||
return;
|
||||
}
|
||||
const query = buildListQuery();
|
||||
const queryKey = editorAssetListQueryKey(token, query);
|
||||
if (committedQueryKey !== queryKey) {
|
||||
return;
|
||||
}
|
||||
const cursor = visibleNextCursor;
|
||||
const { requestGeneration, abortController } = beginListRequest();
|
||||
setIsLoadingMore(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminEditorAssets(token, {
|
||||
...buildListQuery(),
|
||||
cursor: nextCursor,
|
||||
});
|
||||
const response = await listAdminEditorAssets(
|
||||
token,
|
||||
{
|
||||
...query,
|
||||
cursor,
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
if (listRequestGenerationRef.current !== requestGeneration) {
|
||||
return;
|
||||
}
|
||||
setEntries((current) => mergeAssetEntries(current, response.entries));
|
||||
setNextCursor(response.nextCursor ?? null);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
listRequestGenerationRef.current !== requestGeneration ||
|
||||
isAbortError(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
if (listRequestGenerationRef.current === requestGeneration) {
|
||||
if (listRequestAbortControllerRef.current === abortController) {
|
||||
listRequestAbortControllerRef.current = null;
|
||||
}
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function beginListRequest() {
|
||||
invalidateListRequest();
|
||||
const abortController = new AbortController();
|
||||
listRequestAbortControllerRef.current = abortController;
|
||||
const requestGeneration = listRequestGenerationRef.current;
|
||||
return { requestGeneration, abortController };
|
||||
}
|
||||
|
||||
function invalidateListRequest() {
|
||||
listRequestGenerationRef.current += 1;
|
||||
listRequestAbortControllerRef.current?.abort();
|
||||
listRequestAbortControllerRef.current = null;
|
||||
}
|
||||
|
||||
function buildListQuery(): AdminEditorAssetListQuery {
|
||||
return {
|
||||
ownerUserId: ownerUserId || null,
|
||||
ownerUserId: ownerUserId.trim() || null,
|
||||
keyword: keyword.trim() || null,
|
||||
createdAfter: dateInputToStartRfc3339(createdAfter),
|
||||
createdBefore: dateInputToEndRfc3339(createdBefore),
|
||||
@@ -143,7 +245,7 @@ export function AdminEditorAssetQueryPage({
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>用户 ID</span>
|
||||
<span>用户 ID / 陶泥号</span>
|
||||
<input
|
||||
value={ownerUserId}
|
||||
onChange={(event) => setOwnerUserId(event.target.value)}
|
||||
@@ -173,7 +275,7 @@ export function AdminEditorAssetQueryPage({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
{visibleEntries.map((entry) => {
|
||||
const promptText = entry.prompt || entry.actualPrompt || '-';
|
||||
const expanded = expandedTaskIds.has(entry.assetId);
|
||||
const children = entry.children ?? [];
|
||||
@@ -318,13 +420,13 @@ export function AdminEditorAssetQueryPage({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!isLoading && entries.length === 0 ? (
|
||||
{!isLoading && !errorMessage && visibleEntries.length === 0 ? (
|
||||
<div className="admin-empty-state">暂无生成素材</div>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
{visibleNextCursor ? (
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoadingMore}
|
||||
disabled={isLoading || isLoadingMore}
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
>
|
||||
@@ -645,6 +747,12 @@ function AdminAssetDetailDialog({
|
||||
{entry.provider || '-'}
|
||||
</AdminInfoItem>
|
||||
<AdminInfoItem label="Task ID">{entry.taskId || '-'}</AdminInfoItem>
|
||||
{entry.groupTaskId?.trim() &&
|
||||
entry.groupTaskId.trim() !== entry.taskId?.trim() ? (
|
||||
<AdminInfoItem label="归组 Task ID">
|
||||
{entry.groupTaskId.trim()}
|
||||
</AdminInfoItem>
|
||||
) : null}
|
||||
<AdminInfoItem label="Object Key">
|
||||
{entry.objectKey || '-'}
|
||||
</AdminInfoItem>
|
||||
@@ -988,6 +1096,29 @@ function mergeAssetEntries(
|
||||
);
|
||||
}
|
||||
|
||||
function editorAssetListQueryKey(
|
||||
token: string,
|
||||
query: AdminEditorAssetListQuery,
|
||||
) {
|
||||
return JSON.stringify([
|
||||
token.trim(),
|
||||
query.ownerUserId?.trim() || null,
|
||||
query.keyword?.trim() || null,
|
||||
query.createdAfter?.trim() || null,
|
||||
query.createdBefore?.trim() || null,
|
||||
query.limit ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'AbortError'
|
||||
);
|
||||
}
|
||||
|
||||
function dateInputToStartRfc3339(value: string) {
|
||||
return value ? `${value}T00:00:00+08:00` : null;
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ button:disabled {
|
||||
|
||||
.admin-dashboard-retention-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -2120,6 +2120,14 @@ button:disabled {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card:nth-child(odd) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card:nth-child(n + 3) {
|
||||
border-top: 1px solid #ead8ca;
|
||||
}
|
||||
|
||||
.admin-field-compact {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -4171,6 +4171,13 @@
|
||||
- 影响范围:SpacetimeDB Dashboard 聚合 procedure、`spacetime-client` facade、`/admin/api/dashboard` 与 shared contracts、`apps/admin-web` Dashboard 页面和运营文档。
|
||||
- 验证方式:SpacetimeDB 聚合与 api-server 定向 Rust 测试、Dashboard Vitest、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:encoding`、`git diff --check`,并用桌面 / 移动浏览器核对留存、日期、每日 UV、零值与滚动同步。
|
||||
|
||||
## 2026-07-15 后台 Dashboard 增加新增用户付费率
|
||||
|
||||
- 背景:Dashboard 已能展示筛选时段的新增用户数和 D1 / D7 留存,但缺少同一新增 cohort 的真实付费转化指标。
|
||||
- 决策:新增用户付费率的分母为筛选期内 `profile_dashboard_state.created_at` 归属的新增用户,分子为其中截至查询时至少有一笔 `profile_recharge_order.paid_at` 的去重用户;已退款仍代表曾发生付费转化,未支付订单不计。
|
||||
- 聚合边界:继续扩展仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure,不新增持久化表或字段,前端只展示 BFF 返回的付费人数、新增用户数和基点率。
|
||||
- 验证方式:SpacetimeDB Dashboard 聚合测试、api-server admin 测试、Dashboard Vitest、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:encoding`、`git diff --check`,并用桌面 / 移动浏览器核对付费率卡片。
|
||||
|
||||
## 2026-07-14 SpacetimeDB 调用池与缓存读连接分离
|
||||
|
||||
- 背景:HTTP 角色原先在每个 `GENARRATIVE_SPACETIME_POOL_SIZE` 槽位首次建连时订阅同一批 read model;release 配置为 8 时会保存 8 份相同行缓存和 subscription handles,放大 api-server 内存。
|
||||
|
||||
@@ -173,6 +173,19 @@ RAG 主要供 Agent 检索项目上下文,开发者仍按 `AGENTS.md`、`docs/
|
||||
|
||||
## 常用检查命令
|
||||
|
||||
Rust 格式检查:
|
||||
|
||||
```bash
|
||||
npm run check:rustfmt
|
||||
```
|
||||
|
||||
仓库通过根目录 `rust-toolchain.toml` 固定 Rust `1.96.0` 和 `rustfmt` 组件,
|
||||
并通过 `rustfmt.toml` 固定 Edition 2024 格式口径。需要修复格式时运行:
|
||||
|
||||
```bash
|
||||
cargo fmt --all --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
- 后端通用用户行为埋点统一通过 `record_tracking_event_and_return` procedure、`SpacetimeRuntimeClient::record_tracking_event(...)` 与 api-server `tracking` 中间件写入 `tracking_event` / `tracking_daily_stat`;后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 默认排除;作品级游玩埋点统一使用 `work_play_start`,详细事件清单见 `docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md`。
|
||||
|
||||
编码检查:
|
||||
|
||||
@@ -55,6 +55,14 @@
|
||||
- 验证:`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs`、`server-rs/crates/api-server/src/admin.rs`。
|
||||
|
||||
## 后台素材查询要在分页前归一用户、游标和派生任务
|
||||
|
||||
- 现象:输入 `SY-*` 陶泥号查不到已有素材;无筛选时只显示 80 个任务且没有“读取更多”;手动重拆图集后,`素材 N` 被单独当成零成本父任务,原图集又显示成另一组。
|
||||
- 原因:`editor_asset.owner_user_id` 保存内部 `user_id`,不保存公开陶泥号;作者陶泥号在 procedure 返回后才映射,不能直接参与素材表过滤。`spacetime-client` 的统一时间文本是 `seconds.microsZ`,若 cursor 只按 RFC3339 解析,第 80 个任务无法生成 `nextCursor`。手动图集拆分使用独立 `editor-atlas-split-*` 任务号,但切片项目资源通过 `source_resource_id` 指回原图集资源,只按当前 `task_id` 分组会割裂同一条素材生产链。
|
||||
- 处理:`api-server` 在调用素材 procedure 前把“用户 ID / 陶泥号”字段或精确 `SY-*` keyword 解析成内部 `user_id`;游标解析同时接受整数微秒、统一 `seconds.microsZ` 和 RFC3339,编码失败必须返回服务错误,不能伪装成末页,客户端传入的非法 cursor 必须返回 `400`,不能静默回到第一页。手动拆分素材保留真实 `task_id`,通过 `editor_asset_group_source_provenance` 按原 resource、asset object 或 Object Key 查找服务端生成账号素材的可信来源任务,再把来源任务写入 `editor_asset.group_task_id`;跨项目复用时同时携带当前和原始 source resource,首次历史回溯命中后补写 provenance,后续不再扫描账号全量素材。没有可信来源的新批次显式以自己的拆分任务作为 `group_task_id`,不得落回可读取用户资源元数据的 legacy 分支。每片保存 `group_task_expected_asset_count`,全部切片落库后再写不可逆的 `editor_asset_group_cohort` 完成事实;read model 依据完成事实判断批次资格,不用当前剩余行数猜测初始是否完整,因此用户后来删除切片不会让批次脱组。部分失败批次没有完成事实,始终保留为独立拆分任务。历史行兼容沿资源链回溯,删除项目时只固化直接引用待删资源且尚未固化的历史切片;固化始终保存真实来源任务,有界展示分组不得反写覆盖来源。read model 只让同根任务的一个完整拆分批次并入原任务,重复批次按真实拆分任务独立分页。带 owner 条件时先走 `by_editor_asset_owner_user_id`,不要为单用户查询扫描全站素材。后台筛选输入使用防抖并取消旧 transport;手动刷新同一查询失败时保留已有结果和游标,筛选已变化时不展示旧查询结果。
|
||||
- 验证:API 测试覆盖陶泥号组合条件、未知陶泥号、可信来源归组、`seconds.microsZ` / 极值游标和真实 / 归组 Task ID;SpacetimeDB 测试覆盖资源删除后稳定归组、部分失败批次不抢占根任务、重复拆分有界无丢失和 owner 索引分支;后台页面测试覆盖逐字输入防抖、请求取消、刷新失败保留结果、筛选请求乱序、旧分页响应失效和“用户 ID / 陶泥号”请求。再运行 `cargo test -p api-server admin_editor_asset --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-module admin_editor_asset --manifest-path server-rs/Cargo.toml` 和 `npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`。
|
||||
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`、`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`。
|
||||
|
||||
## 后台素材缩略图不要在首次挂载时全量换签
|
||||
|
||||
- 现象:后台“素材查询”首批缩略图正常,继续向下滚动或读取更多后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
|
||||
- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=<projectId>`。
|
||||
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
|
||||
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口,并以充值中心 `mudPointBalance` 为余额真相源。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。
|
||||
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口。顶部总余额优先展示画板按扣费、退回、到账和页面恢复链路刷新的 `profileDashboard.walletBalance`,充值中心 `mudPointBalance` 仅用于展开面板的账户明细,不覆盖已刷新的总余额。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。
|
||||
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
|
||||
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
||||
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
|
||||
@@ -25,7 +25,7 @@
|
||||
- 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。
|
||||
- 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。
|
||||
- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置;透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。
|
||||
- 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。
|
||||
- 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。
|
||||
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
|
||||
- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。
|
||||
- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。
|
||||
@@ -50,14 +50,14 @@
|
||||
- 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、创建时间和更新时间;历史 layout 字段暂保留为兼容列,不再作为权威画布数据。
|
||||
- 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。
|
||||
- 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。
|
||||
- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、`generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
|
||||
- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、可选 `groupTaskId`、可选 `groupTaskExpectedAssetCount`、`assetKind`、`generationInputs`、`generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;`taskId` 保留真实生成 / 拆分操作身份,`groupTaskId` 保存服务端验证后的来源任务,`groupTaskExpectedAssetCount` 保存该拆分批次完整时应有的素材数;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
|
||||
- 新增 `editor_showcase_asset`、`editor_showcase_asset_like` 和 `editor_showcase_campaign_config` 表承接 `陶泥儿精选`:生成素材默认不公开,用户在素材菜单中提交精选审核后生成独立快照;后台审核通过后先返还 50% 生成成本泥点,但仍需运营手动设置精选分类并开启展示才进入公开精选。公开列表不再读取 `editor_project_resource.public_showcase_enabled`,而是读取已通过、展示开启且分类合法的精选快照,支持点赞数和首位活动卡。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
|
||||
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。
|
||||
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。
|
||||
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。
|
||||
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
|
||||
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
|
||||
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
|
||||
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。
|
||||
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
|
||||
- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
- 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。
|
||||
- 总注册用户:`profile_dashboard_state` 行数。
|
||||
- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。
|
||||
- 新增用户付费率:分母为当前筛选时间窗内的新增用户,分子为这些用户中截至本次查询时已至少完成一次真实支付的去重人数。真实支付以 `profile_recharge_order.paid_at` 存在且不晚于本次查询时刻为准;只创建订单或未支付订单不计,已退款订单仍表示曾经发生过付费转化,因此保留在分子。返回付费人数、新增用户数和四舍五入后的基点率;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。
|
||||
- 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
|
||||
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user`、`count > 0` 且 `scope_id` 非空、非 `anonymous` 的用户去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。时段指标按整个筛选范围跨日去重,趋势图按 `day_key + scope_id` 每日去重,不能把时段 UV 塞到终止日,也不能把每日 UV 之和当成时段 UV。
|
||||
- 近 5 分钟活跃用户:以请求时刻为终点,最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。这是滚动窗口,不是每 5 分钟才采样一次。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -236,7 +236,7 @@ npm run codegraph:index
|
||||
|
||||
Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`:
|
||||
|
||||
- `PreToolUse` hook 会在 Codex 准备执行 `git commit` 前运行 `node .codex/hooks/pre-submit-compile-check.mjs`,依次执行 `npm run typecheck`、`npm run admin-web:typecheck`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,发现编译错误会阻止本次提交。
|
||||
- `PreToolUse` hook 会在 Codex 准备执行 `git commit` 前运行 `node .codex/hooks/pre-submit-compile-check.mjs`,依次执行 `npm run check:rustfmt`、`npm run typecheck`、`npm run admin-web:typecheck`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,发现格式或编译错误会阻止本次提交。
|
||||
- `PostToolUse` hook 会在 Codex 工具修改文件后运行 `node .codex/hooks/post-edit-codegraph-sync.mjs`,执行 `npm run codegraph:sync` 刷新本地语义索引。
|
||||
- 如果某个 Codex 客户端版本尚未自动加载项目级 hook,可先手动运行 `node .codex/hooks/pre-submit-compile-check.mjs` 与 `node .codex/hooks/post-edit-codegraph-sync.mjs`;个人模型、token、MCP server 仍放在个人 `~/.codex/config.toml`,不要提交。
|
||||
|
||||
@@ -246,6 +246,7 @@ Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`:
|
||||
|
||||
后端代码修改后,按变更范围选择:
|
||||
|
||||
- `npm run check:rustfmt`
|
||||
- `cargo test -p <crate> --manifest-path server-rs/Cargo.toml`
|
||||
- `cargo test -p platform-image --manifest-path server-rs/Cargo.toml`
|
||||
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
|
||||
@@ -268,6 +269,12 @@ npm run spacetime:generate
|
||||
npm run check:spacetime-schema
|
||||
```
|
||||
|
||||
仓库根目录的 `rust-toolchain.toml` 固定 Rust `1.96.0` 并要求 `rustfmt` 组件,
|
||||
`rustfmt.toml` 固定 Edition 2024 的格式化口径。Rust 源码统一使用
|
||||
`cargo fmt --all --manifest-path server-rs/Cargo.toml` 格式化,并用
|
||||
`npm run check:rustfmt` 做只读校验;Codex 提交前门禁、API 生产构建和
|
||||
SpacetimeDB module 生产构建都会执行同一检查,避免不同开发机或构建节点反复产生格式差异。
|
||||
|
||||
## 前端改动验收
|
||||
|
||||
前端修改后,根据范围选择:
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。
|
||||
|
||||
创作页和草稿页顶栏右上角统一复用公共泥点资产入口,不再把余额区本身作为直接充值按钮。余额区展开后只展示不限时泥点、每日免费泥点及重置口径;会员周期限时泥点仅由后端保留用于存量兼容和结算,当前版本不在前台展示。独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。主站各位置必须保持同一组件、数据口径和交互语义,头像 / 设置等账号入口继续保留各自语义。
|
||||
创作页和草稿页顶栏右上角统一复用公共泥点资产入口,不再把余额区本身作为直接充值按钮。顶部总余额优先展示会随扣费、退回和到账刷新的 `profileDashboard.walletBalance`,充值中心的 `mudPointBalance` 只承接展开面板中的不限时 / 每日免费泥点明细,不得用已缓存的明细总数覆盖已刷新的顶部余额。余额区展开后只展示不限时泥点、每日免费泥点及重置口径;会员周期限时泥点仅由后端保留用于存量兼容和结算,当前版本不在前台展示。独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。主站各位置必须保持同一组件、数据口径和交互语义,头像 / 设置等账号入口继续保留各自语义。
|
||||
|
||||
创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ pipeline {
|
||||
echo "[api-build] 未找到 sccache,改用 rustc 直接构建。"
|
||||
unset RUSTC_WRAPPER
|
||||
fi
|
||||
npm run check:rustfmt
|
||||
pingora_args=()
|
||||
if [[ "${INCLUDE_PINGORA_GATEWAY:-false}" == "true" ]]; then
|
||||
pingora_args+=(--include-pingora-gateway)
|
||||
|
||||
@@ -129,6 +129,7 @@ pipeline {
|
||||
echo "[stdb-build] 未找到可用 sccache,改用 rustc 直接构建。"
|
||||
unset RUSTC_WRAPPER
|
||||
fi
|
||||
npm run check:rustfmt
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \
|
||||
npm run build:production-release -- --component spacetime-module --name "${EFFECTIVE_BUILD_VERSION}"
|
||||
'
|
||||
|
||||
+2
-1
@@ -31,6 +31,7 @@
|
||||
"preview": "node scripts/vite-cli.mjs preview",
|
||||
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"check:encoding": "node scripts/check-encoding.mjs",
|
||||
"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check",
|
||||
"check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs",
|
||||
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
|
||||
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
|
||||
@@ -84,7 +85,7 @@
|
||||
"lint:guardrails": "npm run lint:eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
||||
"typecheck:guardrails": "npm run typecheck",
|
||||
"lint": "npm run check:encoding && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck",
|
||||
"lint": "npm run check:encoding && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -30,7 +36,7 @@ test('shows only permanent and daily free points in the shared wallet panel', as
|
||||
|
||||
render(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={999}
|
||||
balance={207}
|
||||
breakdown={breakdown}
|
||||
onRequestDetails={onRequestDetails}
|
||||
onRecharge={onRecharge}
|
||||
@@ -60,6 +66,25 @@ test('shows only permanent and daily free points in the shared wallet panel', as
|
||||
expect(onRecharge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps the chip on the live balance when cached details are stale', () => {
|
||||
const props = {
|
||||
breakdown,
|
||||
onRequestDetails: vi.fn(),
|
||||
onRecharge: vi.fn(),
|
||||
onOpenLedger: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<PlatformMudPointWalletEntry balance={207} {...props} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '泥点 207' })).toBeTruthy();
|
||||
|
||||
rerender(<PlatformMudPointWalletEntry balance={195} {...props} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '泥点 195' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '泥点 207' })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the desktop panel open while moving across the gap without click pinning', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function PlatformMudPointWalletEntry({
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isCompact = variant === 'mobile';
|
||||
const displayedBalance = breakdown?.totalPoints ?? balance;
|
||||
const displayedBalance = balance ?? breakdown?.totalPoints ?? null;
|
||||
const balanceLabel =
|
||||
displayedBalance === null
|
||||
? '--'
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.96.0"
|
||||
profile = "minimal"
|
||||
components = ["rustfmt"]
|
||||
@@ -0,0 +1,4 @@
|
||||
edition = "2024"
|
||||
max_width = 100
|
||||
newline_style = "Unix"
|
||||
use_small_heuristics = "Default"
|
||||
@@ -3,6 +3,21 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const checks = [
|
||||
{
|
||||
file: 'package.json',
|
||||
includes: '"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check"',
|
||||
reason: '仓库必须保留统一、只读的 Rust workspace 格式检查入口。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-api-build',
|
||||
includes: 'npm run check:rustfmt',
|
||||
reason: 'API 生产构建必须在编译前执行 Rust 格式检查。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'npm run check:rustfmt',
|
||||
reason: 'Stdb module 生产构建必须在编译前执行 Rust 格式检查。',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/migration.rs',
|
||||
includes:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user