已发布作品可二次编辑
This commit is contained in:
@@ -874,6 +874,7 @@ isCardDetailLoading: boolean;
|
||||
1. 前端步骤名优先复用服务端 `phaseLabel` 的真实语义,不再单独发明一套四段式文案。
|
||||
2. 如果服务端处于批处理阶段,顶部 `phaseLabel` / `phaseDetail` 继续直接显示当前批次信息。
|
||||
3. 自动补主形象与幕背景图也属于草稿生成链路的一部分,不能在进度 UI 中被误折叠成“已完成”后的隐藏耗时。
|
||||
4. 进度页“已耗时”必须按服务端 operation 的创建时间 `startedAt` 与当前时间计算;刷新页面、恢复轮询或前端重挂载时不能重新从本地点击时间开始计时。只有旧 operation 缺少 `startedAt` 时,才允许使用本地记录的开始时间作为兜底。
|
||||
|
||||
## 12.1 生成底稿时序
|
||||
|
||||
|
||||
@@ -135,6 +135,18 @@
|
||||
|
||||
结果页不是一个只读总结页,而是拼图作品最小可编辑工作台。
|
||||
|
||||
### 5.1.1 已发布作品二次编辑
|
||||
|
||||
创作者在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。
|
||||
|
||||
落地规则:
|
||||
|
||||
1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。
|
||||
2. 恢复后的结果页沿用原草稿、候选图、正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成或切换图片。
|
||||
3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`。
|
||||
4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。
|
||||
5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。
|
||||
|
||||
## 5.2 运行时结论
|
||||
|
||||
拼图运行时应该是:
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface RpgAgentOperationRecord {
|
||||
phaseDetail: string;
|
||||
progress: number;
|
||||
error?: string | null;
|
||||
/** 操作创建时间,草稿生成进度页用它计算总耗时。 */
|
||||
startedAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -50,6 +50,7 @@ export function createFailedRpgEntryAgentOperation(params: {
|
||||
phaseDetail: params.error,
|
||||
progress: 0,
|
||||
error: params.error,
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user