已发布作品可二次编辑

This commit is contained in:
2026-04-26 15:32:36 +08:00
parent 7aabbcc10c
commit de2c49005f
13 changed files with 154 additions and 77 deletions

View File

@@ -874,6 +874,7 @@ isCardDetailLoading: boolean;
1. 前端步骤名优先复用服务端 `phaseLabel` 的真实语义,不再单独发明一套四段式文案。 1. 前端步骤名优先复用服务端 `phaseLabel` 的真实语义,不再单独发明一套四段式文案。
2. 如果服务端处于批处理阶段,顶部 `phaseLabel` / `phaseDetail` 继续直接显示当前批次信息。 2. 如果服务端处于批处理阶段,顶部 `phaseLabel` / `phaseDetail` 继续直接显示当前批次信息。
3. 自动补主形象与幕背景图也属于草稿生成链路的一部分,不能在进度 UI 中被误折叠成“已完成”后的隐藏耗时。 3. 自动补主形象与幕背景图也属于草稿生成链路的一部分,不能在进度 UI 中被误折叠成“已完成”后的隐藏耗时。
4. 进度页“已耗时”必须按服务端 operation 的创建时间 `startedAt` 与当前时间计算;刷新页面、恢复轮询或前端重挂载时不能重新从本地点击时间开始计时。只有旧 operation 缺少 `startedAt` 时,才允许使用本地记录的开始时间作为兜底。
## 12.1 生成底稿时序 ## 12.1 生成底稿时序

View File

@@ -135,6 +135,18 @@
结果页不是一个只读总结页,而是拼图作品最小可编辑工作台。 结果页不是一个只读总结页,而是拼图作品最小可编辑工作台。
### 5.1.1 已发布作品二次编辑
创作者在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。
落地规则:
1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。
2. 恢复后的结果页沿用原草稿、候选图、正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成或切换图片。
3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`
4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。
5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。
## 5.2 运行时结论 ## 5.2 运行时结论
拼图运行时应该是: 拼图运行时应该是:

View File

@@ -66,6 +66,8 @@ export interface RpgAgentOperationRecord {
phaseDetail: string; phaseDetail: string;
progress: number; progress: number;
error?: string | null; error?: string | null;
/** 操作创建时间,草稿生成进度页用它计算总耗时。 */
startedAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
} }

View File

@@ -434,13 +434,7 @@ pub async fn execute_big_fish_action(
let now = current_utc_micros(); let now = current_utc_micros();
let session = match payload.action.trim() { let session = match payload.action.trim() {
"big_fish_compile_draft" => { "big_fish_compile_draft" => {
compile_big_fish_draft_with_all_assets( compile_big_fish_draft_with_all_assets(&state, session_id, owner_user_id, now).await
&state,
session_id,
owner_user_id,
now,
)
.await
} }
"big_fish_generate_level_main_image" => { "big_fish_generate_level_main_image" => {
let asset_url = generate_big_fish_formal_asset( let asset_url = generate_big_fish_formal_asset(

View File

@@ -2538,6 +2538,7 @@ fn map_custom_world_agent_operation_response(
phase_detail: operation.phase_detail, phase_detail: operation.phase_detail,
progress: operation.progress, progress: operation.progress,
error: operation.error_message, error: operation.error_message,
started_at: Some(timestamp_micros_to_rfc3339(operation.started_at_micros)),
updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)), updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)),
} }
} }

View File

@@ -2548,26 +2548,24 @@ mod tests {
name: Some("礁石神殿".to_string()), name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()), description: Some("古老礁石上的半沉神殿。".to_string()),
}; };
let manual_prompt = build_custom_world_scene_image_prompt( let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
SceneImagePromptParams { profile: SceneImagePromptProfile {
profile: SceneImagePromptProfile { name: profile_input.name.as_deref().unwrap_or_default(),
name: profile_input.name.as_deref().unwrap_or_default(), subtitle: profile_input.subtitle.as_deref().unwrap_or_default(),
subtitle: profile_input.subtitle.as_deref().unwrap_or_default(), tone: profile_input.tone.as_deref().unwrap_or_default(),
tone: profile_input.tone.as_deref().unwrap_or_default(), player_goal: profile_input.player_goal.as_deref().unwrap_or_default(),
player_goal: profile_input.player_goal.as_deref().unwrap_or_default(), summary: profile_input.summary.as_deref().unwrap_or_default(),
summary: profile_input.summary.as_deref().unwrap_or_default(), setting_text: profile_input.setting_text.as_deref().unwrap_or_default(),
setting_text: profile_input.setting_text.as_deref().unwrap_or_default(),
},
landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt,
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
}, },
); landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt,
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
});
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest { let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()), profile_id: Some("profile_001".to_string()),

View File

@@ -47,13 +47,14 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleWorkUpsertRecordInput, SpacetimeClientError, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
}; };
use std::convert::Infallible; use std::convert::Infallible;
use tokio::time::sleep; use tokio::time::sleep;
@@ -1639,7 +1640,10 @@ async fn generate_puzzle_image_candidates(
let mut items = Vec::with_capacity(generated.images.len()); let mut items = Vec::with_capacity(generated.images.len());
for (index, image) in generated.images.into_iter().enumerate() { for (index, image) in generated.images.into_iter().enumerate() {
let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + index + 1); let candidate_id = format!(
"{session_id}-candidate-{}",
candidate_start_index + index + 1
);
let asset = persist_puzzle_generated_asset( let asset = persist_puzzle_generated_asset(
state, state,
owner_user_id, owner_user_id,
@@ -1690,10 +1694,12 @@ async fn build_local_next_puzzle_run(
})) }))
})?; })?;
if current_level.status != "cleared" { if current_level.status != "cleared" {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ return Err(
"provider": PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "current level is not cleared", "provider": PUZZLE_RUNTIME_PROVIDER,
}))); "message": "current level is not cleared",
})),
);
} }
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? { if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
@@ -1702,10 +1708,12 @@ async fn build_local_next_puzzle_run(
let source_session_id = payload.source_session_id.unwrap_or_default(); let source_session_id = payload.source_session_id.unwrap_or_default();
if source_session_id.trim().is_empty() { if source_session_id.trim().is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ return Err(
"provider": PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "sourceSessionId is required when gallery has no next puzzle work", "provider": PUZZLE_RUNTIME_PROVIDER,
}))); "message": "sourceSessionId is required when gallery has no next puzzle work",
})),
);
} }
let session = state let session = state
.spacetime_client() .spacetime_client()
@@ -1767,14 +1775,23 @@ async fn build_local_next_puzzle_run(
let candidate = updated_session let candidate = updated_session
.draft .draft
.as_ref() .as_ref()
.and_then(|draft| draft.candidates.iter().find(|candidate| !candidate.image_src.is_empty())) .and_then(|draft| {
draft
.candidates
.iter()
.find(|candidate| !candidate.image_src.is_empty())
})
.ok_or_else(|| { .ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER, "provider": PUZZLE_RUNTIME_PROVIDER,
"message": "现场生成后没有可用候选图", "message": "现场生成后没有可用候选图",
})) }))
})?; })?;
Ok(build_next_run_from_candidate(run, &updated_session, candidate)) Ok(build_next_run_from_candidate(
run,
&updated_session,
candidate,
))
} }
async fn resolve_gallery_next_puzzle_work( async fn resolve_gallery_next_puzzle_work(
@@ -1788,7 +1805,10 @@ async fn resolve_gallery_next_puzzle_work(
.map_err(map_puzzle_client_error)?; .map_err(map_puzzle_client_error)?;
Ok(items.into_iter().find(|item| { Ok(items.into_iter().find(|item| {
item.publication_status == "published" item.publication_status == "published"
&& item.cover_image_src.as_ref().is_some_and(|value| !value.is_empty()) && item
.cover_image_src
.as_ref()
.is_some_and(|value| !value.is_empty())
&& !run.played_profile_ids.contains(&item.profile_id) && !run.played_profile_ids.contains(&item.profile_id)
})) }))
} }
@@ -1836,7 +1856,9 @@ fn build_next_run_from_candidate(
.map(|draft| format!("{} · 候选 {}", draft.level_name, level_index)) .map(|draft| format!("{} · 候选 {}", draft.level_name, level_index))
.unwrap_or_else(|| format!("候选拼图 {level_index}")), .unwrap_or_else(|| format!("候选拼图 {level_index}")),
"当前草稿".to_string(), "当前草稿".to_string(),
draft.map(|draft| draft.theme_tags.clone()).unwrap_or_default(), draft
.map(|draft| draft.theme_tags.clone())
.unwrap_or_default(),
Some(candidate.image_src.clone()), Some(candidate.image_src.clone()),
) )
} }
@@ -1893,13 +1915,14 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
} }
let pieces = (0..total) let pieces = (0..total)
.map(|index| { .map(|index| {
let current = positions let current =
.get(index as usize) positions
.cloned() .get(index as usize)
.unwrap_or(PuzzleCellPositionRecord { .cloned()
row: index / grid_size, .unwrap_or(PuzzleCellPositionRecord {
col: index % grid_size, row: index / grid_size,
}); col: index % grid_size,
});
PuzzlePieceStateRecord { PuzzlePieceStateRecord {
piece_id: format!("piece-{index}"), piece_id: format!("piece-{index}"),
correct_row: index / grid_size, correct_row: index / grid_size,

View File

@@ -452,6 +452,7 @@ pub struct CustomWorldAgentOperationResponse {
pub phase_detail: String, pub phase_detail: String,
pub progress: u32, pub progress: u32,
pub error: Option<String>, pub error: Option<String>,
pub started_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }

View File

@@ -1837,6 +1837,7 @@ pub(crate) fn map_custom_world_agent_operation_snapshot(
phase_detail: snapshot.phase_detail, phase_detail: snapshot.phase_detail,
progress: snapshot.progress, progress: snapshot.progress,
error_message: snapshot.error_message, error_message: snapshot.error_message,
started_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros, updated_at_micros: snapshot.updated_at_micros,
} }
} }
@@ -3721,6 +3722,7 @@ pub struct CustomWorldAgentOperationRecord {
pub phase_detail: String, pub phase_detail: String,
pub progress: u32, pub progress: u32,
pub error_message: Option<String>, pub error_message: Option<String>,
pub started_at_micros: i64,
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }

View File

@@ -1384,7 +1384,9 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
cover_image_src: profile.cover_image_src, cover_image_src: profile.cover_image_src,
cover_asset_id: profile.cover_asset_id, cover_asset_id: profile.cover_asset_id,
publication_status: profile.publication_status, publication_status: profile.publication_status,
play_count: profile.play_count, // 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
// 广场消费数据,不能因为重新发布被清零。
play_count: existing.play_count.max(profile.play_count),
anchor_pack_json: serialize_json(&profile.anchor_pack), anchor_pack_json: serialize_json(&profile.anchor_pack),
publish_ready: profile.publish_ready, publish_ready: profile.publish_ready,
created_at: existing.created_at, created_at: existing.created_at,

View File

@@ -50,6 +50,7 @@ export function createFailedRpgEntryAgentOperation(params: {
phaseDetail: params.error, phaseDetail: params.error,
progress: 0, progress: 0,
error: params.error, error: params.error,
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
} }

View File

@@ -1,12 +1,19 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, expect, it } from 'vitest'; import { act, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
collectRouteImageUrls, collectRouteImageUrls,
extractCssImageUrls, extractCssImageUrls,
normalizePreloadImageUrl, normalizePreloadImageUrl,
} from './routeImageReadyGateUtils'; } from './routeImageReadyGateUtils';
import { RouteImageReadyGate } from './RouteImageReadyGate';
afterEach(() => {
vi.useRealTimers();
});
describe('RouteImageReadyGate image url helpers', () => { describe('RouteImageReadyGate image url helpers', () => {
it('extracts urls from layered CSS image values', () => { it('extracts urls from layered CSS image values', () => {
@@ -41,4 +48,40 @@ describe('RouteImageReadyGate image url helpers', () => {
new URL('/ui/frame.png', document.baseURI).href, new URL('/ui/frame.png', document.baseURI).href,
]); ]);
}); });
it('reveals route content after a short cap when images stay pending', () => {
vi.useFakeTimers();
render(
createElement(
RouteImageReadyGate,
{
eyebrow: '正在载入游戏',
text: '正在载入冒险...',
},
createElement(
'section',
{
'data-testid': 'route-content',
},
createElement('img', {
src: '/generated-characters/slow-cover.png',
alt: 'slow cover',
}),
),
),
);
const content = screen.getByTestId('route-content');
const visibilityGate = content.parentElement;
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('true');
expect(visibilityGate?.style.visibility).toBe('hidden');
act(() => {
vi.advanceTimersByTime(1600);
});
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('false');
expect(visibilityGate?.style.visibility).toBe('visible');
});
}); });

View File

@@ -15,6 +15,7 @@ type RouteImageReadyGateProps = {
const IMAGE_GATE_QUIET_MS = 140; const IMAGE_GATE_QUIET_MS = 140;
const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260; const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260;
const IMAGE_GATE_MAX_BLOCK_MS = 1400;
const IMAGE_PRELOAD_TIMEOUT_MS = 12000; const IMAGE_PRELOAD_TIMEOUT_MS = 12000;
const settledImageUrls = new Set<string>(); const settledImageUrls = new Set<string>();
@@ -66,7 +67,7 @@ function preloadImageUrl(url: string) {
/** /**
* 路由首屏图片门闩:业务页面先真实挂载但不可见, * 路由首屏图片门闩:业务页面先真实挂载但不可见,
* 等当前 DOM 中已发现的图片全部 settled 后再一次性显示 * 只等待短暂稳定窗口,不再把所有图片加载完成作为首屏硬阻塞
*/ */
export function RouteImageReadyGate({ export function RouteImageReadyGate({
children, children,
@@ -78,6 +79,7 @@ export function RouteImageReadyGate({
const scanTimerRef = useRef<number | null>(null); const scanTimerRef = useRef<number | null>(null);
const revealTimerRef = useRef<number | null>(null); const revealTimerRef = useRef<number | null>(null);
const scanVersionRef = useRef(0); const scanVersionRef = useRef(0);
const revealedRef = useRef(false);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
@@ -88,6 +90,7 @@ export function RouteImageReadyGate({
let disposed = false; let disposed = false;
startTimeRef.current = window.performance.now(); startTimeRef.current = window.performance.now();
revealedRef.current = false;
setReady(false); setReady(false);
const clearScanTimer = () => { const clearScanTimer = () => {
@@ -110,27 +113,26 @@ export function RouteImageReadyGate({
}; };
const scheduleReveal = (version: number) => { const scheduleReveal = (version: number) => {
if (revealedRef.current) {
return;
}
clearRevealTimer(); clearRevealTimer();
const elapsed = window.performance.now() - startTimeRef.current; const elapsed = window.performance.now() - startTimeRef.current;
const delay = Math.max( const preferredDelay = Math.max(
IMAGE_GATE_QUIET_MS, IMAGE_GATE_QUIET_MS,
IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed, IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
); );
const maxRemainingDelay = Math.max(0, IMAGE_GATE_MAX_BLOCK_MS - elapsed);
const delay = Math.min(preferredDelay, maxRemainingDelay);
revealTimerRef.current = window.setTimeout(() => { revealTimerRef.current = window.setTimeout(() => {
if (disposed || version !== scanVersionRef.current) { if (disposed || version !== scanVersionRef.current) {
return; return;
} }
const pendingUrls = collectRouteImageUrls(root).filter( revealedRef.current = true;
(url) => !settledImageUrls.has(url),
);
if (pendingUrls.length > 0) {
scheduleScan();
return;
}
setReady(true); setReady(true);
}, delay); }, delay);
}; };
@@ -146,23 +148,18 @@ export function RouteImageReadyGate({
(url) => !settledImageUrls.has(url), (url) => !settledImageUrls.has(url),
); );
if (pendingUrls.length === 0) { if (pendingUrls.length > 0) {
scheduleReveal(version); // 首屏慢加载的核心约束:图片可预热,但不能无限期阻塞页面主体可见。
return; pendingUrls.forEach((url) => {
void preloadImageUrl(url);
});
} }
// 已进入页面但新 DOM 批量挂载图片时,先回到等待态,避免图片逐张闪入。 scheduleReveal(version);
setReady(false);
void Promise.allSettled(pendingUrls.map(preloadImageUrl)).then(() => {
if (disposed || version !== scanVersionRef.current) {
return;
}
scheduleScan();
});
} }
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
if (disposed) { if (disposed || revealedRef.current) {
return; return;
} }