From b5069fd3e35dd9602b94bf253d6b0ba24be4de05 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 1 Oct 2026 08:40:58 +0000 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=B9=BF=E5=9C=BA=E5=B1=95=E7=A4=BA=E7=9C=9F=E5=AE=9E=E8=AF=84?= =?UTF-8?q?=E5=88=86=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公开游戏列表与详情在同一读取快照中返回有效评价均分和人数 同步共享契约、公开投影映射、生成绑定与 no-store 响应 广场与详情复用评分文本组件,区分无人评分与摘要不可用 扩展必要展示和响应契约测试,保留原有目录交互 更新规范、实施计划与本地定向测试及联调验证证据 --- docs/README.md | 2 +- ...【实施计划】游戏广场评分展示-2026-10-01.md | 15 +++++-- .../【里程碑】游戏广场评分展示-2026-10-01.md | 16 ++++++-- .../shared-memory/decision-log.md | 2 +- ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 14 ++++--- .../src/components/GameRatingSummaryText.tsx | 17 ++++++++ packages/shared/src/components/index.ts | 1 + .../shared/src/contracts/gameDistribution.ts | 2 + .../check-game-distribution-dto-parity.mjs | 4 +- .../src/modules/game_distribution.rs | 40 ++++++++++++++++--- .../shared-contracts/src/game_distribution.rs | 7 +++- .../src/active/mapper/game_distribution.rs | 13 +++++- ..._distribution_public_game_snapshot_type.rs | 2 + .../spacetime-module/src/game_distribution.rs | 25 +++++++----- .../GameDistributionPages.test.tsx | 12 ++++++ .../game-distribution/GameGalleryPage.tsx | 4 ++ .../game-distribution/GameReviews.tsx | 10 ++--- .../game-distribution/gameDistribution.css | 8 ++++ 18 files changed, 152 insertions(+), 42 deletions(-) create mode 100644 packages/shared/src/components/GameRatingSummaryText.tsx diff --git a/docs/README.md b/docs/README.md index 5bc55d8f8..d038b7fba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ - [网站游戏评分与评价里程碑](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md):唯一评价、编辑预填、4000 字符、公共分页与平均分/人数的验收边界与本地证据。 - [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。 - [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。 -- [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):方案已确认,待实现;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。 +- [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 diff --git a/docs/project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md b/docs/project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md index 24e40c20d..439f0bc02 100644 --- a/docs/project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md +++ b/docs/project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Milestone | [游戏广场评分展示](./【里程碑】游戏广场评分展示-2026-10-01.md) | -| Status | ready(用户确认方案,待开始工程实现;本次仅提交文档) | +| Status | implemented(本地验证通过,待用户验收;未部署) | | Date | 2026-10-01 | | Owner | Codex | @@ -52,6 +52,15 @@ - 缺字段只表示不可用,不能默认成 `null/0`;详情评价状态继续由现有读取/保存驱动,避免公开投影摘要覆盖提交后的最新值。 - 回滚网站展示可保留新增公开字段;回滚 module/后端必须恢复互相匹配的绑定,并继续过滤隐藏评价。不得退回不识别管理隐藏状态的基础评价旧实现。 -## 未验证 +## 执行结果 -本次仅落地方案文档,工程实现、行为测试、绑定生成、真实数据库/API、浏览器和部署均待执行。文档检查结果只证明文档完整性,不证明功能已实现。 +- 已完成上述代码与绑定接入;公开列表先过滤有效版本、排序并限量,只为返回游戏聚合评分。持久化表与评价写入规则未变。 +- 前端两文件 36 项、API game_distribution 34 项、shared-contracts 4 项、领域 summary 2 项通过;module/client 编译、typecheck、受影响代码静态/格式检查通过。 +- `spacetime:generate` 使用固定 2.8.3 完成;保留新增快照字段,清除无关生成格式 diff。生成绑定、schema(87 表)、DTO parity(31 类型/4 构建器)、编码、文档索引和 diff 检查通过。 +- `dev:api-server` 启动后 `/healthz` 通过;本机显式隔离库没有既有评分 fixture,复用现有 helper 创建两个账号、两款游戏、两条评价。游客/登录公开列表摘要一致,列表/详情/评价摘要 `8/2`,隐藏一条后 `7/1`,另一游戏始终 `null/0`;列表/详情 no-store 通过。临时联调代码与数据仅留在 ignored 本地产物,未新增专用仓库 E2E 脚本。 +- 1280px/375px 浏览器评分行无横向溢出,卡片可进入详情;详情期间通过 API 将可见评分从 7 改为 6,返回广场显示 `6.0/10 · 1 人评分`。 +- 前端实现与后端只读审查由两个 `gpt-6.1-sol` subagent 协助,后端审查未发现可执行问题。 + +## 未验证与待验收 + +用户最终验收与生产部署待完成,保留本计划。未跑完整仓库测试、完整评价/后台管理 E2E、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分和实际发行包游玩。返回刷新通过 API 改分验证,登录公开读取通过 HTTP smoke 验证;未增加复杂测试矩阵。 diff --git a/docs/project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md b/docs/project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md index 79fe80f3f..df20a6028 100644 --- a/docs/project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md +++ b/docs/project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | accepted(用户确认方案,待实现;未部署) | +| Status | implemented(本地验证通过,待用户验收;未部署) | | Date | 2026-10-01 | | Parent Spec | [游戏广场评分展示合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同) Version 1.0 | @@ -28,7 +28,7 @@ ## 依赖与前置条件 -- 用户已确认方案,本次授权先编写并提交文档;本里程碑尚未开始工程实现。 +- 用户已确认方案、提交文档,并后续授权工程实现;本里程碑代码与本地定向验证已完成。 - 当前基础评价、后台管理与有效评价统计已实现并通过本地验证,其用户验收和生产发布状态独立。 - 现有公开游戏目录、详情、平台认证、数据库服务身份与后端访问链路可用。 - 可复用现有隔离测试游戏和少量评价,完成统计对照及桌面/移动视口检查;不要求新建大批量账号或分页数据。 @@ -53,6 +53,14 @@ - 运行时:现有两款游戏和少量评价的一次列表/详情/评价摘要对照,确认隐藏过滤与统计不串联;桌面/移动检查评分布局及详情返回后的更新。 - 不新增测试框架、专用 E2E 脚本或大批量分页 fixture;纯文本共享组件由页面测试覆盖。只有新增行为或实际改动的旧行为缺少覆盖时补测试。 -## 未验证 +## 实现与验证证据 -当前仅确认并记录方案;工程实现、行为测试、数据库/API、浏览器及生产部署尚未执行。既有评价与后台管理证据不能作为本里程碑已通过的证据。 +- 公开列表/详情快照同事务携带摘要,限量后聚合;共享 DTO、mapper、2.8.3 生成绑定与公开 no-store 已接入。网站卡片与详情复用纯文本组件,缺字段不会造零值。 +- 前端受影响两文件 36 项、API game_distribution 34 项、shared-contracts 4 项、领域 summary 2 项通过。仅补评分文案参数化断言并扩展已有响应测试,未新增测试基础设施。 +- module/client 编译、TypeScript typecheck、受影响文件静态/格式检查、DTO parity、生成绑定/schema、编码、文档索引与 diff 检查通过。 +- 本机隔离库没有既有评分数据,复用既有 helper 建两个账号、两款游戏和两条空评论评价;三接口摘要 `8/2` 一致,隐藏一条后为 `7/1`,另一游戏 `null/0`。游客/登录列表一致,公开列表/详情 no-store、健康检查通过。 +- 1280px 桌面及 375px 移动浏览器显示正常且评分行无横向溢出;详情期间经真实 API 改分,返回广场更新为 `6.0/10 · 1 人评分`。 + +## 未验证与待验收 + +验收勾选保留给用户最终确认,临时计划尚未清理。未执行完整仓库测试、完整评价/后台管理 E2E、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分、实际发行包游玩或生产部署;本次没有重建管理动作/分页矩阵。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2f14b852a..70e540b2c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7,7 +7,7 @@ - 共用 DTO 可选字段兼容作者及旧响应,当前公开列表/详情保证返回;缺字段显示“评分暂不可用”,不能伪装为无人评分。读取与后台管理后的下一次刷新一致,不增加推送/轮询。 - 本增量不改变持久化表或评价写入,仅同步读取投影、DTO 与生成绑定;不扩展评分排序、推荐、AGC 或外部 API。 - 用户要求测试保持简单:仅补卡片正常/零评价/缺摘要及公开摘要契约断言,复用已有统计/管理/目录测试和少量隔离数据 smoke;不新增专用 E2E、分页 fixture 或逐层重复测试。 -- 权威入口:[游戏广场评分展示合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)。当前先交付文档,工程实现和验收待完成。 +- 权威入口:[游戏广场评分展示合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)。已完成工程实现及本地定向验证,待用户验收,未部署;持久化表未变,公开 procedure 返回类型与后端绑定需配套发布。 ## 2026-10-01 后台游戏评价管理规则 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 71b044aeb..b4cdb4251 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -410,7 +410,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | accepted(用户确认方案,待实现;未部署) | +| Status | implemented(本地验证通过,待用户验收;未部署) | | Date | 2026-10-01 | | 入口 | 网站 `/games` 游戏广场卡片 | | 交付目标 | 游客与登录用户在游戏卡片查看真实平均分和评分人数,统计与详情评价及后台管理口径一致 | @@ -449,11 +449,13 @@ | 条款 | 验收方式 | 当前证据 | | --- | --- | --- | -| 展示与字段 | 现有页面测试补正常评分/零评价/缺摘要三种情况,现有公开响应/DTO 测试核对新增字段 | 待实现验证 | -| 统计口径 | 复用有效评价统计及管理测试;用现有两款游戏和少量评价做一次列表/详情/评价摘要对照,确认隐藏过滤与统计不串联 | 待实现验证 | -| 页面与原行为 | 桌面及 375px 移动检查评分行和详情返回后的更新;可见性、列表失败、筛选与滚动恢复沿用现有测试 | 待实现验证 | -| 工程与兼容 | 受影响代码定向测试/typecheck、DTO/绑定/schema、编码/文档索引/diff 检查及上述一次隔离 API smoke | 待实现验证 | +| 展示与字段 | 现有页面测试补正常评分/零评价/缺摘要三种情况,现有公开响应/DTO 测试核对新增字段 | 前端两文件 36 项、API 定向 34 项通过;仅新增评分文案参数化断言并扩展已有响应测试 | +| 统计口径 | 复用有效评价统计及管理测试;用两款游戏和少量评价做一次列表/详情/评价摘要对照,确认隐藏过滤与统计不串联 | 领域 summary 2 项通过;隔离库两款游戏、两条空评论评价,`8/2` 隐藏一条后为 `7/1`,另一游戏始终 `null/0`,三接口摘要一致 | +| 页面与原行为 | 桌面及 375px 移动检查评分行和详情返回后的更新;可见性、列表失败、筛选与滚动恢复沿用现有测试 | 1280px/375px 浏览器卡片无横向溢出;详情期间经 API 改分,返回广场由 `7.0/1` 更新为 `6.0/1`;既有目录/详情测试通过 | +| 工程与兼容 | 受影响代码定向测试/typecheck、DTO/绑定/schema、编码/文档索引/diff 检查及上述一次隔离 API smoke | Rust shared-contracts 4 项、module/client 编译、TypeScript typecheck、受影响文件静态/格式检查及 DTO/绑定/schema/编码/文档索引/diff 检查通过;真实列表/详情 no-store 通过 | 测试按本增量风险控制:只补新展示与摘要契约的必要断言,复用已有评价、管理和目录测试;不新增框架、专用 E2E 脚本、大批量分页数据、故障注入或重复的多层映射测试。既有证据可支撑未改动的规则,但仍须验证本次公开投影和页面接入。 -用户已确认方案,并要求先写文档与提交。本次只交付主规范、单里程碑和实施计划,工程实现、自动化行为测试、真实数据库/API、浏览器与生产部署均未执行;此前基础评价及后台管理证据不能替代本增量验收。 +2026-10-01 按用户后续实现授权完成公开快照、共享契约、后端响应和共享评分文本接入。绑定以仓库固定 SpacetimeDB 2.8.3 生成,持久化表未变;只为列表最终返回游戏聚合评价。隔离库没有既有评分 fixture,本次复用既有 helper 临时创建两个账号、两款游戏和两条评价,未新增仓库 E2E 脚本或完整管理矩阵。 + +本地验证通过,尚待用户验收和生产部署。未跑完整仓库测试、完整评价/后台管理 E2E、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分和实际发行包游玩;返回刷新使用真实 API 改分验证,登录用户公开列表已在 API smoke 对照。 diff --git a/packages/shared/src/components/GameRatingSummaryText.tsx b/packages/shared/src/components/GameRatingSummaryText.tsx new file mode 100644 index 000000000..9aa33c2ce --- /dev/null +++ b/packages/shared/src/components/GameRatingSummaryText.tsx @@ -0,0 +1,17 @@ +import type { GameDistributionRatingSummary } from '../contracts/gameDistribution'; + +export function GameRatingSummaryText({ + summary, +}: { + summary?: GameDistributionRatingSummary; +}) { + return ( + + {!summary + ? '评分暂不可用' + : summary.ratingCount === 0 + ? '暂无评分' + : `${summary.averageScore!.toFixed(1)}/10 · ${summary.ratingCount} 人评分`} + + ); +} diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts index 5ffea5e58..811853f6e 100644 --- a/packages/shared/src/components/index.ts +++ b/packages/shared/src/components/index.ts @@ -1,3 +1,4 @@ +export { GameRatingSummaryText } from './GameRatingSummaryText'; export type { PlatformActionButtonAlign, PlatformActionButtonProps, diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 7946a2ef2..1d0d8cd7a 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -139,6 +139,8 @@ export type GameDistributionGame = { * 创建、提交、审核、撤回)走服务端同一个 `game_payload`,不带这一项。 */ currentVersion?: GameDistributionVersionSummary | null; + /** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */ + ratingSummary?: GameDistributionRatingSummary; playCount: number; createdAt: string; }; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 88de00f60..c87e829dc 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -76,7 +76,7 @@ const TS_ONLY_TYPES = [ ]; // 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。 -// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影 = 作者投影 + currentVersion); +// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影追加 currentVersion、ratingSummary); // - `mustEmit`:TS 类型为了同时描述两种形状把该键标成可选,但这条路径必须发出。 // 只解析顶层键;需要一并比对的嵌套对象用 `nested` 显式登记,没登记的嵌套对象不在证据范围内。 const RESPONSE_BUILDERS = [ @@ -92,7 +92,7 @@ const RESPONSE_BUILDERS = [ fn: 'public_game_payload', ts: 'GameDistributionGame', base: 'game_payload', - mustEmit: ['currentVersion'], + mustEmit: ['currentVersion', 'ratingSummary'], }, { fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' }, { fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' }, diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index b0e409f12..a892127b7 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -246,7 +246,7 @@ pub fn router(state: AppState) -> Router { state.clone(), require_admin_auth, )) - .route_layer(middleware::from_fn(add_user_review_response_headers)); + .route_layer(middleware::from_fn(add_no_store_response_headers)); let user_reviews = Router::new() .route( "/api/game-distribution/games/{game_id}/my-review", @@ -260,7 +260,7 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/games/{game_id}/reviews", get(list_user_reviews), ) - .route_layer(middleware::from_fn(add_user_review_response_headers)); + .route_layer(middleware::from_fn(add_no_store_response_headers)); let protected = Router::new() .route( "/api/game-distribution/publish-metadata/suggestions", @@ -341,9 +341,12 @@ pub fn router(state: AppState) -> Router { require_admin_auth, )); - Router::new() + let public_games = Router::new() .route("/api/game-distribution/games", get(list_games)) .route("/api/game-distribution/games/{game_id}", get(get_game)) + .route_layer(middleware::from_fn(add_no_store_response_headers)); + + Router::new() .route( "/api/game-distribution/releases/{game_id}/{*asset_path}", get(serve_release_asset), @@ -358,13 +361,14 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/releases/{game_id}/", get(serve_release_entry), ) + .merge(public_games) .merge(protected) .merge(user_reviews) .merge(admin_user_reviews) .merge(admin) } -async fn add_user_review_response_headers(request: Request, next: Next) -> Response { +async fn add_no_store_response_headers(request: Request, next: Next) -> Response { let mut response = next.run(request).await; response .headers_mut() @@ -2306,6 +2310,10 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value { .map(|version| version_summary_payload(&version)) .unwrap_or(Value::Null), ); + object.insert( + "ratingSummary".to_string(), + json!(rating_summary_payload(game.rating_summary)), + ); } payload } @@ -3484,7 +3492,7 @@ mod tests { } #[test] - fn public_payload_exposes_cover_and_screenshot_object_keys() { + fn public_payload_exposes_assets_and_rating_summary() { let game = GameDistributionGameRecord { game_id: "game_1".to_string(), owner_user_id: "user_1".to_string(), @@ -3521,6 +3529,28 @@ mod tests { payload["screenshots"][0], Value::String("generated/shot-1.png".to_string()) ); + let legacy: shared_contracts::game_distribution::GameDistributionGameSummary = + serde_json::from_value(payload).expect("旧游戏响应应可解析"); + assert!(legacy.rating_summary.is_none()); + for (average_score, rating_count) in [(None, 0), (Some(8.2), 26)] { + let payload = public_game_payload(GameDistributionPublicGameRecord { + game: game.clone(), + current_version: None, + rating_summary: GameDistributionRatingSummaryRecord { + average_score, + rating_count, + }, + }); + let summary: shared_contracts::game_distribution::GameDistributionGameSummary = + serde_json::from_value(payload).expect("公开游戏响应应可解析"); + assert_eq!( + summary.rating_summary, + Some(GameDistributionRatingSummary { + average_score, + rating_count, + }) + ); + } } #[test] diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 22712227e..8142c5761 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -155,7 +155,7 @@ pub struct GameDistributionVersionSummary { pub controls: Vec, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionGameSummary { pub id: String, @@ -184,9 +184,12 @@ pub struct GameDistributionGameSummary { pub current_version: Option, pub play_count: u64, pub created_at: String, + /// 公开列表/详情保证返回;作者侧及旧响应可省略。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rating_summary: Option, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionListResponse { pub games: Vec, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 12d00bada..176783bc4 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -14,7 +14,7 @@ pub struct GameDistributionUserReviewRecord { pub is_hidden: bool, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct GameDistributionRatingSummaryRecord { pub average_score: Option, pub rating_count: u64, @@ -240,10 +240,11 @@ pub struct GameDistributionOwnerGameRecord { pub versions: Vec, } -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct GameDistributionPublicGameRecord { pub game: GameDistributionGameRecord, pub current_version: Option, + pub rating_summary: GameDistributionRatingSummaryRecord, } fn map_game( @@ -397,6 +398,10 @@ pub(crate) fn map_game_distribution_public_game_list_result( .map(|value| GameDistributionPublicGameRecord { game: map_game(value.game), current_version: value.current_version.map(map_version), + rating_summary: GameDistributionRatingSummaryRecord { + average_score: value.average_score, + rating_count: value.rating_count, + }, }) .collect()) } @@ -419,6 +424,10 @@ pub(crate) fn map_game_distribution_public_game_result( Ok(result.game.map(|value| GameDistributionPublicGameRecord { game: map_game(value.game), current_version: value.current_version.map(map_version), + rating_summary: GameDistributionRatingSummaryRecord { + average_score: value.average_score, + rating_count: value.rating_count, + }, })) } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs index 554c13887..b9cd4b0aa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_snapshot_type.rs @@ -12,6 +12,8 @@ use super::game_distribution_version_snapshot_type::GameDistributionVersionSnaps pub struct GameDistributionPublicGameSnapshot { pub game: GameDistributionGameSnapshot, pub current_version: Option, + pub average_score: Option, + pub rating_count: u64, } impl __sdk::InModule for GameDistributionPublicGameSnapshot { diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 4a9f2bd61..1d4a59cde 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1161,10 +1161,12 @@ pub struct GameDistributionAdminGameListResult { pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct GameDistributionPublicGameSnapshot { pub game: GameDistributionGameSnapshot, pub current_version: Option, + pub average_score: Option, + pub rating_count: u64, } /// 作者自有游戏的聚合快照:游戏身份 + 最近若干版本,供作者管理页展示状态与驳回理由。 @@ -1181,14 +1183,14 @@ pub struct GameDistributionOwnerGameListProcedureResult { pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct GameDistributionGameProcedureResult { pub ok: bool, pub game: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct GameDistributionGameListProcedureResult { pub ok: bool, pub games: Vec, @@ -2994,17 +2996,19 @@ fn list_public_game_distribution_games_tx( || game.tags_json.contains(value) }) }) - .filter_map(|game| public_game_distribution_snapshot(ctx, &game)) + .filter(|game| public_game_distribution_version(ctx, game).is_some()) .collect::>(); games.sort_by(|left, right| { right - .game - .created_at_micros - .cmp(&left.game.created_at_micros) - .then_with(|| left.game.game_id.cmp(&right.game.game_id)) + .created_at + .cmp(&left.created_at) + .then_with(|| left.game_id.cmp(&right.game_id)) }); games.truncate(limit); - Ok(games) + Ok(games + .into_iter() + .filter_map(|game| public_game_distribution_snapshot(ctx, &game)) + .collect()) } fn get_public_game_distribution_game_tx( @@ -3347,8 +3351,11 @@ fn public_game_distribution_snapshot( game: &GameDistributionGame, ) -> Option { let version = public_game_distribution_version(ctx, game)?; + let (average_score, rating_count) = game_distribution_user_rating_summary(ctx, &game.game_id); Some(GameDistributionPublicGameSnapshot { game: game_distribution_game_snapshot(game), current_version: Some(game_distribution_version_snapshot_for_game(ctx, &version)), + average_score, + rating_count, }) } diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 1132c88a1..879a4b223 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -120,6 +120,18 @@ beforeEach(() => { }); describe('GameGalleryPage', () => { + it.each([ + [{ averageScore: 8.2, ratingCount: 26 }, '8.2/10 · 26 人评分'], + [{ averageScore: 9, ratingCount: 1 }, '9.0/10 · 1 人评分'], + [{ averageScore: null, ratingCount: 0 }, '暂无评分'], + [undefined, '评分暂不可用'], + ])('显示列表返回的评分摘要 %#', async (ratingSummary, expected) => { + listGamesMock.mockResolvedValueOnce([createGame({ ratingSummary })]); + render(); + + expect(await screen.findByText(expected)).toBeTruthy(); + }); + it('ignores a stale response after search keyword changes', async () => { const first = deferred(); const second = deferred(); diff --git a/src/components/game-distribution/GameGalleryPage.tsx b/src/components/game-distribution/GameGalleryPage.tsx index 6df822fc1..867fc9e14 100644 --- a/src/components/game-distribution/GameGalleryPage.tsx +++ b/src/components/game-distribution/GameGalleryPage.tsx @@ -1,6 +1,7 @@ import './gameDistribution.css'; import './gameDistributionShowcase.css'; +import { GameRatingSummaryText } from '@genarrative/shared/components'; import { Gamepad2, Monitor, Search, Smartphone, Sparkles } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -104,6 +105,9 @@ function GameCard({ {game.summary} + + + {game.category} · diff --git a/src/components/game-distribution/GameReviews.tsx b/src/components/game-distribution/GameReviews.tsx index fc0f19c50..34e11723d 100644 --- a/src/components/game-distribution/GameReviews.tsx +++ b/src/components/game-distribution/GameReviews.tsx @@ -1,5 +1,7 @@ import './gameReviews.css'; +import { GameRatingSummaryText } from '@genarrative/shared/components'; + import type { GameDistributionReview } from '../../services/gameDistributionClient'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; @@ -15,13 +17,7 @@ type ReviewsState = ReturnType; export function GameRatingSummary({ state }: { state: ReviewsState }) { if (state.summary) { - return ( - - {state.summary.ratingCount === 0 - ? '暂无评分' - : `${state.summary.averageScore!.toFixed(1)}/10 · ${state.summary.ratingCount} 人评分`} - - ); + return ; } if (state.listError) { return ( diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 986e43668..948a80a14 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -238,6 +238,14 @@ text-overflow: ellipsis; white-space: nowrap; } +.game-card__rating { + display: block; + margin-top: 0.42rem; + color: var(--platform-text-base); + font-size: 0.78rem; + line-height: 1.55; + overflow-wrap: anywhere; +} .game-card__meta { display: flex; gap: 0.38rem;