1
This commit is contained in:
@@ -120,6 +120,14 @@
|
|||||||
- 验证:`npm run api-server` 后 `/healthz` 返回 200,相关路由冒烟通过。
|
- 验证:`npm run api-server` 后 `/healthz` 返回 200,相关路由冒烟通过。
|
||||||
- 关联:`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/app.rs`。
|
- 关联:`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/app.rs`。
|
||||||
|
|
||||||
|
## Windows api-server.exe 锁文件与强杀退出码容易混淆
|
||||||
|
|
||||||
|
- 现象:`cargo run -p api-server` 或 `npm run api-server` 报 `failed to remove file ... target\debug\api-server.exe`;清理旧进程后,旧终端可能继续打印 `process didn't exit successfully: server-rs\target\debug\api-server.exe (exit code: 0xffffffff)`。
|
||||||
|
- 原因:Windows 不能覆盖仍在运行的 exe;通常是上一条 `npm run api-server` 链路仍在运行,进程树为 `npm run api-server -> node scripts/api-server-dev.mjs -> cargo run -> api-server.exe`。`0xffffffff` 常见于排障时用 `Stop-Process -Force` 强制结束旧 `api-server.exe` 后由 Cargo 回显,不一定代表新启动失败。
|
||||||
|
- 处理:先按目标路径确认并停止本仓库的旧 `api-server.exe` 及其父级 `cargo/node/cmd` 启动链路,再重新启动;不要同时开多个 `npm run api-server`。
|
||||||
|
- 验证:确认没有匹配 `C:\Genarrative\server-rs\target\debug\api-server.exe` 的进程后,`Remove-Item` 能删除旧 exe;随后 `npm run api-server` 启动并访问 `/healthz` 返回 200。
|
||||||
|
- 关联:`scripts/api-server-dev.mjs`、`server-rs/crates/api-server/src/main.rs`。
|
||||||
|
|
||||||
## Windows debug 长 SSE Future 触发 api-server 断连
|
## Windows debug 长 SSE Future 触发 api-server 断连
|
||||||
|
|
||||||
- 现象:前端 Vite 代理请求 `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream` 报 `read ECONNRESET`,随后 `api-server.exe` 以 `0xffffffff` 退出,`dev:rust` 回收 SpacetimeDB、Vite 和后台 Vite。
|
- 现象:前端 Vite 代理请求 `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream` 报 `read ECONNRESET`,随后 `api-server.exe` 以 `0xffffffff` 退出,`dev:rust` 回收 SpacetimeDB、Vite 和后台 Vite。
|
||||||
|
|||||||
@@ -31,10 +31,13 @@
|
|||||||
|
|
||||||
- 数据来源沿用 `featuredEntries + latestEntries` 去重后的公开作品列表。
|
- 数据来源沿用 `featuredEntries + latestEntries` 去重后的公开作品列表。
|
||||||
- 首个可运行作品自动进入推荐页内嵌运行态,主视口不再展示作品封面卡。
|
- 首个可运行作品自动进入推荐页内嵌运行态,主视口不再展示作品封面卡。
|
||||||
- 主视口占据顶部栏与底部作品区之间的主要空间,保持黑色运行容器、圆角边界和短加载态,直接承载作品启动后的玩法画面。
|
- 主视口占据顶部栏与作品信息区之间的主要空间,使用平台主题 token 控制运行容器背景、边框、阴影、加载态文字和错误态按钮;亮色主题不得残留纯黑底白字加载块。
|
||||||
- 主视口下方展示当前作品的游玩、点赞、评论/改造等紧凑指标、作者头像、作者名与作品名,不写规则说明类文案。
|
- 主视口下方展示当前作品的游玩、点赞、评论/改造等紧凑指标、作者头像、作者名与作品名,不写规则说明类文案。
|
||||||
- 底部作品区使用横向滑动切换器,条目只展示作品名、类型和核心指标;点击或滑动到其他作品时切换上方运行内容。
|
- 作品信息区不再提供详情箭头或点击详情入口;点击该区域无效,上滑切换下一个推荐作品,下滑切换上一个推荐作品。
|
||||||
- 点击作品元信息仍可进入既有详情页,点赞、改造、复制作品号等完整操作继续收敛在详情页。
|
- 用户停留在推荐页时,底部当前 Tab 从“推荐”切换为“下一个”,图标使用向下的倒三角 / 双下箭头语义,点击后切换下一个推荐作品。
|
||||||
|
- 推荐页不再展示额外的底部作品切换块;当前作品的完整操作继续收敛在详情页和作品自身运行态中。
|
||||||
|
- 推荐页嵌入运行只调整平台外壳容器、主题注入和玩法壳层配色,不改写作品数据、关卡设定、道具设定或图片资产。
|
||||||
|
- 点赞、改造、复制作品号等完整操作继续收敛在详情页,详情入口由作品自身运行态或其它广场列表承接,推荐页作品信息区只负责展示和上下滑切换。
|
||||||
- 无数据、加载中、启动失败和暂不支持内嵌运行的作品沿用短状态文案。
|
- 无数据、加载中、启动失败和暂不支持内嵌运行的作品沿用短状态文案。
|
||||||
|
|
||||||
桌面端仍保持现有首页布局,只把一级导航文案从“首页”改为“推荐”。
|
桌面端仍保持现有首页布局,只把一级导航文案从“首页”改为“推荐”。
|
||||||
@@ -74,14 +77,16 @@
|
|||||||
|
|
||||||
## 7. 验收
|
## 7. 验收
|
||||||
|
|
||||||
1. 移动端底部导航显示“推荐 / 发现 / 创作 / 草稿 / 我的”,未登录时显示“推荐 / 创作 / 发现”。
|
1. 移动端底部导航非推荐页显示“推荐 / 发现 / 创作 / 草稿 / 我的”,未登录时显示“推荐 / 创作 / 发现”;停留在推荐页时当前 Tab 显示“下一个”。
|
||||||
2. 点击“推荐”直接看到公开作品启动后的内容,不再先看到搜索框、频道 Tab 或封面卡流。
|
2. 点击“推荐”直接看到公开作品启动后的内容,不再先看到搜索框、频道 Tab 或封面卡流。
|
||||||
3. 点击“发现”可看到搜索、推荐、今日、分类、排行子 Tab。
|
3. 点击“发现”可看到搜索、推荐、今日、分类、排行子 Tab。
|
||||||
4. 点击“草稿”看到原创作页作品列表。
|
4. 点击“草稿”看到原创作页作品列表。
|
||||||
5. 点击“创作”只看到新建创作入口。
|
5. 点击“创作”只看到新建创作入口。
|
||||||
6. “我的”里的“玩过”弹层包含原存档列表入口,点击存档能继续恢复。
|
6. “我的”里的“玩过”弹层包含原存档列表入口,点击存档能继续恢复。
|
||||||
7. 移动端底部导航为悬浮胶囊样式,保留当前明暗主题色变量,不新增第三套主题。
|
7. 移动端底部导航为悬浮胶囊样式,保留当前明暗主题色变量,不新增第三套主题。
|
||||||
8. 推荐页底部作品区可横向滑动并切换作品,切换后上方运行视口同步进入对应作品内容。
|
8. 推荐页不出现额外底部作品卡或横滑切换块,运行视口、加载态和错误态跟随当前明暗主题。
|
||||||
|
9. 拼图玩法背景、HUD、按钮、弹窗、排行榜和相似作品卡跟随平台主题色;暗色主题仍保留深色游戏感,亮色主题不得出现大面积固定黑底。
|
||||||
|
10. 推荐页作品信息区点击无效,上滑切下一个、下滑切上一个;点击底部“下一个”也切下一个作品。
|
||||||
|
|
||||||
## 8. 2026-05-07 未登录三栏补充
|
## 8. 2026-05-07 未登录三栏补充
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,8 @@
|
|||||||
|
|
||||||
1. 左侧保留返回按钮。
|
1. 左侧保留返回按钮。
|
||||||
2. 中间居中展示关卡主信息:
|
2. 中间居中展示关卡主信息:
|
||||||
- 第一行:拼图关卡名
|
- 第一行:`第 N 关` 与拼图关卡名,二者保持同一行。
|
||||||
- 第二行:作者昵称
|
- 第二行:紧凑倒计时。
|
||||||
- 第三行:`第 N 关`
|
|
||||||
3. 右侧新增设置按钮。
|
3. 右侧新增设置按钮。
|
||||||
|
|
||||||
同时移除以下冗余标识:
|
同时移除以下冗余标识:
|
||||||
@@ -31,12 +30,27 @@
|
|||||||
|
|
||||||
### 1.1 2026-04-30 顶栏与底部工具补充
|
### 1.1 2026-04-30 顶栏与底部工具补充
|
||||||
|
|
||||||
1. 顶栏作者信息不再只显示一行作者名,必须展示为作者头像与昵称组合;当前运行态只提供昵称时,用昵称首字生成圆形占位头像。
|
1. 历史口径曾要求顶栏展示作者头像与昵称;该要求已被 2026-05-08 精简口径替代,当前拼图棋盘 HUD 不再展示作者信息。
|
||||||
2. 倒计时组件提升为顶栏中的强信息,字号、内边距和图标尺寸都需要明显大于作者昵称与关卡序号。
|
2. 倒计时组件保持为顶栏中的强信息,但需要采用紧凑尺寸,不得遮挡棋盘内容。
|
||||||
3. 底部只保留 `提示 / 原图 / 冻结` 三个功能按钮,并整体居中展示;三个按钮触控面积和图标字号都需要放大。
|
3. 底部只保留 `提示 / 原图 / 冻结` 三个功能按钮,并整体居中展示;三个按钮触控面积和图标字号都需要放大。
|
||||||
4. 底部不再展示“等待下一关候选”这类状态占位。通关后在三个道具按钮上方固定展示“下一关”按钮,展示条件只依赖当前关卡已通关,不依赖 `recommendedNextProfileId` 是否已有值。
|
4. 底部不再展示“等待下一关候选”这类状态占位。通关后在三个道具按钮上方固定展示“下一关”按钮,展示条件只依赖当前关卡已通关,不依赖 `recommendedNextProfileId` 是否已有值。
|
||||||
5. 点击底部“下一关”按钮继续调用运行时壳层已有 `onAdvanceNextLevel` 事件;正式 run 由后端 `next-level` 选择候选,本地 run 由 `local-next-level` 生成或接续下一关,前端不在按钮层自行决定下一关来源。
|
5. 点击底部“下一关”按钮继续调用运行时壳层已有 `onAdvanceNextLevel` 事件;正式 run 由后端 `next-level` 选择候选,本地 run 由 `local-next-level` 生成或接续下一关,前端不在按钮层自行决定下一关来源。
|
||||||
|
|
||||||
|
### 1.2 2026-05-08 推荐页嵌入态 HUD 精简
|
||||||
|
|
||||||
|
1. 拼图运行时顶栏不再展示作者头像、作者昵称或作者首字占位,作者信息只在推荐页作品信息、详情页和排行榜等非棋盘 HUD 区域展示。
|
||||||
|
2. 顶部主信息压缩为两行:第一行 `第 N 关 + 关卡名`,第二行小号倒计时;倒计时不能使用此前的大号胶囊尺寸。
|
||||||
|
3. 返回按钮和设置按钮上移并缩小移动端基础尺寸,减少对棋盘顶部空间的占用。
|
||||||
|
4. 棋盘容器必须保留固定顶部安全区,确保关卡名和倒计时不会遮挡拼图内容。
|
||||||
|
5. 推荐页嵌入时只调整外壳间距和 HUD 布局,不改写作品关卡、作者、道具、时间限制或图片资产等作品设定。
|
||||||
|
|
||||||
|
### 1.3 2026-05-08 主题色联动
|
||||||
|
|
||||||
|
1. 拼图运行态根容器、背景、棋盘底色、顶部 HUD、底部工具栏、加载态、失败弹窗、通关弹窗、道具确认弹窗、设置弹窗和相似作品卡必须通过 `src/index.css` 中的 `--puzzle-runtime-*` 主题变量控制。
|
||||||
|
2. `platform-theme--light` 下拼图玩法背景应使用浅色平台底色与粉橙主色点缀,文字使用平台正文 token;不得继续使用固定 `bg-slate-950 text-white` 作为大面积底色。
|
||||||
|
3. `platform-theme--dark` 下可保留深色棋盘氛围,但按钮、选中态、倒计时、弹窗边框和推荐页加载态仍要从主题 token 取色,避免局部色板漂移。
|
||||||
|
4. 推荐页内嵌拼图时,父级必须保持 `platform-theme` 类可传递到 `PuzzleRuntimeShell`,不能让 runtime 脱离平台主题变量。
|
||||||
|
|
||||||
### 2. 拼图块显示规则
|
### 2. 拼图块显示规则
|
||||||
|
|
||||||
运行时单块右下角编号全部移除。
|
运行时单块右下角编号全部移除。
|
||||||
|
|||||||
1
server-rs/Cargo.lock
generated
1
server-rs/Cargo.lock
generated
@@ -119,7 +119,6 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tokio-tungstenite 0.27.0",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ shared-logging = { workspace = true }
|
|||||||
spacetime-client = { workspace = true }
|
spacetime-client = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] }
|
||||||
tokio-stream = { workspace = true }
|
tokio-stream = { workspace = true }
|
||||||
tokio-tungstenite = { workspace = true }
|
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
time = { workspace = true, features = ["formatting"] }
|
time = { workspace = true, features = ["formatting"] }
|
||||||
tower-http = { workspace = true, features = ["trace"] }
|
tower-http = { workspace = true, features = ["trace"] }
|
||||||
|
|||||||
@@ -6,18 +6,17 @@ use axum::{
|
|||||||
ws::{Message as ClientWsMessage, WebSocket, WebSocketUpgrade},
|
ws::{Message as ClientWsMessage, WebSocket, WebSocketUpgrade},
|
||||||
},
|
},
|
||||||
http::{HeaderValue, StatusCode, header},
|
http::{HeaderValue, StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::Response,
|
||||||
};
|
};
|
||||||
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
||||||
use platform_speech::{
|
use platform_speech::{
|
||||||
AsrAudioConfig, AsrFrameKind, PublicSpeechConfig, PublicSpeechEndpoints, SpeechError,
|
AsrAudioConfig, AsrFrameKind, PublicSpeechConfig, PublicSpeechEndpoints, SpeechError,
|
||||||
TtsAudioParams, TtsBidirectionClientEvent, TtsSseRequest, VolcengineSpeechClient,
|
TtsAudioParams, TtsBidirectionClientEvent, TtsSseRequest, UpstreamWsError, UpstreamWsMessage,
|
||||||
VolcengineSpeechConfig, build_asr_frame, build_asr_full_client_request,
|
VolcengineSpeechClient, VolcengineSpeechConfig, build_asr_frame, build_asr_full_client_request,
|
||||||
build_tts_bidirection_frame_from_client_event, default_asr_request_payload,
|
build_tts_bidirection_frame_from_client_event, default_asr_request_payload,
|
||||||
parse_asr_response_frame, parse_tts_response_frame, tts_response_to_client_value,
|
parse_asr_response_frame, parse_tts_response_frame, tts_response_to_client_value,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio_tungstenite::tungstenite::Message as UpstreamWsMessage;
|
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -249,12 +248,12 @@ async fn proxy_asr_websocket(socket: WebSocket, client: VolcengineSpeechClient,
|
|||||||
}
|
}
|
||||||
Ok(UpstreamWsMessage::Text(text)) => {
|
Ok(UpstreamWsMessage::Text(text)) => {
|
||||||
browser_sender
|
browser_sender
|
||||||
.send(ClientWsMessage::Text(text))
|
.send(ClientWsMessage::Text(text.to_string().into()))
|
||||||
.await
|
.await
|
||||||
.map_err(map_client_ws_send_error)?;
|
.map_err(map_client_ws_send_error)?;
|
||||||
}
|
}
|
||||||
Ok(UpstreamWsMessage::Close(close)) => {
|
Ok(UpstreamWsMessage::Close(_)) => {
|
||||||
let _ = browser_sender.send(ClientWsMessage::Close(close)).await;
|
let _ = browser_sender.send(ClientWsMessage::Close(None)).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(UpstreamWsMessage::Ping(bytes)) => {
|
Ok(UpstreamWsMessage::Ping(bytes)) => {
|
||||||
@@ -363,12 +362,12 @@ async fn proxy_tts_bidirection_websocket(socket: WebSocket, client: VolcengineSp
|
|||||||
}
|
}
|
||||||
Ok(UpstreamWsMessage::Text(text)) => {
|
Ok(UpstreamWsMessage::Text(text)) => {
|
||||||
browser_sender
|
browser_sender
|
||||||
.send(ClientWsMessage::Text(text))
|
.send(ClientWsMessage::Text(text.to_string().into()))
|
||||||
.await
|
.await
|
||||||
.map_err(map_client_ws_send_error)?;
|
.map_err(map_client_ws_send_error)?;
|
||||||
}
|
}
|
||||||
Ok(UpstreamWsMessage::Close(close)) => {
|
Ok(UpstreamWsMessage::Close(_)) => {
|
||||||
let _ = browser_sender.send(ClientWsMessage::Close(close)).await;
|
let _ = browser_sender.send(ClientWsMessage::Close(None)).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(UpstreamWsMessage::Ping(bytes)) => {
|
Ok(UpstreamWsMessage::Ping(bytes)) => {
|
||||||
@@ -451,7 +450,7 @@ fn map_speech_error(error: SpeechError) -> AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_ws_send_error(error: tokio_tungstenite::tungstenite::Error) -> SpeechError {
|
fn map_ws_send_error(error: UpstreamWsError) -> SpeechError {
|
||||||
SpeechError::Upstream(format!("发送火山语音 WebSocket 帧失败:{error}"))
|
SpeechError::Upstream(format!("发送火山语音 WebSocket 帧失败:{error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use tokio_tungstenite::{
|
|||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub use tokio_tungstenite::tungstenite::{Error as UpstreamWsError, Message as UpstreamWsMessage};
|
||||||
|
|
||||||
pub const DEFAULT_ASR_WS_URL: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async";
|
pub const DEFAULT_ASR_WS_URL: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async";
|
||||||
pub const DEFAULT_TTS_BIDIRECTION_WS_URL: &str =
|
pub const DEFAULT_TTS_BIDIRECTION_WS_URL: &str =
|
||||||
"wss://openspeech.bytedance.com/api/v3/tts/bidirection";
|
"wss://openspeech.bytedance.com/api/v3/tts/bidirection";
|
||||||
|
|||||||
@@ -4699,6 +4699,31 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const saveAndExitRecommendPuzzleRuntime = useCallback(async () => {
|
||||||
|
if (activeRecommendRuntimeKind !== 'puzzle') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRun = puzzleRunRef.current;
|
||||||
|
if (!currentRun) {
|
||||||
|
setActiveRecommendRuntimeKind(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文注释:推荐页嵌入拼图的“保存并退出”沿用现有运行态语义;
|
||||||
|
// 正式 run 的每次交换/拖动/通关已写回后端,退出时只收口暂停和本地快照。
|
||||||
|
const closedRun = currentRun.currentLevel
|
||||||
|
? setLocalPuzzlePaused(currentRun, false)
|
||||||
|
: currentRun;
|
||||||
|
puzzleRunRef.current = null;
|
||||||
|
setPuzzleRun(null);
|
||||||
|
setActiveRecommendRuntimeKind(null);
|
||||||
|
|
||||||
|
if (closedRun.currentLevel) {
|
||||||
|
setPuzzleError(null);
|
||||||
|
}
|
||||||
|
}, [activeRecommendRuntimeKind, setPuzzleError]);
|
||||||
|
|
||||||
const leaveAgentWorkspace = useCallback(() => {
|
const leaveAgentWorkspace = useCallback(() => {
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
sessionController.resetSessionViewState();
|
sessionController.resetSessionViewState();
|
||||||
@@ -5952,6 +5977,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
async (entry: PlatformPublicGalleryCard) => {
|
async (entry: PlatformPublicGalleryCard) => {
|
||||||
const entryKey = getPlatformPublicGalleryEntryKey(entry);
|
const entryKey = getPlatformPublicGalleryEntryKey(entry);
|
||||||
const runtimeKind = getPlatformRecommendRuntimeKind(entry);
|
const runtimeKind = getPlatformRecommendRuntimeKind(entry);
|
||||||
|
if (entryKey !== activeRecommendEntryKey) {
|
||||||
|
await saveAndExitRecommendPuzzleRuntime();
|
||||||
|
}
|
||||||
setActiveRecommendEntryKey(entryKey);
|
setActiveRecommendEntryKey(entryKey);
|
||||||
setActiveRecommendRuntimeKind(runtimeKind);
|
setActiveRecommendRuntimeKind(runtimeKind);
|
||||||
setActiveRecommendRuntimeError(null);
|
setActiveRecommendRuntimeError(null);
|
||||||
@@ -6025,6 +6053,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
activeRecommendEntryKey,
|
||||||
|
saveAndExitRecommendPuzzleRuntime,
|
||||||
selectedPuzzleDetail,
|
selectedPuzzleDetail,
|
||||||
setBigFishError,
|
setBigFishError,
|
||||||
setMatch3DError,
|
setMatch3DError,
|
||||||
@@ -6037,6 +6067,38 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
startVisualNovelRunFromProfile,
|
startVisualNovelRunFromProfile,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const selectAdjacentRecommendRuntimeEntry = useCallback(
|
||||||
|
(direction: 1 | -1) => {
|
||||||
|
if (recommendRuntimeEntries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = recommendRuntimeEntries.findIndex(
|
||||||
|
(entry) =>
|
||||||
|
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||||
|
);
|
||||||
|
const baseIndex = activeIndex >= 0 ? activeIndex : 0;
|
||||||
|
const nextIndex =
|
||||||
|
(baseIndex + direction + recommendRuntimeEntries.length) %
|
||||||
|
recommendRuntimeEntries.length;
|
||||||
|
const nextEntry = recommendRuntimeEntries[nextIndex];
|
||||||
|
if (!nextEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
getPlatformPublicGalleryEntryKey(nextEntry) === activeRecommendEntryKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectRecommendRuntimeEntry(nextEntry);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeRecommendEntryKey,
|
||||||
|
recommendRuntimeEntries,
|
||||||
|
selectRecommendRuntimeEntry,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const recommendRuntimeContent = useMemo(() => {
|
const recommendRuntimeContent = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
@@ -6153,6 +6215,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
error={puzzleError}
|
error={puzzleError}
|
||||||
embedded
|
embedded
|
||||||
|
hideBackButton
|
||||||
|
hideExitControls
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setActiveRecommendRuntimeKind(null);
|
setActiveRecommendRuntimeKind(null);
|
||||||
}}
|
}}
|
||||||
@@ -6270,7 +6334,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 items-center justify-center bg-black px-5 text-center text-sm font-semibold leading-6 text-white">
|
<div className={`platform-theme ${platformThemeClass} flex h-full min-h-0 items-center justify-center bg-[var(--platform-recommend-runtime-state-fill)] px-5 text-center text-sm font-semibold leading-6 text-[var(--platform-recommend-runtime-state-text)]`}>
|
||||||
正在读取世界
|
正在读取世界
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -6291,6 +6355,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
match3dError,
|
match3dError,
|
||||||
match3dFlow,
|
match3dFlow,
|
||||||
match3dRun,
|
match3dRun,
|
||||||
|
platformThemeClass,
|
||||||
puzzleError,
|
puzzleError,
|
||||||
puzzleRun,
|
puzzleRun,
|
||||||
recommendRuntimeEntries,
|
recommendRuntimeEntries,
|
||||||
@@ -7357,9 +7422,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isVisualNovelBusy
|
isVisualNovelBusy
|
||||||
}
|
}
|
||||||
recommendRuntimeError={activeRecommendRuntimeError}
|
recommendRuntimeError={activeRecommendRuntimeError}
|
||||||
onSelectRecommendEntry={(entry) => {
|
onSelectNextRecommendEntry={() =>
|
||||||
void selectRecommendRuntimeEntry(entry);
|
selectAdjacentRecommendRuntimeEntry(1)
|
||||||
}}
|
}
|
||||||
|
onSelectPreviousRecommendEntry={() =>
|
||||||
|
selectAdjacentRecommendRuntimeEntry(-1)
|
||||||
|
}
|
||||||
onOpenLibraryDetail={(entry) => {
|
onOpenLibraryDetail={(entry) => {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
void detailNavigation.openLibraryDetail(entry);
|
void detailNavigation.openLibraryDetail(entry);
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ test('首次退出引导的作品改造按钮进入改造流程', () => {
|
|||||||
expect(screen.queryByRole('dialog')).toBeNull();
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => {
|
test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
|
||||||
const runWithoutNext: PuzzleRunSnapshot = {
|
const runWithoutNext: PuzzleRunSnapshot = {
|
||||||
...clearedRun,
|
...clearedRun,
|
||||||
recommendedNextProfileId: null,
|
recommendedNextProfileId: null,
|
||||||
@@ -256,15 +256,17 @@ test('顶部作者显示头像昵称,底部功能居中放大且不显示等
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const avatar = screen.getByText('测');
|
|
||||||
const timer = screen.getByText('4:48');
|
const timer = screen.getByText('4:48');
|
||||||
const hintButton = screen.getByRole('button', { name: '提示' });
|
const hintButton = screen.getByRole('button', { name: '提示' });
|
||||||
const referenceButton = screen.getByRole('button', { name: '原图' });
|
const referenceButton = screen.getByRole('button', { name: '原图' });
|
||||||
const freezeButton = screen.getByRole('button', { name: '冻结' });
|
const freezeButton = screen.getByRole('button', { name: '冻结' });
|
||||||
|
|
||||||
expect(avatar.className).toContain('rounded-full');
|
expect(screen.queryByText('测试作者')).toBeNull();
|
||||||
expect(screen.getByText('测试作者')).toBeTruthy();
|
expect(screen.getByText('第 1 关')).toBeTruthy();
|
||||||
expect(timer.className).toContain('text-2xl');
|
expect(screen.getByText('潮雾拼图')).toBeTruthy();
|
||||||
|
expect(timer.className).toContain('puzzle-runtime-timer');
|
||||||
|
expect(timer.className).toContain('text-lg');
|
||||||
|
expect(timer.className).not.toContain('text-2xl');
|
||||||
expect(hintButton.className).toContain('h-16');
|
expect(hintButton.className).toContain('h-16');
|
||||||
expect(referenceButton.className).toContain('h-16');
|
expect(referenceButton.className).toContain('h-16');
|
||||||
expect(freezeButton.className).toContain('h-16');
|
expect(freezeButton.className).toContain('h-16');
|
||||||
@@ -467,14 +469,32 @@ test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', ()
|
|||||||
);
|
);
|
||||||
|
|
||||||
const board = screen.getByTestId('puzzle-board');
|
const board = screen.getByTestId('puzzle-board');
|
||||||
|
expect(board.className).toContain('puzzle-runtime-board');
|
||||||
expect(board.className).toContain('aspect-square');
|
expect(board.className).toContain('aspect-square');
|
||||||
expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_16.5rem))]');
|
expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_14rem))]');
|
||||||
expect(board.className).not.toContain('aspect-video');
|
expect(board.className).not.toContain('aspect-video');
|
||||||
expect(board.className).not.toContain('aspect-[9/16]');
|
expect(board.className).not.toContain('aspect-[9/16]');
|
||||||
expect(board.getAttribute('style')).toContain('grid-template-rows');
|
expect(board.getAttribute('style')).toContain('grid-template-rows');
|
||||||
expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull();
|
expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('拼图运行态主体使用主题语义类承接明暗主题', () => {
|
||||||
|
const { container } = renderPuzzleRuntime(
|
||||||
|
<PuzzleRuntimeShell
|
||||||
|
run={null}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onSwapPieces={vi.fn()}
|
||||||
|
onDragPiece={vi.fn()}
|
||||||
|
onAdvanceNextLevel={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstElementChild?.className).toContain(
|
||||||
|
'puzzle-runtime-shell',
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.puzzle-runtime-pill')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
test('合并块按实际拼块外轮廓描边', () => {
|
test('合并块按实际拼块外轮廓描边', () => {
|
||||||
const mergedRun: PuzzleRunSnapshot = {
|
const mergedRun: PuzzleRunSnapshot = {
|
||||||
...clearedRun,
|
...clearedRun,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type PuzzleRuntimeShellProps = {
|
|||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
hideBackButton?: boolean;
|
hideBackButton?: boolean;
|
||||||
|
hideExitControls?: boolean;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onRemodelWork?: (profileId: string) => void | Promise<void>;
|
onRemodelWork?: (profileId: string) => void | Promise<void>;
|
||||||
@@ -157,10 +158,6 @@ function formatTimerMs(value: number | null | undefined) {
|
|||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAuthorAvatarLabel(authorDisplayName: string) {
|
|
||||||
return authorDisplayName.trim().slice(0, 1) || '玩';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveActiveFreezeElapsedMs(
|
function resolveActiveFreezeElapsedMs(
|
||||||
level: PuzzleRuntimeLevelSnapshot,
|
level: PuzzleRuntimeLevelSnapshot,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
@@ -309,6 +306,7 @@ export function PuzzleRuntimeShell({
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
error = null,
|
error = null,
|
||||||
hideBackButton = false,
|
hideBackButton = false,
|
||||||
|
hideExitControls = false,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
onBack,
|
onBack,
|
||||||
onRemodelWork,
|
onRemodelWork,
|
||||||
@@ -790,9 +788,9 @@ export function PuzzleRuntimeShell({
|
|||||||
if (!run || !currentLevel || !board) {
|
if (!run || !currentLevel || !board) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center bg-slate-950 text-white`}
|
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
|
<div className="puzzle-runtime-pill flex items-center gap-2 rounded-full px-5 py-3 text-sm">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
正在进入拼图关卡
|
正在进入拼图关卡
|
||||||
</div>
|
</div>
|
||||||
@@ -963,9 +961,6 @@ export function PuzzleRuntimeShell({
|
|||||||
const canShowNextAction =
|
const canShowNextAction =
|
||||||
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
|
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
|
||||||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||||
const authorAvatarLabel = resolveAuthorAvatarLabel(
|
|
||||||
currentLevel.authorDisplayName,
|
|
||||||
);
|
|
||||||
const exitPromptProfileId = currentLevel.profileId.trim();
|
const exitPromptProfileId = currentLevel.profileId.trim();
|
||||||
const leaderboardEntries =
|
const leaderboardEntries =
|
||||||
(currentLevel.leaderboardEntries ?? []).length > 0
|
(currentLevel.leaderboardEntries ?? []).length > 0
|
||||||
@@ -979,6 +974,10 @@ export function PuzzleRuntimeShell({
|
|||||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||||
|
|
||||||
const handleBackRequest = () => {
|
const handleBackRequest = () => {
|
||||||
|
if (hideExitControls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
onRemodelWork &&
|
onRemodelWork &&
|
||||||
exitPromptProfileId &&
|
exitPromptProfileId &&
|
||||||
@@ -1069,7 +1068,10 @@ export function PuzzleRuntimeShell({
|
|||||||
if (propKind === 'freezeTime') {
|
if (propKind === 'freezeTime') {
|
||||||
// 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态;
|
// 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态;
|
||||||
// 这种边界同步只关闭确认窗,不再播放冻结成功反馈。
|
// 这种边界同步只关闭确认窗,不再播放冻结成功反馈。
|
||||||
const resultLevel = (useResult ?? null)?.currentLevel ?? currentLevelRef.current;
|
const resultLevel =
|
||||||
|
useResult && typeof useResult === 'object'
|
||||||
|
? useResult.currentLevel
|
||||||
|
: currentLevelRef.current;
|
||||||
if (resultLevel?.status === 'playing') {
|
if (resultLevel?.status === 'playing') {
|
||||||
setIsFreezeEffectVisible(true);
|
setIsFreezeEffectVisible(true);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -1084,9 +1086,9 @@ export function PuzzleRuntimeShell({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center bg-slate-950 text-white`}
|
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||||
>
|
>
|
||||||
<div className="relative h-full w-full overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(251,191,36,0.18),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(249,115,22,0.16),transparent_26%),linear-gradient(180deg,#2d160e,#020617)]">
|
<div className="puzzle-runtime-stage relative h-full w-full overflow-hidden">
|
||||||
{currentLevel.coverImageSrc ? (
|
{currentLevel.coverImageSrc ? (
|
||||||
<ResolvedAssetImage
|
<ResolvedAssetImage
|
||||||
src={currentLevel.coverImageSrc}
|
src={currentLevel.coverImageSrc}
|
||||||
@@ -1095,49 +1097,40 @@ export function PuzzleRuntimeShell({
|
|||||||
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
|
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
|
<div className="puzzle-runtime-stage__grid" />
|
||||||
|
|
||||||
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
|
<div className="absolute left-0 top-0 z-20 w-full px-3 py-3 sm:px-4">
|
||||||
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm:gap-3">
|
<div className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] sm:gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBackRequest}
|
onClick={handleBackRequest}
|
||||||
aria-label="返回上一页"
|
aria-label="返回上一页"
|
||||||
disabled={hideBackButton}
|
disabled={hideBackButton}
|
||||||
className={`h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur ${
|
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
|
||||||
hideBackButton ? 'invisible pointer-events-none' : 'inline-flex'
|
hideBackButton ? 'invisible pointer-events-none' : 'inline-flex'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col items-center gap-2 rounded-[1.35rem] bg-black/30 px-3 py-3 text-center backdrop-blur sm:px-5">
|
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(15rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center gap-1.5 rounded-[1.1rem] px-3 py-2 text-center sm:max-w-[18rem] sm:px-4">
|
||||||
<div className="line-clamp-1 max-w-full text-sm font-black text-white sm:text-base">
|
<div className="flex max-w-full items-center justify-center gap-1.5">
|
||||||
{currentLevel.levelName}
|
<span className="puzzle-runtime-level-badge shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold sm:text-[11px]">
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 font-mono text-2xl font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.24)] sm:text-3xl ${
|
|
||||||
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
|
||||||
? 'bg-red-500/24 text-red-100'
|
|
||||||
: 'bg-white/12 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Clock className="h-5 w-5 sm:h-6 sm:w-6" />
|
|
||||||
{formatTimerMs(displayRemainingMs)}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 max-w-full items-center justify-center gap-2 text-white/82">
|
|
||||||
<span
|
|
||||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/16 bg-amber-200 text-xs font-black text-slate-950 shadow-[0_8px_20px_rgba(0,0,0,0.2)]"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{authorAvatarLabel}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate text-xs font-semibold sm:text-sm">
|
|
||||||
{currentLevel.authorDisplayName}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-amber-100/90 sm:text-[11px]">
|
|
||||||
{levelLabel}
|
{levelLabel}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-sm font-black sm:text-base">
|
||||||
|
{currentLevel.levelName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 font-mono text-lg font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.2)] sm:text-xl ${
|
||||||
|
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
||||||
|
? 'puzzle-runtime-timer--urgent'
|
||||||
|
: 'puzzle-runtime-timer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
|
{formatTimerMs(displayRemainingMs)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1146,21 +1139,21 @@ export function PuzzleRuntimeShell({
|
|||||||
onClick={() => setIsSettingsPanelOpen(true)}
|
onClick={() => setIsSettingsPanelOpen(true)}
|
||||||
aria-label="打开拼图设置"
|
aria-label="打开拼图设置"
|
||||||
title="打开拼图设置"
|
title="打开拼图设置"
|
||||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/60"
|
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
|
||||||
>
|
>
|
||||||
<PixelIcon
|
<PixelIcon
|
||||||
src={CHROME_ICONS.settings}
|
src={CHROME_ICONS.settings}
|
||||||
className="h-[1.4rem] w-[1.4rem] drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)]"
|
className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)] sm:h-[1.4rem] sm:w-[1.4rem]"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center px-1 py-3 pt-28 pb-32 sm:p-4">
|
<div className="absolute inset-0 flex items-center justify-center px-1 py-3 pt-24 pb-32 sm:px-4 sm:py-4 sm:pt-24 sm:pb-28">
|
||||||
<div
|
<div
|
||||||
ref={boardRef}
|
ref={boardRef}
|
||||||
data-testid="puzzle-board"
|
data-testid="puzzle-board"
|
||||||
className="relative grid aspect-square w-full max-w-[min(99vw,calc(100vh_-_16.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:max-w-[min(92vw,calc(100vh_-_17rem))] sm:rounded-[1.45rem]"
|
className="puzzle-runtime-board relative grid aspect-square w-full max-w-[min(99vw,calc(100vh_-_14rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border backdrop-blur-sm sm:max-w-[min(92vw,calc(100vh_-_13rem))] sm:rounded-[1.45rem]"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
||||||
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
|
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
|
||||||
@@ -1216,14 +1209,14 @@ export function PuzzleRuntimeShell({
|
|||||||
pieceElementRefMap.current.delete(piece.pieceId);
|
pieceElementRefMap.current.delete(piece.pieceId);
|
||||||
}}
|
}}
|
||||||
data-piece-id={piece?.pieceId ?? undefined}
|
data-piece-id={piece?.pieceId ?? undefined}
|
||||||
className={`relative flex h-full items-center justify-center overflow-hidden rounded-[0.85rem] border-2 border-white/22 text-sm font-black transition ${
|
className={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden rounded-[0.85rem] border-2 text-sm font-black transition ${
|
||||||
occupied
|
occupied
|
||||||
? isSelected
|
? isSelected
|
||||||
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
|
? 'puzzle-runtime-piece--selected'
|
||||||
: isMerged
|
: isMerged
|
||||||
? 'border-transparent bg-transparent text-white'
|
? 'puzzle-runtime-piece--merged'
|
||||||
: 'bg-white/12 text-white'
|
: 'puzzle-runtime-piece--filled'
|
||||||
: 'border-white/8 bg-black/18 text-white/20'
|
: 'puzzle-runtime-piece--empty'
|
||||||
} ${
|
} ${
|
||||||
isMerged
|
isMerged
|
||||||
? 'transition-colors'
|
? 'transition-colors'
|
||||||
@@ -1282,7 +1275,7 @@ export function PuzzleRuntimeShell({
|
|||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
|
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-black/10" />
|
<div className="puzzle-runtime-piece-overlay absolute inset-0" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
@@ -1467,12 +1460,12 @@ export function PuzzleRuntimeShell({
|
|||||||
|
|
||||||
<div className="absolute bottom-0 left-0 z-20 flex w-full flex-col items-center gap-2 px-3 py-3 sm:px-4 sm:py-4">
|
<div className="absolute bottom-0 left-0 z-20 flex w-full flex-col items-center gap-2 px-3 py-3 sm:px-4 sm:py-4">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
|
<div className="puzzle-runtime-error-chip rounded-full px-3 py-1 text-xs">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||||||
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
|
<div className="puzzle-runtime-status-chip rounded-full px-3 py-1 text-xs">
|
||||||
已选择
|
已选择
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1491,21 +1484,21 @@ export function PuzzleRuntimeShell({
|
|||||||
levelId: run.nextLevelId ?? null,
|
levelId: run.nextLevelId ?? null,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-bold text-slate-950 shadow-[0_14px_36px_rgba(251,191,36,0.26)] transition hover:bg-amber-100 disabled:opacity-45"
|
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
|
||||||
>
|
>
|
||||||
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2 rounded-full bg-black/36 p-2 backdrop-blur sm:gap-3">
|
<div className="puzzle-runtime-toolbar flex items-center justify-center gap-2 rounded-full p-2 sm:gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isInteractionLocked}
|
disabled={isInteractionLocked}
|
||||||
onClick={() => openPropDialog('hint', '使用提示')}
|
onClick={() => openPropDialog('hint', '使用提示')}
|
||||||
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
|
className="puzzle-runtime-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
|
||||||
>
|
>
|
||||||
<Lightbulb className="h-6 w-6 text-amber-100" />
|
<Lightbulb className="puzzle-runtime-tool-button__warm h-6 w-6" />
|
||||||
提示
|
提示
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -1519,10 +1512,10 @@ export function PuzzleRuntimeShell({
|
|||||||
}
|
}
|
||||||
openPropDialog('reference', '查看原图');
|
openPropDialog('reference', '查看原图');
|
||||||
}}
|
}}
|
||||||
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition hover:bg-white/10 disabled:opacity-45 ${
|
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45 ${
|
||||||
isOriginalOverlayVisible
|
isOriginalOverlayVisible
|
||||||
? 'bg-sky-200 text-slate-950'
|
? 'puzzle-runtime-tool-button--active'
|
||||||
: 'text-white/86'
|
: 'puzzle-runtime-tool-button'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Eye className="h-6 w-6" />
|
<Eye className="h-6 w-6" />
|
||||||
@@ -1532,9 +1525,9 @@ export function PuzzleRuntimeShell({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isInteractionLocked}
|
disabled={isInteractionLocked}
|
||||||
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
||||||
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
|
className="puzzle-runtime-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
|
||||||
>
|
>
|
||||||
<Snowflake className="h-6 w-6 text-cyan-100" />
|
<Snowflake className="puzzle-runtime-tool-button__cool h-6 w-6" />
|
||||||
冻结
|
冻结
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1565,7 +1558,7 @@ export function PuzzleRuntimeShell({
|
|||||||
|
|
||||||
{propDialog ? (
|
{propDialog ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isPropConfirming) {
|
if (!isPropConfirming) {
|
||||||
setPropDialog(null);
|
setPropDialog(null);
|
||||||
@@ -1576,35 +1569,35 @@ export function PuzzleRuntimeShell({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="puzzle-prop-confirm-title"
|
aria-labelledby="puzzle-prop-confirm-title"
|
||||||
className="pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
className="puzzle-runtime-dialog pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-3 border-b border-white/10 px-5 py-4">
|
<header className="puzzle-runtime-dialog__line flex items-center gap-3 border-b px-5 py-4">
|
||||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
|
<span className="puzzle-runtime-primary-button inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<h2
|
<h2
|
||||||
id="puzzle-prop-confirm-title"
|
id="puzzle-prop-confirm-title"
|
||||||
className="text-sm font-black text-white"
|
className="text-sm font-black"
|
||||||
>
|
>
|
||||||
{propDialog.title}
|
{propDialog.title}
|
||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<div className="px-5 py-4 text-sm text-white/72">
|
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
|
||||||
消耗 1 光点
|
消耗 1 光点
|
||||||
{propConfirmError ? (
|
{propConfirmError ? (
|
||||||
<div className="mt-3 rounded-[0.9rem] border border-red-300/20 bg-red-500/12 px-3 py-2 text-xs leading-5 text-red-100">
|
<div className="puzzle-runtime-error-chip mt-3 rounded-[0.9rem] border px-3 py-2 text-xs leading-5">
|
||||||
{propConfirmError}
|
{propConfirmError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-5 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPropDialog(null)}
|
onClick={() => setPropDialog(null)}
|
||||||
disabled={isPropConfirming}
|
disabled={isPropConfirming}
|
||||||
className="rounded-full border border-white/12 bg-black/20 px-4 py-2 text-xs font-bold text-zinc-200 transition hover:text-white"
|
className="puzzle-runtime-secondary-button rounded-full px-4 py-2 text-xs font-bold transition hover:brightness-105"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
@@ -1614,7 +1607,7 @@ export function PuzzleRuntimeShell({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleConfirmProp();
|
void handleConfirmProp();
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-60"
|
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-black transition hover:brightness-105 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isPropConfirming ? (
|
{isPropConfirming ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -1628,51 +1621,53 @@ export function PuzzleRuntimeShell({
|
|||||||
|
|
||||||
{isSettingsPanelOpen ? (
|
{isSettingsPanelOpen ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
|
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-3 backdrop-blur-sm sm:p-4"
|
||||||
onClick={() => setIsSettingsPanelOpen(false)}
|
onClick={() => setIsSettingsPanelOpen(false)}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="puzzle-settings-title"
|
aria-labelledby="puzzle-settings-title"
|
||||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
className="puzzle-runtime-dialog pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<header className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
<header className="puzzle-runtime-dialog__line relative border-b px-4 py-3 sm:px-5 sm:py-4">
|
||||||
<div className="min-w-0 pr-10">
|
<div className="min-w-0 pr-10">
|
||||||
<h2
|
<h2
|
||||||
id="puzzle-settings-title"
|
id="puzzle-settings-title"
|
||||||
className="text-sm font-semibold text-white"
|
className="text-sm font-semibold"
|
||||||
>
|
>
|
||||||
拼图设置
|
拼图设置
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-1 text-[11px] text-zinc-500">
|
<div className="puzzle-runtime-dialog__soft mt-1 text-[11px]">
|
||||||
调整音乐音量,查看本局进度,或返回上一页。
|
{hideExitControls
|
||||||
|
? '调整音乐音量,查看本局进度。'
|
||||||
|
: '调整音乐音量,查看本局进度,或返回上一页。'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭拼图设置"
|
aria-label="关闭拼图设置"
|
||||||
onClick={() => setIsSettingsPanelOpen(false)}
|
onClick={() => setIsSettingsPanelOpen(false)}
|
||||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
className="puzzle-runtime-dialog__soft absolute right-4 top-3 p-1 transition-colors hover:brightness-75 sm:right-5 sm:top-4"
|
||||||
>
|
>
|
||||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_65%),rgba(0,0,0,0.24)] p-4">
|
<div className="puzzle-runtime-settings-card rounded-2xl p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] tracking-[0.24em] text-sky-200/80">
|
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
|
||||||
音频
|
音频
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm font-semibold text-white">
|
<div className="mt-2 text-sm font-semibold">
|
||||||
音乐音量
|
音乐音量
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-full border border-white/10 bg-black/28 px-2 py-1 text-xs text-white/80">
|
<div className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
|
||||||
{Math.round(musicVolume * 100)}%
|
{Math.round(musicVolume * 100)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1690,37 +1685,37 @@ export function PuzzleRuntimeShell({
|
|||||||
Number(event.currentTarget.value) / 100,
|
Number(event.currentTarget.value) / 100,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="h-2 w-full cursor-pointer accent-sky-400"
|
className="h-2 w-full cursor-pointer accent-[var(--puzzle-runtime-accent-text)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
|
<div className="puzzle-runtime-settings-card rounded-2xl px-4 py-3">
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
<div className="puzzle-runtime-dialog__soft text-[10px] uppercase tracking-[0.18em]">
|
||||||
本局进度
|
本局进度
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-2 text-sm text-white/82">
|
<div className="puzzle-runtime-dialog__body mt-3 space-y-2 text-sm">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-white/56">关卡</span>
|
<span className="puzzle-runtime-dialog__soft">关卡</span>
|
||||||
<span className="font-semibold text-white">
|
<span className="font-semibold">
|
||||||
{levelLabel}
|
{levelLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-white/56">已完成关卡</span>
|
<span className="puzzle-runtime-dialog__soft">已完成关卡</span>
|
||||||
<span className="font-semibold text-white">
|
<span className="font-semibold">
|
||||||
{run.clearedLevelCount}
|
{run.clearedLevelCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-white/56">当前状态</span>
|
<span className="puzzle-runtime-dialog__soft">当前状态</span>
|
||||||
<span className="font-semibold text-white">
|
<span className="font-semibold">
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-white/56">当前用时</span>
|
<span className="puzzle-runtime-dialog__soft">当前用时</span>
|
||||||
<span className="font-mono font-semibold text-white">
|
<span className="font-mono font-semibold">
|
||||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1728,50 +1723,52 @@ export function PuzzleRuntimeShell({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5">
|
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-4 py-3 sm:px-5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsSettingsPanelOpen(false)}
|
onClick={() => setIsSettingsPanelOpen(false)}
|
||||||
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
|
className="puzzle-runtime-secondary-button rounded-full px-3 py-1.5 text-[11px] transition hover:brightness-105"
|
||||||
>
|
>
|
||||||
继续拼图
|
继续拼图
|
||||||
</button>
|
</button>
|
||||||
|
{!hideExitControls ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsSettingsPanelOpen(false);
|
setIsSettingsPanelOpen(false);
|
||||||
onBack();
|
onBack();
|
||||||
}}
|
}}
|
||||||
className={`rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100 ${
|
className={`puzzle-runtime-primary-button rounded-full px-4 py-2 text-sm font-bold transition hover:brightness-105 ${
|
||||||
hideBackButton ? 'hidden' : ''
|
hideBackButton ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
返回上一页
|
返回上一页
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isExitRemodelPromptOpen ? (
|
{isExitRemodelPromptOpen && !hideExitControls ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/76 px-4 py-6 backdrop-blur-md"
|
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="puzzle-exit-remodel-title"
|
aria-labelledby="puzzle-exit-remodel-title"
|
||||||
className="relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] border border-amber-200/24 bg-[linear-gradient(180deg,rgba(30,41,59,0.98),rgba(2,6,23,0.98))] shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
|
className="puzzle-runtime-dialog relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-amber-200/70 to-transparent" />
|
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[var(--puzzle-runtime-accent-text)] to-transparent" />
|
||||||
<header className="flex flex-col items-center px-6 pt-7 text-center">
|
<header className="flex flex-col items-center px-6 pt-7 text-center">
|
||||||
<div className="mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-amber-200/28 bg-amber-200/12 shadow-[0_16px_42px_rgba(251,191,36,0.18)]">
|
<div className="puzzle-runtime-stat-card mb-4 grid h-14 w-14 place-items-center rounded-2xl">
|
||||||
<Sparkles className="h-7 w-7 text-amber-200" />
|
<Sparkles className="puzzle-runtime-tool-button__warm h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
<h2
|
<h2
|
||||||
id="puzzle-exit-remodel-title"
|
id="puzzle-exit-remodel-title"
|
||||||
className="text-[1.75rem] font-black leading-[1.08] text-white"
|
className="text-[1.75rem] font-black leading-[1.08]"
|
||||||
>
|
>
|
||||||
体验不佳?
|
体验不佳?
|
||||||
<br />
|
<br />
|
||||||
@@ -1786,7 +1783,7 @@ export function PuzzleRuntimeShell({
|
|||||||
setIsExitRemodelPromptOpen(false);
|
setIsExitRemodelPromptOpen(false);
|
||||||
void onRemodelWork?.(exitPromptProfileId);
|
void onRemodelWork?.(exitPromptProfileId);
|
||||||
}}
|
}}
|
||||||
className="min-h-[3.25rem] rounded-2xl bg-amber-200 px-5 text-sm font-black text-slate-950 shadow-[0_14px_34px_rgba(251,191,36,0.24)] transition hover:bg-amber-100 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
|
className="puzzle-runtime-primary-button min-h-[3.25rem] rounded-2xl px-5 text-sm font-black transition hover:brightness-105 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
作品改造
|
作品改造
|
||||||
</button>
|
</button>
|
||||||
@@ -1796,7 +1793,7 @@ export function PuzzleRuntimeShell({
|
|||||||
setIsExitRemodelPromptOpen(false);
|
setIsExitRemodelPromptOpen(false);
|
||||||
onBack();
|
onBack();
|
||||||
}}
|
}}
|
||||||
className="min-h-[3rem] rounded-2xl border border-white/14 bg-white/8 px-5 text-sm font-bold text-white/92 transition hover:bg-white/12 active:translate-y-px"
|
className="puzzle-runtime-secondary-button min-h-[3rem] rounded-2xl px-5 text-sm font-bold transition hover:brightness-105 active:translate-y-px"
|
||||||
>
|
>
|
||||||
保存并退出
|
保存并退出
|
||||||
</button>
|
</button>
|
||||||
@@ -1806,32 +1803,32 @@ export function PuzzleRuntimeShell({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{runtimeStatus === 'failed' ? (
|
{runtimeStatus === 'failed' ? (
|
||||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
|
||||||
<section
|
<section
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="puzzle-failed-title"
|
aria-labelledby="puzzle-failed-title"
|
||||||
className="flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
className="puzzle-runtime-dialog flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||||
>
|
>
|
||||||
<header className="border-b border-white/10 px-5 py-4">
|
<header className="puzzle-runtime-dialog__line border-b px-5 py-4">
|
||||||
<h2
|
<h2
|
||||||
id="puzzle-failed-title"
|
id="puzzle-failed-title"
|
||||||
className="text-lg font-black text-white"
|
className="text-lg font-black"
|
||||||
>
|
>
|
||||||
关卡失败
|
关卡失败
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-1 text-xs text-white/62">
|
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
|
||||||
{currentLevel.levelName}
|
{currentLevel.levelName}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<footer className="grid grid-cols-2 gap-3 border-t border-white/10 px-5 py-4">
|
<footer className="puzzle-runtime-dialog__line grid grid-cols-2 gap-3 border-t px-5 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void onRestartLevel?.();
|
void onRestartLevel?.();
|
||||||
}}
|
}}
|
||||||
className="rounded-full border border-white/14 bg-black/24 px-4 py-2.5 text-sm font-black text-white transition hover:bg-white/10 disabled:opacity-50"
|
className="puzzle-runtime-secondary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
重新开始
|
重新开始
|
||||||
</button>
|
</button>
|
||||||
@@ -1839,7 +1836,7 @@ export function PuzzleRuntimeShell({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => openPropDialog('extendTime', '继续1分钟')}
|
onClick={() => openPropDialog('extendTime', '继续1分钟')}
|
||||||
className="rounded-full bg-amber-200 px-4 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-50"
|
className="puzzle-runtime-primary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
继续1分钟
|
继续1分钟
|
||||||
</button>
|
</button>
|
||||||
@@ -1849,32 +1846,32 @@ export function PuzzleRuntimeShell({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isClearResultOpen ? (
|
{isClearResultOpen ? (
|
||||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
|
||||||
<section
|
<section
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="puzzle-clear-result-title"
|
aria-labelledby="puzzle-clear-result-title"
|
||||||
className="flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||||
>
|
>
|
||||||
<header className="flex items-start justify-between gap-3 border-b border-white/10 px-5 py-4">
|
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
|
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||||
<Trophy className="h-4 w-4" />
|
<Trophy className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<h2
|
<h2
|
||||||
id="puzzle-clear-result-title"
|
id="puzzle-clear-result-title"
|
||||||
className="truncate text-lg font-black text-white"
|
className="truncate text-lg font-black"
|
||||||
>
|
>
|
||||||
通关完成
|
通关完成
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-1 line-clamp-1 text-xs text-white/62">
|
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
|
||||||
{currentLevel.levelName}
|
{currentLevel.levelName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭通关弹窗"
|
aria-label="关闭通关弹窗"
|
||||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/8 text-white/72 transition hover:bg-white/14 hover:text-white"
|
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDismissedClearKey(clearResultKey);
|
setDismissedClearKey(clearResultKey);
|
||||||
}}
|
}}
|
||||||
@@ -1884,26 +1881,26 @@ export function PuzzleRuntimeShell({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||||
<div className="flex items-center justify-between gap-4 rounded-[1rem] border border-amber-200/24 bg-amber-200/10 px-4 py-3">
|
<div className="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/24 text-amber-100">
|
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-white/72">
|
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
|
||||||
通关时间
|
通关时间
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xl font-black text-amber-100">
|
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
|
||||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="mb-2 text-sm font-bold text-white">
|
<div className="mb-2 text-sm font-bold">
|
||||||
排行榜
|
排行榜
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-[1rem] border border-white/10">
|
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
|
||||||
<div className="grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] bg-white/6 px-3 py-2 text-[11px] font-bold text-white/48">
|
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] px-3 py-2 text-[11px] font-bold">
|
||||||
<span>名次</span>
|
<span>名次</span>
|
||||||
<span>昵称</span>
|
<span>昵称</span>
|
||||||
<span className="text-right">通关时间</span>
|
<span className="text-right">通关时间</span>
|
||||||
@@ -1915,8 +1912,8 @@ export function PuzzleRuntimeShell({
|
|||||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||||
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
||||||
entry.isCurrentPlayer
|
entry.isCurrentPlayer
|
||||||
? 'bg-amber-200/14 text-amber-50'
|
? 'puzzle-runtime-leaderboard-row--active'
|
||||||
: 'border-t border-white/8 text-white/78'
|
: 'puzzle-runtime-leaderboard-row border-t'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-mono font-black">
|
<span className="font-mono font-black">
|
||||||
@@ -1931,7 +1928,7 @@ export function PuzzleRuntimeShell({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
|
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
|
||||||
{isBusy
|
{isBusy
|
||||||
? '正在同步真实排行榜…'
|
? '正在同步真实排行榜…'
|
||||||
: '暂无真实排行榜成绩'}
|
: '暂无真实排行榜成绩'}
|
||||||
@@ -1960,7 +1957,7 @@ export function PuzzleRuntimeShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canAdvanceDefaultNextLevel ? (
|
{canAdvanceDefaultNextLevel ? (
|
||||||
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
@@ -1970,7 +1967,7 @@ export function PuzzleRuntimeShell({
|
|||||||
levelId: run.nextLevelId ?? null,
|
levelId: run.nextLevelId ?? null,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
>
|
>
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -2003,9 +2000,9 @@ function PuzzleNextWorkCard({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] border border-white/10 bg-white/6 text-left transition hover:border-amber-200/40 hover:bg-amber-200/10 disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
|
className="puzzle-runtime-next-card group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] text-left transition disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
|
||||||
>
|
>
|
||||||
<div className="relative min-h-full bg-white/8 sm:aspect-[1.35]">
|
<div className="puzzle-runtime-next-card__media relative min-h-full sm:aspect-[1.35]">
|
||||||
{item.coverImageSrc ? (
|
{item.coverImageSrc ? (
|
||||||
<ResolvedAssetImage
|
<ResolvedAssetImage
|
||||||
src={item.coverImageSrc}
|
src={item.coverImageSrc}
|
||||||
@@ -2015,20 +2012,20 @@ function PuzzleNextWorkCard({
|
|||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
|
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-black/10 transition group-hover:bg-black/0" />
|
<div className="puzzle-runtime-piece-overlay absolute inset-0 transition group-hover:opacity-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 px-3 py-2.5">
|
<div className="min-w-0 px-3 py-2.5">
|
||||||
<div className="truncate text-sm font-black text-white">
|
<div className="truncate text-sm font-black">
|
||||||
{item.levelName}
|
{item.levelName}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 truncate text-xs font-semibold text-white/58">
|
<div className="puzzle-runtime-dialog__soft mt-1 truncate text-xs font-semibold">
|
||||||
{item.authorDisplayName}
|
{item.authorDisplayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{item.themeTags.slice(0, 2).map((tag) => (
|
{item.themeTags.slice(0, 2).map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="max-w-full truncate rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold text-white/64"
|
className="puzzle-runtime-next-card__tag max-w-full truncate rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
@@ -302,6 +309,17 @@ vi.mock('../ResolvedAssetImage', () => ({
|
|||||||
const originalMatchMedia = window.matchMedia;
|
const originalMatchMedia = window.matchMedia;
|
||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||||
|
|
||||||
|
function dispatchClientYPointerEvent(
|
||||||
|
target: HTMLElement,
|
||||||
|
type: string,
|
||||||
|
clientY: number,
|
||||||
|
) {
|
||||||
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
Object.assign(event, { clientY });
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
const puzzlePublicEntry = {
|
const puzzlePublicEntry = {
|
||||||
sourceType: 'puzzle',
|
sourceType: 'puzzle',
|
||||||
workId: 'puzzle-work-public-1',
|
workId: 'puzzle-work-public-1',
|
||||||
@@ -512,7 +530,8 @@ function renderLoggedOutHomeView(
|
|||||||
| 'activeRecommendEntryKey'
|
| 'activeRecommendEntryKey'
|
||||||
| 'isStartingRecommendEntry'
|
| 'isStartingRecommendEntry'
|
||||||
| 'recommendRuntimeError'
|
| 'recommendRuntimeError'
|
||||||
| 'onSelectRecommendEntry'
|
| 'onSelectNextRecommendEntry'
|
||||||
|
| 'onSelectPreviousRecommendEntry'
|
||||||
>
|
>
|
||||||
> = {},
|
> = {},
|
||||||
) {
|
) {
|
||||||
@@ -566,7 +585,8 @@ function renderLoggedOutHomeView(
|
|||||||
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||||
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
||||||
recommendRuntimeError={overrides.recommendRuntimeError}
|
recommendRuntimeError={overrides.recommendRuntimeError}
|
||||||
onSelectRecommendEntry={overrides.onSelectRecommendEntry}
|
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||||
|
onSelectPreviousRecommendEntry={overrides.onSelectPreviousRecommendEntry}
|
||||||
onOpenLibraryDetail={vi.fn()}
|
onOpenLibraryDetail={vi.fn()}
|
||||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||||
/>
|
/>
|
||||||
@@ -960,7 +980,7 @@ test('shows a reachable login entry in logged out mobile shell', async () => {
|
|||||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logged out bottom nav keeps creation centered with recommend icon', () => {
|
test('logged out bottom nav turns active recommend tab into next action', () => {
|
||||||
const { container } = renderLoggedOutHomeView(vi.fn());
|
const { container } = renderLoggedOutHomeView(vi.fn());
|
||||||
|
|
||||||
const nav = container.querySelector('.platform-bottom-nav');
|
const nav = container.querySelector('.platform-bottom-nav');
|
||||||
@@ -968,11 +988,11 @@ test('logged out bottom nav keeps creation centered with recommend icon', () =>
|
|||||||
const buttons = within(nav as HTMLElement).getAllByRole('button');
|
const buttons = within(nav as HTMLElement).getAllByRole('button');
|
||||||
|
|
||||||
expect(buttons.map((button) => button.textContent)).toEqual([
|
expect(buttons.map((button) => button.textContent)).toEqual([
|
||||||
'推荐',
|
'下一个',
|
||||||
'创作',
|
'创作',
|
||||||
'发现',
|
'发现',
|
||||||
]);
|
]);
|
||||||
expect(buttons[0]?.querySelector('.lucide-gamepad-2')).toBeTruthy();
|
expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy();
|
||||||
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
|
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
|
||||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -1091,16 +1111,18 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
|||||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile recommend page renders runtime viewport and bottom switcher', () => {
|
test('mobile recommend page renders runtime viewport without bottom work cards', () => {
|
||||||
const onSelectRecommendEntry = vi.fn();
|
const onOpenGalleryDetail = vi.fn();
|
||||||
|
|
||||||
renderLoggedOutHomeView(vi.fn(), {
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
latestEntries: [puzzlePublicEntry],
|
latestEntries: [puzzlePublicEntry],
|
||||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
onSelectRecommendEntry,
|
onOpenGalleryDetail,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||||
|
const runtimePanel = document.querySelector('.platform-recommend-runtime-panel');
|
||||||
|
expect(runtimePanel).toBeTruthy();
|
||||||
|
expect(runtimePanel?.className).not.toContain('bg-black');
|
||||||
expect(screen.queryByText('一张用于公开分享的拼图作品。')).toBeNull();
|
expect(screen.queryByText('一张用于公开分享的拼图作品。')).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('.platform-public-work-card__cover'),
|
document.querySelector('.platform-public-work-card__cover'),
|
||||||
@@ -1109,33 +1131,66 @@ test('mobile recommend page renders runtime viewport and bottom switcher', () =>
|
|||||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('20').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('20').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('12').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('12').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByRole('button', { name: '切换到 奇幻拼图' })).toBeNull();
|
||||||
const switchButton = screen.getByRole('button', {
|
expect(
|
||||||
name: '切换到 奇幻拼图',
|
screen.queryByRole('button', { name: '查看 奇幻拼图 详情' }),
|
||||||
});
|
).toBeNull();
|
||||||
expect(switchButton.getAttribute('aria-pressed')).toBe('true');
|
expect(
|
||||||
|
screen.queryByRole('button', { name: '打开 奇幻拼图 详情' }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(document.querySelector('.platform-recommend-switcher')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByLabelText('奇幻拼图 作品信息'));
|
||||||
|
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile recommend switcher selects a different public work', async () => {
|
test('mobile recommend loading state is themed instead of hardcoded black', () => {
|
||||||
const user = userEvent.setup();
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
const onSelectRecommendEntry = vi.fn();
|
latestEntries: [puzzlePublicEntry],
|
||||||
const secondEntry = {
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
...puzzlePublicEntry,
|
isStartingRecommendEntry: true,
|
||||||
workId: 'puzzle-work-second',
|
recommendRuntimeContent: null,
|
||||||
profileId: 'puzzle-profile-second',
|
});
|
||||||
publicWorkCode: 'PZ-SECOND',
|
|
||||||
worldName: '第二拼图',
|
const loadingState = screen.getByText('加载中...');
|
||||||
} satisfies PlatformPublicGalleryCard;
|
expect(loadingState.className).toContain('platform-recommend-runtime-state');
|
||||||
|
expect(loadingState.className).not.toContain('bg-black');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile recommend meta swipes between public works', () => {
|
||||||
|
const onSelectNextRecommendEntry = vi.fn();
|
||||||
|
const onSelectPreviousRecommendEntry = vi.fn();
|
||||||
|
|
||||||
renderLoggedOutHomeView(vi.fn(), {
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
latestEntries: [puzzlePublicEntry, secondEntry],
|
latestEntries: [puzzlePublicEntry],
|
||||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
onSelectRecommendEntry,
|
onSelectNextRecommendEntry,
|
||||||
|
onSelectPreviousRecommendEntry,
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '切换到 第二拼图' }));
|
const meta = screen.getByLabelText('奇幻拼图 作品信息');
|
||||||
|
dispatchClientYPointerEvent(meta, 'pointerdown', 240);
|
||||||
|
dispatchClientYPointerEvent(meta, 'pointerup', 180);
|
||||||
|
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(onSelectRecommendEntry).toHaveBeenCalledWith(secondEntry);
|
dispatchClientYPointerEvent(meta, 'pointerdown', 180);
|
||||||
|
dispatchClientYPointerEvent(meta, 'pointerup', 240);
|
||||||
|
expect(onSelectPreviousRecommendEntry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active recommend bottom tab selects next work instead of navigating', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelectNextRecommendEntry = vi.fn();
|
||||||
|
|
||||||
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
|
latestEntries: [puzzlePublicEntry],
|
||||||
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
|
onSelectNextRecommendEntry,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '下一个' }));
|
||||||
|
|
||||||
|
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile recommend meta loads real author avatar from public user summary', async () => {
|
test('mobile recommend meta loads real author avatar from public user summary', async () => {
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ export interface RpgEntryHomeViewProps {
|
|||||||
activeRecommendEntryKey?: string | null;
|
activeRecommendEntryKey?: string | null;
|
||||||
isStartingRecommendEntry?: boolean;
|
isStartingRecommendEntry?: boolean;
|
||||||
recommendRuntimeError?: string | null;
|
recommendRuntimeError?: string | null;
|
||||||
onSelectRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
onSelectNextRecommendEntry?: () => void;
|
||||||
|
onSelectPreviousRecommendEntry?: () => void;
|
||||||
onOpenLibraryDetail: (
|
onOpenLibraryDetail: (
|
||||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -149,6 +150,8 @@ const HERO_SURFACE_CLASS =
|
|||||||
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
|
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
|
||||||
const MOBILE_PAGE_STAGE_CLASS =
|
const MOBILE_PAGE_STAGE_CLASS =
|
||||||
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
|
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
|
||||||
|
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
|
||||||
|
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
|
||||||
const DESKTOP_PAGE_STAGE_CLASS =
|
const DESKTOP_PAGE_STAGE_CLASS =
|
||||||
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
|
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
|
||||||
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
||||||
@@ -165,6 +168,7 @@ const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
|||||||
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
|
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
|
||||||
const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
|
const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
|
||||||
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
||||||
|
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||||
|
|
||||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||||
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
|
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
|
||||||
@@ -664,12 +668,15 @@ function CreationLibraryCard({
|
|||||||
function RecommendRuntimeMeta({
|
function RecommendRuntimeMeta({
|
||||||
entry,
|
entry,
|
||||||
authorAvatarUrl,
|
authorAvatarUrl,
|
||||||
onOpenDetail,
|
onSelectNext,
|
||||||
|
onSelectPrevious,
|
||||||
}: {
|
}: {
|
||||||
entry: PlatformPublicGalleryCard;
|
entry: PlatformPublicGalleryCard;
|
||||||
authorAvatarUrl?: string | null;
|
authorAvatarUrl?: string | null;
|
||||||
onOpenDetail: () => void;
|
onSelectNext?: () => void;
|
||||||
|
onSelectPrevious?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const swipeStartYRef = useRef<number | null>(null);
|
||||||
const playCount = getPlatformWorldPlayCount(entry);
|
const playCount = getPlatformWorldPlayCount(entry);
|
||||||
const remixCount = getPlatformWorldRemixCount(entry);
|
const remixCount = getPlatformWorldRemixCount(entry);
|
||||||
const likeCount = getPlatformWorldLikeCount(entry);
|
const likeCount = getPlatformWorldLikeCount(entry);
|
||||||
@@ -682,11 +689,37 @@ function RecommendRuntimeMeta({
|
|||||||
{ label: '点赞', value: likeCount, icon: Heart },
|
{ label: '点赞', value: likeCount, icon: Heart },
|
||||||
{ label: '改造', value: remixCount, icon: MessageCircle },
|
{ label: '改造', value: remixCount, icon: MessageCircle },
|
||||||
];
|
];
|
||||||
|
const handlePointerEnd = (clientY: number) => {
|
||||||
|
const startY = swipeStartYRef.current;
|
||||||
|
swipeStartYRef.current = null;
|
||||||
|
if (startY === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaY = clientY - startY;
|
||||||
|
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaY < 0) {
|
||||||
|
onSelectNext?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectPrevious?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="platform-recommend-work-meta"
|
className="platform-recommend-work-meta"
|
||||||
aria-label={`${entry.worldName} 作品信息`}
|
aria-label={`${entry.worldName} 作品信息`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
swipeStartYRef.current = event.clientY;
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => handlePointerEnd(event.clientY)}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
swipeStartYRef.current = null;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="platform-recommend-work-meta__stats">
|
<div className="platform-recommend-work-meta__stats">
|
||||||
{statItems.map(({ label, value, icon: Icon }) => (
|
{statItems.map(({ label, value, icon: Icon }) => (
|
||||||
@@ -702,11 +735,8 @@ function RecommendRuntimeMeta({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="platform-recommend-work-meta__row">
|
<div className="platform-recommend-work-meta__row">
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
onClick={onOpenDetail}
|
|
||||||
className="platform-recommend-work-meta__identity"
|
className="platform-recommend-work-meta__identity"
|
||||||
aria-label={`打开 ${entry.worldName} 详情`}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="platform-recommend-work-meta__avatar"
|
className="platform-recommend-work-meta__avatar"
|
||||||
@@ -730,62 +760,12 @@ function RecommendRuntimeMeta({
|
|||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenDetail}
|
|
||||||
className="platform-recommend-work-meta__detail-button"
|
|
||||||
aria-label={`查看 ${entry.worldName} 详情`}
|
|
||||||
title="详情"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecommendWorkSwitchItem({
|
|
||||||
entry,
|
|
||||||
active,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
entry: PlatformPublicGalleryCard;
|
|
||||||
active: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
|
||||||
const typeLabel = describePublicGalleryCardKind(entry);
|
|
||||||
const playCount = getPlatformWorldPlayCount(entry);
|
|
||||||
const likeCount = getPlatformWorldLikeCount(entry);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
aria-label={`切换到 ${entry.worldName}`}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={`platform-recommend-switch-card ${active ? 'platform-recommend-switch-card--active' : ''}`}
|
|
||||||
>
|
|
||||||
<span className="platform-recommend-switch-card__kind">{typeLabel}</span>
|
|
||||||
<span className="platform-recommend-switch-card__title">
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
<span className="platform-recommend-switch-card__stats">
|
|
||||||
<span>
|
|
||||||
<Gamepad2 className="h-3 w-3" aria-hidden="true" />
|
|
||||||
{formatCompactCount(playCount)}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<Heart className="h-3 w-3" aria-hidden="true" />
|
|
||||||
{formatCompactCount(likeCount)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SaveArchiveCard({
|
function SaveArchiveCard({
|
||||||
entry,
|
entry,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -2861,7 +2841,8 @@ export function RpgEntryHomeView({
|
|||||||
activeRecommendEntryKey = null,
|
activeRecommendEntryKey = null,
|
||||||
isStartingRecommendEntry = false,
|
isStartingRecommendEntry = false,
|
||||||
recommendRuntimeError = null,
|
recommendRuntimeError = null,
|
||||||
onSelectRecommendEntry,
|
onSelectNextRecommendEntry,
|
||||||
|
onSelectPreviousRecommendEntry,
|
||||||
onOpenLibraryDetail,
|
onOpenLibraryDetail,
|
||||||
onDeleteLibraryEntry,
|
onDeleteLibraryEntry,
|
||||||
deletingLibraryEntryId = null,
|
deletingLibraryEntryId = null,
|
||||||
@@ -3722,6 +3703,12 @@ export function RpgEntryHomeView({
|
|||||||
) ??
|
) ??
|
||||||
recommendedFeedEntries[0] ??
|
recommendedFeedEntries[0] ??
|
||||||
null;
|
null;
|
||||||
|
const selectNextRecommendEntry = useCallback(() => {
|
||||||
|
onSelectNextRecommendEntry?.();
|
||||||
|
}, [onSelectNextRecommendEntry]);
|
||||||
|
const selectPreviousRecommendEntry = useCallback(() => {
|
||||||
|
onSelectPreviousRecommendEntry?.();
|
||||||
|
}, [onSelectPreviousRecommendEntry]);
|
||||||
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
||||||
const openLeadPublicEntry = () => {
|
const openLeadPublicEntry = () => {
|
||||||
if (leadPublicEntry) {
|
if (leadPublicEntry) {
|
||||||
@@ -3785,7 +3772,7 @@ export function RpgEntryHomeView({
|
|||||||
|
|
||||||
const mobileRecommendContent: ReactNode = (
|
const mobileRecommendContent: ReactNode = (
|
||||||
<div
|
<div
|
||||||
className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
|
className={`${MOBILE_RECOMMEND_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
|
||||||
>
|
>
|
||||||
{platformError ? (
|
{platformError ? (
|
||||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||||
@@ -3823,42 +3810,9 @@ export function RpgEntryHomeView({
|
|||||||
<RecommendRuntimeMeta
|
<RecommendRuntimeMeta
|
||||||
entry={activeRecommendEntry}
|
entry={activeRecommendEntry}
|
||||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
||||||
onOpenDetail={() => onOpenGalleryDetail(activeRecommendEntry)}
|
onSelectNext={selectNextRecommendEntry}
|
||||||
|
onSelectPrevious={selectPreviousRecommendEntry}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{recommendedFeedEntries.length > 0 ? (
|
|
||||||
<section
|
|
||||||
className="platform-recommend-switcher"
|
|
||||||
aria-label="推荐作品"
|
|
||||||
>
|
|
||||||
{recommendedFeedEntries.map((entry) => {
|
|
||||||
const cardKey = buildPublicGalleryCardKey(entry);
|
|
||||||
const active =
|
|
||||||
activeRecommendEntryKey === cardKey ||
|
|
||||||
Boolean(
|
|
||||||
!activeRecommendEntryKey &&
|
|
||||||
activeRecommendEntry &&
|
|
||||||
buildPublicGalleryCardKey(activeRecommendEntry) === cardKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RecommendWorkSwitchItem
|
|
||||||
key={`${cardKey}:recommend-switch`}
|
|
||||||
entry={entry}
|
|
||||||
active={active}
|
|
||||||
onSelect={() => {
|
|
||||||
if (onSelectRecommendEntry) {
|
|
||||||
onSelectRecommendEntry(entry);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenGalleryDetail(entry);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
) : !isLoadingPlatform ? (
|
) : !isLoadingPlatform ? (
|
||||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -4706,10 +4660,25 @@ export function RpgEntryHomeView({
|
|||||||
<PlatformTabButton
|
<PlatformTabButton
|
||||||
key={tab}
|
key={tab}
|
||||||
active={activeTab === tab}
|
active={activeTab === tab}
|
||||||
label={tabLabels[tab]}
|
label={
|
||||||
icon={tabIcons[tab]}
|
activeTab === 'home' && tab === 'home'
|
||||||
|
? '下一个'
|
||||||
|
: tabLabels[tab]
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
activeTab === 'home' && tab === 'home'
|
||||||
|
? ChevronDown
|
||||||
|
: tabIcons[tab]
|
||||||
|
}
|
||||||
emphasized={tab === 'create'}
|
emphasized={tab === 'create'}
|
||||||
onClick={() => onTabChange(tab)}
|
onClick={() => {
|
||||||
|
if (activeTab === 'home' && tab === 'home') {
|
||||||
|
selectNextRecommendEntry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTabChange(tab);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
424
src/index.css
424
src/index.css
@@ -505,6 +505,59 @@ body {
|
|||||||
),
|
),
|
||||||
radial-gradient(circle at right, rgba(255, 205, 178, 0.14), transparent 28%),
|
radial-gradient(circle at right, rgba(255, 205, 178, 0.14), transparent 28%),
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 241, 246, 0.9));
|
linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 241, 246, 0.9));
|
||||||
|
--platform-recommend-runtime-fill: var(--platform-panel-fill);
|
||||||
|
--platform-recommend-runtime-border: rgba(232, 191, 205, 0.42);
|
||||||
|
--platform-recommend-runtime-shadow: 0 18px 44px rgba(215, 87, 134, 0.13),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.58);
|
||||||
|
--platform-recommend-runtime-state-fill: radial-gradient(
|
||||||
|
circle at 50% 18%,
|
||||||
|
rgba(255, 91, 132, 0.12),
|
||||||
|
transparent 34%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 246, 249, 0.94));
|
||||||
|
--platform-recommend-runtime-state-text: var(--platform-text-strong);
|
||||||
|
--puzzle-runtime-shell-fill: var(--platform-body-fill);
|
||||||
|
--puzzle-runtime-stage-fill: radial-gradient(
|
||||||
|
circle at 50% 18%,
|
||||||
|
rgba(255, 91, 132, 0.13),
|
||||||
|
transparent 30%
|
||||||
|
),
|
||||||
|
radial-gradient(circle at 18% 82%, rgba(255, 138, 115, 0.13), transparent 28%),
|
||||||
|
linear-gradient(180deg, #fffefe 0%, #fff7fa 58%, #fff1f5 100%);
|
||||||
|
--puzzle-runtime-grid-line: rgba(130, 75, 95, 0.06);
|
||||||
|
--puzzle-runtime-text-strong: var(--platform-text-strong);
|
||||||
|
--puzzle-runtime-text-base: var(--platform-text-base);
|
||||||
|
--puzzle-runtime-text-soft: var(--platform-text-soft);
|
||||||
|
--puzzle-runtime-surface-fill: rgba(255, 255, 255, 0.76);
|
||||||
|
--puzzle-runtime-surface-fill-strong: rgba(255, 255, 255, 0.9);
|
||||||
|
--puzzle-runtime-surface-border: rgba(232, 191, 205, 0.48);
|
||||||
|
--puzzle-runtime-board-fill: rgba(255, 255, 255, 0.68);
|
||||||
|
--puzzle-runtime-board-border: rgba(255, 126, 154, 0.28);
|
||||||
|
--puzzle-runtime-board-shadow: 0 30px 80px rgba(215, 87, 134, 0.14);
|
||||||
|
--puzzle-runtime-piece-fill: rgba(255, 255, 255, 0.74);
|
||||||
|
--puzzle-runtime-piece-border: rgba(232, 191, 205, 0.54);
|
||||||
|
--puzzle-runtime-piece-empty-fill: rgba(255, 228, 236, 0.34);
|
||||||
|
--puzzle-runtime-piece-empty-text: rgba(92, 70, 80, 0.38);
|
||||||
|
--puzzle-runtime-piece-selected-fill: linear-gradient(135deg, #ff4f8b, #ff8a73);
|
||||||
|
--puzzle-runtime-piece-selected-text: #fff7fb;
|
||||||
|
--puzzle-runtime-piece-selected-border: rgba(255, 79, 139, 0.68);
|
||||||
|
--puzzle-runtime-piece-overlay: rgba(61, 24, 38, 0.06);
|
||||||
|
--puzzle-runtime-control-fill: rgba(255, 255, 255, 0.72);
|
||||||
|
--puzzle-runtime-control-hover-fill: rgba(255, 91, 132, 0.1);
|
||||||
|
--puzzle-runtime-primary-fill: var(--platform-button-primary-fill);
|
||||||
|
--puzzle-runtime-primary-text: var(--platform-button-primary-text);
|
||||||
|
--puzzle-runtime-primary-shadow: var(--platform-profile-action-shadow);
|
||||||
|
--puzzle-runtime-accent-text: var(--platform-cool-text);
|
||||||
|
--puzzle-runtime-cool-text: #0f8fa9;
|
||||||
|
--puzzle-runtime-danger-fill: rgba(255, 228, 233, 0.9);
|
||||||
|
--puzzle-runtime-danger-text: #c2415d;
|
||||||
|
--puzzle-runtime-backdrop-fill: rgba(43, 20, 32, 0.34);
|
||||||
|
--puzzle-runtime-dialog-fill: var(--platform-modal-fill);
|
||||||
|
--puzzle-runtime-dialog-border: var(--platform-modal-border);
|
||||||
|
--puzzle-runtime-table-fill: rgba(255, 255, 255, 0.62);
|
||||||
|
--puzzle-runtime-table-row-fill: rgba(255, 91, 132, 0.12);
|
||||||
|
--puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.66);
|
||||||
|
--puzzle-runtime-next-card-hover-fill: rgba(255, 91, 132, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-theme--dark {
|
.platform-theme--dark {
|
||||||
@@ -684,6 +737,54 @@ body {
|
|||||||
),
|
),
|
||||||
radial-gradient(circle at right, rgba(255, 205, 178, 0.14), transparent 28%),
|
radial-gradient(circle at right, rgba(255, 205, 178, 0.14), transparent 28%),
|
||||||
linear-gradient(180deg, rgba(8, 10, 14, 0.22), rgba(8, 10, 14, 0.9));
|
linear-gradient(180deg, rgba(8, 10, 14, 0.22), rgba(8, 10, 14, 0.9));
|
||||||
|
--platform-recommend-runtime-fill: #030303;
|
||||||
|
--platform-recommend-runtime-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--platform-recommend-runtime-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025),
|
||||||
|
0 18px 44px rgba(0, 0, 0, 0.18);
|
||||||
|
--platform-recommend-runtime-state-fill: #030303;
|
||||||
|
--platform-recommend-runtime-state-text: rgba(255, 255, 255, 0.92);
|
||||||
|
--puzzle-runtime-shell-fill: #020617;
|
||||||
|
--puzzle-runtime-stage-fill: radial-gradient(
|
||||||
|
circle at 50% 20%,
|
||||||
|
rgba(251, 191, 36, 0.18),
|
||||||
|
transparent 28%
|
||||||
|
),
|
||||||
|
radial-gradient(circle at 20% 80%, rgba(249, 115, 22, 0.16), transparent 26%),
|
||||||
|
linear-gradient(180deg, #2d160e, #020617);
|
||||||
|
--puzzle-runtime-grid-line: rgba(255, 255, 255, 0.04);
|
||||||
|
--puzzle-runtime-text-strong: #ffffff;
|
||||||
|
--puzzle-runtime-text-base: rgba(255, 255, 255, 0.82);
|
||||||
|
--puzzle-runtime-text-soft: rgba(255, 255, 255, 0.58);
|
||||||
|
--puzzle-runtime-surface-fill: rgba(0, 0, 0, 0.34);
|
||||||
|
--puzzle-runtime-surface-fill-strong: rgba(0, 0, 0, 0.5);
|
||||||
|
--puzzle-runtime-surface-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--puzzle-runtime-board-fill: rgba(255, 255, 255, 0.08);
|
||||||
|
--puzzle-runtime-board-border: rgba(255, 255, 255, 0.16);
|
||||||
|
--puzzle-runtime-board-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
|
||||||
|
--puzzle-runtime-piece-fill: rgba(255, 255, 255, 0.12);
|
||||||
|
--puzzle-runtime-piece-border: rgba(255, 255, 255, 0.22);
|
||||||
|
--puzzle-runtime-piece-empty-fill: rgba(0, 0, 0, 0.18);
|
||||||
|
--puzzle-runtime-piece-empty-text: rgba(255, 255, 255, 0.2);
|
||||||
|
--puzzle-runtime-piece-selected-fill: rgba(251, 191, 36, 0.84);
|
||||||
|
--puzzle-runtime-piece-selected-text: #020617;
|
||||||
|
--puzzle-runtime-piece-selected-border: rgba(253, 230, 138, 1);
|
||||||
|
--puzzle-runtime-piece-overlay: rgba(0, 0, 0, 0.1);
|
||||||
|
--puzzle-runtime-control-fill: rgba(0, 0, 0, 0.36);
|
||||||
|
--puzzle-runtime-control-hover-fill: rgba(255, 255, 255, 0.1);
|
||||||
|
--puzzle-runtime-primary-fill: #fde68a;
|
||||||
|
--puzzle-runtime-primary-text: #020617;
|
||||||
|
--puzzle-runtime-primary-shadow: 0 14px 36px rgba(251, 191, 36, 0.26);
|
||||||
|
--puzzle-runtime-accent-text: #fde68a;
|
||||||
|
--puzzle-runtime-cool-text: #cffafe;
|
||||||
|
--puzzle-runtime-danger-fill: rgba(239, 68, 68, 0.2);
|
||||||
|
--puzzle-runtime-danger-text: #fee2e2;
|
||||||
|
--puzzle-runtime-backdrop-fill: rgba(2, 6, 23, 0.68);
|
||||||
|
--puzzle-runtime-dialog-fill: rgba(2, 6, 23, 0.94);
|
||||||
|
--puzzle-runtime-dialog-border: rgba(255, 255, 255, 0.14);
|
||||||
|
--puzzle-runtime-table-fill: rgba(255, 255, 255, 0.06);
|
||||||
|
--puzzle-runtime-table-row-fill: rgba(251, 191, 36, 0.14);
|
||||||
|
--puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.06);
|
||||||
|
--puzzle-runtime-next-card-hover-fill: rgba(251, 191, 36, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-brand-logo {
|
.platform-brand-logo {
|
||||||
@@ -1907,6 +2008,205 @@ body {
|
|||||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-shell {
|
||||||
|
background: var(--puzzle-runtime-shell-fill);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-stage {
|
||||||
|
background: var(--puzzle-runtime-stage-fill);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-stage__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--puzzle-runtime-grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--puzzle-runtime-grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 34px 34px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-pill,
|
||||||
|
.puzzle-runtime-icon-button,
|
||||||
|
.puzzle-runtime-header-card,
|
||||||
|
.puzzle-runtime-toolbar {
|
||||||
|
border: 1px solid var(--puzzle-runtime-surface-border);
|
||||||
|
background: var(--puzzle-runtime-surface-fill);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-icon-button {
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-header-card {
|
||||||
|
background: var(--puzzle-runtime-surface-fill-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-level-badge {
|
||||||
|
background: var(--puzzle-runtime-control-fill);
|
||||||
|
color: var(--puzzle-runtime-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-timer {
|
||||||
|
background: var(--puzzle-runtime-control-fill);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-timer--urgent {
|
||||||
|
background: var(--puzzle-runtime-danger-fill);
|
||||||
|
color: var(--puzzle-runtime-danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-board {
|
||||||
|
border-color: var(--puzzle-runtime-board-border);
|
||||||
|
background: var(--puzzle-runtime-board-fill);
|
||||||
|
box-shadow: var(--puzzle-runtime-board-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-piece {
|
||||||
|
border-color: var(--puzzle-runtime-piece-border);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-piece--selected {
|
||||||
|
border-color: var(--puzzle-runtime-piece-selected-border);
|
||||||
|
background: var(--puzzle-runtime-piece-selected-fill);
|
||||||
|
color: var(--puzzle-runtime-piece-selected-text);
|
||||||
|
box-shadow: var(--puzzle-runtime-primary-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-piece--merged {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-piece--filled {
|
||||||
|
background: var(--puzzle-runtime-piece-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-piece--empty {
|
||||||
|
border-color: var(--puzzle-runtime-surface-border);
|
||||||
|
background: var(--puzzle-runtime-piece-empty-fill);
|
||||||
|
color: var(--puzzle-runtime-piece-empty-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-piece-overlay {
|
||||||
|
background: var(--puzzle-runtime-piece-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-primary-button {
|
||||||
|
border: 1px solid var(--platform-button-primary-border);
|
||||||
|
background: var(--puzzle-runtime-primary-fill);
|
||||||
|
color: var(--puzzle-runtime-primary-text);
|
||||||
|
box-shadow: var(--puzzle-runtime-primary-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-tool-button {
|
||||||
|
color: var(--puzzle-runtime-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-tool-button:hover {
|
||||||
|
background: var(--puzzle-runtime-control-hover-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-tool-button--active {
|
||||||
|
background: var(--platform-button-primary-fill);
|
||||||
|
color: var(--platform-button-primary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-tool-button__warm {
|
||||||
|
color: var(--puzzle-runtime-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-tool-button__cool {
|
||||||
|
color: var(--puzzle-runtime-cool-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-status-chip {
|
||||||
|
background: var(--puzzle-runtime-control-fill);
|
||||||
|
color: var(--puzzle-runtime-text-soft);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-error-chip {
|
||||||
|
background: var(--puzzle-runtime-danger-fill);
|
||||||
|
color: var(--puzzle-runtime-danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-modal-overlay {
|
||||||
|
background: var(--puzzle-runtime-backdrop-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-dialog {
|
||||||
|
border: 1px solid var(--puzzle-runtime-dialog-border);
|
||||||
|
background: var(--puzzle-runtime-dialog-fill);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-dialog__line {
|
||||||
|
border-color: var(--puzzle-runtime-surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-dialog__soft {
|
||||||
|
color: var(--puzzle-runtime-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-dialog__body {
|
||||||
|
color: var(--puzzle-runtime-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-secondary-button {
|
||||||
|
border: 1px solid var(--puzzle-runtime-surface-border);
|
||||||
|
background: var(--puzzle-runtime-control-fill);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-stat-card,
|
||||||
|
.puzzle-runtime-settings-card {
|
||||||
|
border: 1px solid var(--puzzle-runtime-surface-border);
|
||||||
|
background: var(--puzzle-runtime-table-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-leaderboard-head {
|
||||||
|
background: var(--puzzle-runtime-table-fill);
|
||||||
|
color: var(--puzzle-runtime-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-leaderboard-row {
|
||||||
|
border-color: var(--puzzle-runtime-surface-border);
|
||||||
|
color: var(--puzzle-runtime-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-leaderboard-row--active {
|
||||||
|
background: var(--puzzle-runtime-table-row-fill);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-next-card {
|
||||||
|
border: 1px solid var(--puzzle-runtime-surface-border);
|
||||||
|
background: var(--puzzle-runtime-next-card-fill);
|
||||||
|
color: var(--puzzle-runtime-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-next-card:hover {
|
||||||
|
border-color: var(--platform-button-primary-border);
|
||||||
|
background: var(--puzzle-runtime-next-card-hover-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-next-card__media {
|
||||||
|
background: var(--puzzle-runtime-control-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-runtime-next-card__tag {
|
||||||
|
background: var(--puzzle-runtime-control-fill);
|
||||||
|
color: var(--puzzle-runtime-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
:root {
|
:root {
|
||||||
--platform-bottom-nav-height: 3.85rem;
|
--platform-bottom-nav-height: 3.85rem;
|
||||||
@@ -2095,7 +2395,7 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.55rem;
|
gap: 0.28rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2109,12 +2409,10 @@ body {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--platform-recommend-runtime-border);
|
||||||
border-radius: 1.65rem;
|
border-radius: 1.65rem;
|
||||||
background: #030303;
|
background: var(--platform-recommend-runtime-fill);
|
||||||
box-shadow:
|
box-shadow: var(--platform-recommend-runtime-shadow);
|
||||||
inset 0 0 0 1px rgba(255, 255, 255, 0.025),
|
|
||||||
0 18px 44px rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-recommend-runtime-viewport {
|
.platform-recommend-runtime-viewport {
|
||||||
@@ -2122,7 +2420,7 @@ body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #030303;
|
background: var(--platform-recommend-runtime-fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-recommend-runtime-state {
|
.platform-recommend-runtime-state {
|
||||||
@@ -2132,8 +2430,8 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: #030303;
|
background: var(--platform-recommend-runtime-state-fill);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: var(--platform-recommend-runtime-state-text);
|
||||||
font-size: clamp(1.8rem, 10vw, 2.45rem);
|
font-size: clamp(1.8rem, 10vw, 2.45rem);
|
||||||
font-weight: 950;
|
font-weight: 950;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
@@ -2148,6 +2446,8 @@ body {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--platform-text-strong);
|
color: var(--platform-text-strong);
|
||||||
|
touch-action: pan-y;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-recommend-work-meta__stats {
|
.platform-recommend-work-meta__stats {
|
||||||
@@ -2170,7 +2470,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.platform-recommend-work-meta__row {
|
.platform-recommend-work-meta__row {
|
||||||
margin-top: 0.55rem;
|
margin-top: 0.36rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2184,9 +2484,6 @@ body {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -2231,107 +2528,6 @@ body {
|
|||||||
color: var(--platform-text-soft);
|
color: var(--platform-text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-recommend-work-meta__detail-button {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 2.35rem;
|
|
||||||
height: 2.35rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid var(--platform-surface-border);
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: var(--platform-button-secondary-fill);
|
|
||||||
color: var(--platform-text-strong);
|
|
||||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-recommend-switcher {
|
|
||||||
display: flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.55rem;
|
|
||||||
margin-right: -0.25rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
overscroll-behavior-x: contain;
|
|
||||||
padding: 0.05rem 0.25rem 0.2rem 0;
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
scrollbar-width: none;
|
|
||||||
touch-action: pan-x;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-recommend-switcher::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-recommend-switch-card {
|
|
||||||
display: grid;
|
|
||||||
width: min(9.1rem, 42vw);
|
|
||||||
min-height: 4.25rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
scroll-snap-align: start;
|
|
||||||
gap: 0.18rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--platform-surface-border) 72%, transparent);
|
|
||||||
border-radius: 1.05rem;
|
|
||||||
background: color-mix(in srgb, var(--platform-panel-fill) 90%, #050505);
|
|
||||||
padding: 0.58rem 0.65rem;
|
|
||||||
color: var(--platform-text-strong);
|
|
||||||
text-align: left;
|
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-recommend-switch-card--active {
|
|
||||||
border-color: color-mix(
|
|
||||||
in srgb,
|
|
||||||
var(--platform-warm-text) 72%,
|
|
||||||
var(--platform-surface-border)
|
|
||||||
);
|
|
||||||
background: color-mix(
|
|
||||||
in srgb,
|
|
||||||
var(--platform-button-secondary-fill) 84%,
|
|
||||||
#151515
|
|
||||||
);
|
|
||||||
box-shadow:
|
|
||||||
0 14px 34px rgba(0, 0, 0, 0.16),
|
|
||||||
inset 0 0 0 1px color-mix(in srgb, var(--platform-warm-text) 42%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-recommend-switch-card__kind {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 850;
|
|
||||||
color: var(--platform-text-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-recommend-switch-card__title {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 950;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-recommend-switch-card__stats {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.55rem;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 850;
|
|
||||||
color: var(--platform-text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-recommend-switch-card__stats span {
|
|
||||||
display: inline-flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.18rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-mobile-home-stage .platform-desktop-search {
|
.platform-mobile-home-stage .platform-desktop-search {
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
padding: 0.64rem 0.9rem;
|
padding: 0.64rem 0.9rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user