功能:游戏广场展示真实评分摘要
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m23s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Successful in 2m4s
Project CI / Repository checks (pull_request) Failing after 19s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 11m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m22s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m23s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Successful in 2m4s
Project CI / Repository checks (pull_request) Failing after 19s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 11m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m22s
公开游戏列表与详情在同一读取快照中返回有效评价均分和人数 同步共享契约、公开投影映射、生成绑定与 no-store 响应 广场与详情复用评分文本组件,区分无人评分与摘要不可用 扩展必要展示和响应契约测试,保留原有目录交互 更新规范、实施计划与本地定向测试及联调验证证据
This commit is contained in:
+1
-1
@@ -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 契约唯一机器可读来源。
|
||||
|
||||
@@ -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 验证;未增加复杂测试矩阵。
|
||||
|
||||
@@ -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、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分、实际发行包游玩或生产部署;本次没有重建管理动作/分页矩阵。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- 共用 DTO 可选字段兼容作者及旧响应,当前公开列表/详情保证返回;缺字段显示“评分暂不可用”,不能伪装为无人评分。读取与后台管理后的下一次刷新一致,不增加推送/轮询。
|
||||
- 本增量不改变持久化表或评价写入,仅同步读取投影、DTO 与生成绑定;不扩展评分排序、推荐、AGC 或外部 API。
|
||||
- 用户要求测试保持简单:仅补卡片正常/零评价/缺摘要及公开摘要契约断言,复用已有统计/管理/目录测试和少量隔离数据 smoke;不新增专用 E2E、分页 fixture 或逐层重复测试。
|
||||
- 权威入口:[游戏广场评分展示合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)。当前先交付文档,工程实现和验收待完成。
|
||||
- 权威入口:[游戏广场评分展示合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)。已完成工程实现及本地定向验证,待用户验收,未部署;持久化表未变,公开 procedure 返回类型与后端绑定需配套发布。
|
||||
|
||||
## 2026-10-01 后台游戏评价管理规则
|
||||
|
||||
|
||||
@@ -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 对照。
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { GameDistributionRatingSummary } from '../contracts/gameDistribution';
|
||||
|
||||
export function GameRatingSummaryText({
|
||||
summary,
|
||||
}: {
|
||||
summary?: GameDistributionRatingSummary;
|
||||
}) {
|
||||
return (
|
||||
<span>
|
||||
{!summary
|
||||
? '评分暂不可用'
|
||||
: summary.ratingCount === 0
|
||||
? '暂无评分'
|
||||
: `${summary.averageScore!.toFixed(1)}/10 · ${summary.ratingCount} 人评分`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { GameRatingSummaryText } from './GameRatingSummaryText';
|
||||
export type {
|
||||
PlatformActionButtonAlign,
|
||||
PlatformActionButtonProps,
|
||||
|
||||
@@ -139,6 +139,8 @@ export type GameDistributionGame = {
|
||||
* 创建、提交、审核、撤回)走服务端同一个 `game_payload`,不带这一项。
|
||||
*/
|
||||
currentVersion?: GameDistributionVersionSummary | null;
|
||||
/** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */
|
||||
ratingSummary?: GameDistributionRatingSummary;
|
||||
playCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -246,7 +246,7 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
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<AppState> {
|
||||
"/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<AppState> {
|
||||
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<AppState> {
|
||||
"/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]
|
||||
|
||||
@@ -155,7 +155,7 @@ pub struct GameDistributionVersionSummary {
|
||||
pub controls: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<GameDistributionVersionSummary>,
|
||||
pub play_count: u64,
|
||||
pub created_at: String,
|
||||
/// 公开列表/详情保证返回;作者侧及旧响应可省略。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rating_summary: Option<GameDistributionRatingSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionListResponse {
|
||||
pub games: Vec<GameDistributionGameSummary>,
|
||||
|
||||
@@ -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<f64>,
|
||||
pub rating_count: u64,
|
||||
@@ -240,10 +240,11 @@ pub struct GameDistributionOwnerGameRecord {
|
||||
pub versions: Vec<GameDistributionVersionRecord>,
|
||||
}
|
||||
|
||||
#[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<GameDistributionVersionRecord>,
|
||||
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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -12,6 +12,8 @@ use super::game_distribution_version_snapshot_type::GameDistributionVersionSnaps
|
||||
pub struct GameDistributionPublicGameSnapshot {
|
||||
pub game: GameDistributionGameSnapshot,
|
||||
pub current_version: Option<GameDistributionVersionSnapshot>,
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: u64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionPublicGameSnapshot {
|
||||
|
||||
@@ -1161,10 +1161,12 @@ pub struct GameDistributionAdminGameListResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct GameDistributionPublicGameSnapshot {
|
||||
pub game: GameDistributionGameSnapshot,
|
||||
pub current_version: Option<GameDistributionVersionSnapshot>,
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: u64,
|
||||
}
|
||||
|
||||
/// 作者自有游戏的聚合快照:游戏身份 + 最近若干版本,供作者管理页展示状态与驳回理由。
|
||||
@@ -1181,14 +1183,14 @@ pub struct GameDistributionOwnerGameListProcedureResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct GameDistributionGameProcedureResult {
|
||||
pub ok: bool,
|
||||
pub game: Option<GameDistributionPublicGameSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct GameDistributionGameListProcedureResult {
|
||||
pub ok: bool,
|
||||
pub games: Vec<GameDistributionPublicGameSnapshot>,
|
||||
@@ -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::<Vec<_>>();
|
||||
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<GameDistributionPublicGameSnapshot> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(<GameGalleryPage onOpenDetail={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores a stale response after search keyword changes', async () => {
|
||||
const first = deferred<GameDistributionGame[]>();
|
||||
const second = deferred<GameDistributionGame[]>();
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
</span>
|
||||
<span className="game-card__summary">{game.summary}</span>
|
||||
<span className="game-card__rating">
|
||||
<GameRatingSummaryText summary={game.ratingSummary} />
|
||||
</span>
|
||||
<span className="game-card__meta">
|
||||
<span>{game.category}</span>
|
||||
<span>·</span>
|
||||
|
||||
@@ -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<typeof useGameReviews>;
|
||||
|
||||
export function GameRatingSummary({ state }: { state: ReviewsState }) {
|
||||
if (state.summary) {
|
||||
return (
|
||||
<span>
|
||||
{state.summary.ratingCount === 0
|
||||
? '暂无评分'
|
||||
: `${state.summary.averageScore!.toFixed(1)}/10 · ${state.summary.ratingCount} 人评分`}
|
||||
</span>
|
||||
);
|
||||
return <GameRatingSummaryText summary={state.summary} />;
|
||||
}
|
||||
if (state.listError) {
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user