已发布作品可二次编辑
This commit is contained in:
@@ -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 生成底稿时序
|
||||||
|
|
||||||
|
|||||||
@@ -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 运行时结论
|
||||||
|
|
||||||
拼图运行时应该是:
|
拼图运行时应该是:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user