E6 走查逮到 6 处用户可见旧品牌串(profile 昵称默认/locale brand/mock 文案),违项目品牌口径(品牌=造梦AI,绘境/huijing 已彻底淘汰除 git 历史): - locales zh/en brand: 绘境AI/Huijing AI → 造梦AI(en 取与 index.html <title> 一致的 CJK 品牌,仓内无既定罗马化) - mock project/community/feed 文案 + store/user 默认昵称「绘境创作者」→「造梦创作者」 - 仅字符串字面值,零逻辑改;grep 实证 src 内 绘境/Huijing 归零 验证:`npm run build` 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
198 lines
7.2 KiB
TypeScript
198 lines
7.2 KiB
TypeScript
/**
|
||
* feed 模块 mock handler(严格贴 contracts/api-schemas/feed.yaml 响应结构)。
|
||
* 覆盖端点:
|
||
* GET /app-api/feed/stream → FeedStreamRespVO
|
||
* GET /app-api/feed/zones → ZoneRespVO[]
|
||
* GET /app-api/feed/zone/stream → FeedStreamRespVO
|
||
* POST /app-api/feed/interact → InteractResultRespVO
|
||
* GET /app-api/feed/share/:gameId → ShareMetaRespVO
|
||
*
|
||
* 假数据:≥10 张卡(够竖屏刷)+ cursor 翻页(offset 编码进 cursor)。
|
||
*/
|
||
import type { MockRoute, MockRequest } from './shared'
|
||
import { MockError, seededInt } from './shared'
|
||
import type {
|
||
FeedCard,
|
||
FeedStream,
|
||
Zone,
|
||
InteractResult,
|
||
ShareMeta,
|
||
FeedInteractReq,
|
||
} from '../api/types'
|
||
|
||
/** demo 版本 ID(与 runtime mock 对齐:所有 mock 游戏都路由到同一 demo 包,entry='demo') */
|
||
const DEMO_VERSION_ID = 9001
|
||
|
||
/** 生成内联 SVG 封面(data URI,离线可用,避免外链依赖) */
|
||
function cover(seed: number, title: string): string {
|
||
// 用 seed 选一组深色科技风渐变色
|
||
const palettes = [
|
||
['#101522', '#1b2440'],
|
||
['#0d1b1e', '#13343a'],
|
||
['#1a0f24', '#2a1740'],
|
||
['#0f1a14', '#163a2a'],
|
||
['#241016', '#3a1722'],
|
||
]
|
||
const [c1, c2] = palettes[seed % palettes.length]
|
||
const accent = ['#31e6ff', '#7cf2a4', '#ff669d', '#ffc857', '#9d7cff'][seed % 5]
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="375" height="640" viewBox="0 0 375 640">
|
||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient></defs>
|
||
<rect width="375" height="640" fill="url(#g)"/>
|
||
<circle cx="300" cy="120" r="90" fill="${accent}" opacity="0.12"/>
|
||
<text x="28" y="540" fill="#eef6ff" font-size="30" font-family="sans-serif" font-weight="700">${title}</text>
|
||
<text x="28" y="575" fill="${accent}" font-size="15" font-family="sans-serif">造梦 demo 游戏</text></svg>`
|
||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
|
||
}
|
||
|
||
/** 卡片标题词库(拼出有辨识度的 demo 标题) */
|
||
const TITLES = [
|
||
'霓虹躲避',
|
||
'星际跑酷',
|
||
'点击工厂',
|
||
'方块消除',
|
||
'弹幕射手',
|
||
'迷宫探险',
|
||
'节奏大师',
|
||
'像素农场',
|
||
'重力翻转',
|
||
'深渊潜水',
|
||
'光速摩托',
|
||
'糖果连连',
|
||
]
|
||
const AUTHORS = ['阿青', '小绿', '粉黛', '琥珀', '紫电', '深蓝']
|
||
|
||
/**
|
||
* 生成第 idx 张卡(idx 从 1 开始,保证字段稳定可复现)。
|
||
* gameId = idx;versionId 统一 DEMO_VERSION_ID(试玩链路真跑同一 demo 包)。
|
||
*/
|
||
function makeCard(idx: number, zoneId: number): FeedCard {
|
||
const title = TITLES[(idx - 1) % TITLES.length]
|
||
return {
|
||
gameId: idx,
|
||
versionId: DEMO_VERSION_ID,
|
||
title,
|
||
coverUrl: cover(idx, title),
|
||
authorName: AUTHORS[(idx - 1) % AUTHORS.length],
|
||
// packageUrl 指向 runtime 取包端点(宿主真实加载时会再调 runtime.getPackage)
|
||
packageUrl: `/games/${idx}/versions/${DEMO_VERSION_ID}/manifest.json`,
|
||
zoneId,
|
||
qualityScore: seededInt(idx, 60, 98), // 运营质量分(0-100)
|
||
playCount: seededInt(idx * 7, 120, 99999),
|
||
likeCount: seededInt(idx * 13, 30, 8888),
|
||
liked: false,
|
||
favorited: false,
|
||
}
|
||
}
|
||
|
||
/** 互动计数本地态(按 gameId+action 累计,模拟服务端聚合回显) */
|
||
const interactState = new Map<string, { active: boolean; total: number }>()
|
||
|
||
/**
|
||
* 通用流分页:按 cursor(编码 offset)返回一页,PAGE_SIZE 条。
|
||
* @param zoneId 专区 ID(0=默认混合流)
|
||
* @param cursor 上页游标('' 或 undefined=首屏)
|
||
*/
|
||
function paginate(zoneId: number, cursor: string | undefined): FeedStream {
|
||
const PAGE = 10
|
||
const TOTAL = 36 // 总量(多页可刷,验证上拉加载)
|
||
const offset = cursor ? parseInt(cursor, 10) || 0 : 0
|
||
const end = Math.min(offset + PAGE, TOTAL)
|
||
const list: FeedCard[] = []
|
||
for (let i = offset + 1; i <= end; i++) {
|
||
// zone 流用 zoneId,混合流给卡片分配 1/2 两个专区
|
||
const z = zoneId > 0 ? zoneId : (i % 2 === 0 ? 1 : 2)
|
||
list.push(makeCard(i, z))
|
||
}
|
||
const hasMore = end < TOTAL
|
||
return {
|
||
list,
|
||
nextCursor: hasMore ? String(end) : '',
|
||
hasMore,
|
||
}
|
||
}
|
||
|
||
/** GET /app-api/feed/stream */
|
||
function streamHandler(req: MockRequest): FeedStream {
|
||
return paginate(0, req.query.cursor)
|
||
}
|
||
|
||
/** GET /app-api/feed/zones */
|
||
function zonesHandler(): Zone[] {
|
||
// 双轨:1 官方精选 / 2 UGC 普通(贴 ZoneRespVO)
|
||
return [
|
||
{ id: 1, name: '官方精选', type: 1, sort: 1 },
|
||
{ id: 2, name: 'UGC 广场', type: 2, sort: 2 },
|
||
]
|
||
}
|
||
|
||
/** GET /app-api/feed/zone/stream?zoneId&cursor */
|
||
function zoneStreamHandler(req: MockRequest): FeedStream {
|
||
const zoneId = parseInt(req.query.zoneId, 10)
|
||
if (!zoneId) {
|
||
// 贴契约:zoneId 必填;缺失给一个业务错误码(feed 段)
|
||
throw new MockError(1103001001, '缺少 zoneId')
|
||
}
|
||
return paginate(zoneId, req.query.cursor)
|
||
}
|
||
|
||
/** POST /app-api/feed/interact */
|
||
function interactHandler(req: MockRequest): InteractResult {
|
||
const body = (req.body ?? {}) as Partial<FeedInteractReq>
|
||
const gameId = Number(body.gameId)
|
||
const action = Number(body.action)
|
||
if (!gameId || ![1, 2, 3, 4].includes(action)) {
|
||
throw new MockError(1103001002, 'gameId/action 非法')
|
||
}
|
||
// 举报必须带 reason(贴契约约束)
|
||
if (action === 4 && !body.reason) {
|
||
throw new MockError(1103004001, '举报需填写理由')
|
||
}
|
||
const key = `${gameId}:${action}`
|
||
const prev = interactState.get(key) ?? {
|
||
active: false,
|
||
total: seededInt(gameId * action, 10, 5000),
|
||
}
|
||
// 点赞/收藏可切换;分享/举报恒 active=true 且累加
|
||
let active: boolean
|
||
let total = prev.total
|
||
if (action === 1 || action === 2) {
|
||
active = body.active ?? !prev.active
|
||
total += active ? 1 : -1
|
||
if (total < 0) total = 0
|
||
} else {
|
||
active = true
|
||
total += 1
|
||
}
|
||
interactState.set(key, { active, total })
|
||
return { gameId, action, active, totalCount: total }
|
||
}
|
||
|
||
/** GET /app-api/feed/share/:gameId?channel */
|
||
function shareHandler(req: MockRequest): ShareMeta {
|
||
const gameId = Number(req.params.gameId)
|
||
if (!gameId) throw new MockError(1103002001, '游戏不存在或未发布')
|
||
const channel = req.query.channel || 'direct'
|
||
const title = TITLES[(gameId - 1) % TITLES.length]
|
||
return {
|
||
gameId,
|
||
// GAP-3:分享落地页能直接拿到「即玩版本」。mock 统一回 DEMO_VERSION_ID(已发布态,
|
||
// 与 feed 卡片/runtime 取包同源);真实后端仅在 isGamePublished 时回填,未发布缺省。
|
||
versionId: DEMO_VERSION_ID,
|
||
shareUrl: `https://wanxiang.ai/share/${gameId}?utm_source=${encodeURIComponent(channel)}`,
|
||
ogTitle: `来玩《${title}》`,
|
||
ogImage: cover(gameId, title),
|
||
ogDescription: '造梦AI 一句话生成的小游戏,点开即玩',
|
||
channel,
|
||
}
|
||
}
|
||
|
||
/** 导出本模块路由表(供 mock/index 汇总) */
|
||
export const feedRoutes: MockRoute[] = [
|
||
{ method: 'GET', pattern: '/app-api/feed/stream', handler: streamHandler },
|
||
{ method: 'GET', pattern: '/app-api/feed/zones', handler: zonesHandler },
|
||
{ method: 'GET', pattern: '/app-api/feed/zone/stream', handler: zoneStreamHandler },
|
||
{ method: 'POST', pattern: '/app-api/feed/interact', handler: interactHandler },
|
||
{ method: 'GET', pattern: '/app-api/feed/share/:gameId', handler: shareHandler },
|
||
]
|