已发布作品可二次编辑

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

View File

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

View File

@@ -434,13 +434,7 @@ pub async fn execute_big_fish_action(
let now = current_utc_micros();
let session = match payload.action.trim() {
"big_fish_compile_draft" => {
compile_big_fish_draft_with_all_assets(
&state,
session_id,
owner_user_id,
now,
)
.await
compile_big_fish_draft_with_all_assets(&state, session_id, owner_user_id, now).await
}
"big_fish_generate_level_main_image" => {
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,
progress: operation.progress,
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)),
}
}

View File

@@ -2548,26 +2548,24 @@ mod tests {
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
};
let manual_prompt = build_custom_world_scene_image_prompt(
SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: profile_input.name.as_deref().unwrap_or_default(),
subtitle: profile_input.subtitle.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(),
summary: profile_input.summary.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: "雾海群岛",
let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: profile_input.name.as_deref().unwrap_or_default(),
subtitle: profile_input.subtitle.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(),
summary: profile_input.summary.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: "雾海群岛",
});
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),

View File

@@ -47,13 +47,14 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
@@ -1639,7 +1640,10 @@ async fn generate_puzzle_image_candidates(
let mut items = Vec::with_capacity(generated.images.len());
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(
state,
owner_user_id,
@@ -1690,10 +1694,12 @@ async fn build_local_next_puzzle_run(
}))
})?;
if current_level.status != "cleared" {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "current level is not cleared",
})));
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "current level is not cleared",
})),
);
}
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();
if source_session_id.trim().is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "sourceSessionId is required when gallery has no next puzzle work",
})));
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "sourceSessionId is required when gallery has no next puzzle work",
})),
);
}
let session = state
.spacetime_client()
@@ -1767,14 +1775,23 @@ async fn build_local_next_puzzle_run(
let candidate = updated_session
.draft
.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(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"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(
@@ -1788,7 +1805,10 @@ async fn resolve_gallery_next_puzzle_work(
.map_err(map_puzzle_client_error)?;
Ok(items.into_iter().find(|item| {
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)
}))
}
@@ -1836,7 +1856,9 @@ fn build_next_run_from_candidate(
.map(|draft| format!("{} · 候选 {}", draft.level_name, level_index))
.unwrap_or_else(|| format!("候选拼图 {level_index}")),
"当前草稿".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()),
)
}
@@ -1893,13 +1915,14 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
}
let pieces = (0..total)
.map(|index| {
let current = positions
.get(index as usize)
.cloned()
.unwrap_or(PuzzleCellPositionRecord {
row: index / grid_size,
col: index % grid_size,
});
let current =
positions
.get(index as usize)
.cloned()
.unwrap_or(PuzzleCellPositionRecord {
row: index / grid_size,
col: index % grid_size,
});
PuzzlePieceStateRecord {
piece_id: format!("piece-{index}"),
correct_row: index / grid_size,

View File

@@ -452,6 +452,7 @@ pub struct CustomWorldAgentOperationResponse {
pub phase_detail: String,
pub progress: u32,
pub error: Option<String>,
pub started_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,
progress: snapshot.progress,
error_message: snapshot.error_message,
started_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
@@ -3721,6 +3722,7 @@ pub struct CustomWorldAgentOperationRecord {
pub phase_detail: String,
pub progress: u32,
pub error_message: Option<String>,
pub started_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_asset_id: profile.cover_asset_id,
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),
publish_ready: profile.publish_ready,
created_at: existing.created_at,

View File

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

View File

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