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;