lili 2eb507da4a feat(studio): U9 素材中心/玩法模板中心/创作者公开主页 + P-ACC-03适龄徽标
3 new pages(view→store→api→request CommonResult 范式,全 mock 兜底):
- 素材中心 /studio/material:六类浏览(GET /app-api/studio/asset/browse)+ 上传
  (POST /infra/file/upload→POST /app-api/studio/asset)+ 选用入草稿
  (POST /app-api/studio/asset/select)
- 玩法模板中心 /studio/template:GET /app-api/studio/template/list(5 品类
  business-sim/narrative/puzzle/trpg/heritage)+ 一键去创作
- 创作者公开主页 /creator/:creatorId(PUBLIC 非 requiresAuth,P-OPN-08):
  GET /app-api/project/public/creator/{id}(仅 status==4)+
  GET /app-api/telemetry/public/creator/{id}/summary

P-ACC-03 适龄徽标 AgeRatingBadge(4 级 all/8+/12+/16+)渲染于:项目详情/我的项目
列表/创作者主页(VO ageRating)+ 试玩页(compliance/rating/get 回读)。
gap:feed 卡 VO 无 ageRating 字段(不杜撰,待 feed.yaml additive);profile/account
页无单局分级数据源——均显式留空。

HJ-FE-DS-001 token/组件复用 + i18n zh/en + 移动优先;strict TS 无 any;
新增 mock studio.ts(注册 index.ts,/infra 前缀放行)。
gate:npm run build(vue-tsc -b && vite build)GREEN;preview+curl 6 端点
+ 3 路由 200 实证。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:40:14 -07:00

262 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* studio 模块 mock handlerU9素材中心 + 玩法模板中心 + 创作者公开主页 + 内容分级 + 文件上传)。
* 严格贴 contracts/api-schemas/studio.yaml + compliance.yaml + P-OPN-08 公开聚合契约结构。
* 默认 dev/preview 离线可跑(与 biz/community/project 等同范式),三新页无后端亦可走通交互。
*
* 覆盖端点:
* POST /infra/file/upload → String文件 urlmultipart 上传兜底)
* GET /app-api/studio/asset/browse?category&pageNo&pageSize → { list: StudioMaterialRespVO[], total }
* POST /app-api/studio/asset → StudioAssetContextItem登记返回
* POST /app-api/studio/asset/select → StudioAssetContextItem[](写入后草稿 assetContext
* GET /app-api/studio/template/list → StudioTemplateRespVO[]5 品类)
* GET /app-api/project/public/creator/:creatorId → { list, total }(仅 status==4 公开作品)
* GET /app-api/telemetry/public/creator/:creatorId/summary → { creatorId, publishedCount, totalPlayCount, totalLikeCount }
* GET /app-api/compliance/rating/get?gameId&versionId → { rating, ratedBy, updateTime }
*
* 假数据:素材库预置六类各若干条 + 上传/登记就地增;模板 5 品类business-sim/narrative/puzzle/trpg/heritage
* 创作者公开作品预置 5 条覆盖四级适龄 + 聚合统计据此派生。写操作就地改本地态,保证交互闭环。
*/
import type { MockRoute, MockRequest } from './shared'
import { nowISO, seededInt } from './shared'
import type {
MaterialCategory,
StudioMaterial,
StudioAssetContextItem,
StudioTemplate,
ContentRating,
AgeRating,
PublicCreatorProject,
PublicCreatorSummary,
StudioMaterialRegisterReq,
StudioMaterialSelectReq,
} from '../api/types'
/* ------------------------------- 素材库本地态 ------------------------------- */
/** 六类类目(与契约 MaterialCategoryEnum 严格一致) */
const CATEGORIES: MaterialCategory[] = ['sprite', 'character', 'effect', 'scene', 'ui', 'music']
/** 类目中文名mock 素材名兜底用;展示文案由前端 i18n 控制,这里仅生成假名称) */
const CAT_CN: Record<MaterialCategory, string> = {
sprite: '图元',
character: '角色',
effect: '特效',
scene: '场景',
ui: '界面',
music: '音乐',
}
/** 素材自增 ID */
let materialSeq = 88230
/** 构造一条素材(按类目 + 序号生成稳定假数据) */
function makeMaterial(category: MaterialCategory, idx: number): StudioMaterial {
const id = ++materialSeq
const isMusic = category === 'music'
return {
id,
category,
// ref 主键(喂 assetContext.refmock 用类目+id 拼稳定 ref
ref: `asset_${id}`,
// 只读镜像 url图类给占位图域名music 给占位音频路径;前端卡片据 category 走图标兜底)
url: isMusic ? `/mock-assets/${category}/${id}.mp3` : `/mock-assets/${category}/${id}.png`,
provider: 'mmx-cli',
name: `${CAT_CN[category]}素材_${idx + 1}`,
sizeBytes: seededInt(id, 4096, 524_288),
mimeType: isMusic ? 'audio/mpeg' : 'image/png',
createTime: new Date(Date.now() - idx * 3600_000).toISOString(),
}
}
/** 预置素材库:每类 3 条(共 18 条),覆盖六类浏览/过滤 */
const MATERIALS: StudioMaterial[] = CATEGORIES.flatMap((cat) =>
Array.from({ length: 3 }, (_, i) => makeMaterial(cat, i)),
)
/* ------------------------------- handlers素材中心 ------------------------------- */
/**
* POST /infra/file/upload —— 上传字节兜底mock 不真存字节,返回稳定占位 url
* 真实后端 CommonResult<String>data=文件 urlmock 直接返回一个伪 url 串。
*/
function uploadFileHandler(): string {
const id = ++materialSeq
// 伪 url含时间戳避免与既有素材 ref 撞);登记时作 ref + url
return `/infra/file/mock/${Date.now()}_${id}.bin`
}
/** GET /app-api/studio/asset/browse —— 浏览本人素材(按类目过滤 + pageNo 分页id 倒序) */
function assetBrowseHandler(req: MockRequest): { list: StudioMaterial[]; total: number } {
const catQ =
req.query.category && req.query.category !== '' ? (req.query.category as MaterialCategory) : undefined
const pageNo = req.query.pageNo ? parseInt(req.query.pageNo, 10) || 1 : 1
const pageSize = req.query.pageSize ? parseInt(req.query.pageSize, 10) || 12 : 12
// 全量按 id 倒序(新上传在前)
let rows = [...MATERIALS].sort((a, b) => b.id - a.id)
if (catQ) rows = rows.filter((m) => m.category === catQ)
const total = rows.length
const start = (pageNo - 1) * pageSize
const page = rows.slice(start, start + pageSize)
return { list: page, total }
}
/** POST /app-api/studio/asset —— 按引用登记素材(就地增到库头,返回六类引用项) */
function assetRegisterHandler(req: MockRequest): StudioAssetContextItem {
const body = (req.body ?? {}) as Partial<StudioMaterialRegisterReq>
// 贴契约必填category / ref
const category = (body.category ?? 'sprite') as MaterialCategory
const ref = body.ref ?? `asset_${++materialSeq}`
const id = ++materialSeq
const rec: StudioMaterial = {
id,
category,
ref,
url: body.url ?? ref,
provider: 'mmx-cli',
name: body.name ?? `${CAT_CN[category]}素材`,
sizeBytes: body.sizeBytes,
mimeType: body.mimeType,
createTime: nowISO(),
}
// 新登记插到库头(浏览倒序后即在最前)
MATERIALS.push(rec)
// 返回输入态六类引用项(喂 assetContext
return { category: rec.category, ref: rec.ref, url: rec.url, provider: rec.provider }
}
/** POST /app-api/studio/asset/select —— 选用入草稿mock 直接回选中素材的 assetContext 形态) */
function assetSelectHandler(req: MockRequest): StudioAssetContextItem[] {
const body = (req.body ?? {}) as Partial<StudioMaterialSelectReq>
const ids = Array.isArray(body.materialIds) ? body.materialIds : []
const picked = MATERIALS.filter((m) => ids.includes(m.id))
return picked.map((m) => ({ category: m.category, ref: m.ref, url: m.url, provider: m.provider }))
}
/* ------------------------------- handlers玩法模板中心 ------------------------------- */
/**
* 5 品类玩法模板business-sim/narrative/puzzle/trpg/heritage
* templateId 用品类标识name/description/examplePrompt 贴 U9 任务要求(名称/描述/示例 Prompt
*/
const TEMPLATES: StudioTemplate[] = [
{
templateId: 'business-sim',
name: '模拟经营',
description: '经营养成玩法:开店、升级、扩张,适合品牌世界观与放置经营题材。',
coverUrl: '',
examplePrompt: '做一个经营奶茶店的小游戏,顾客排队点单、升级设备赚更多钱',
},
{
templateId: 'narrative',
name: '叙事互动',
description: '剧情分支玩法:对话、选择、多结局,适合故事向与角色养成体验。',
coverUrl: '',
examplePrompt: '做一个校园悬疑互动小说,玩家的选择会导向不同结局',
},
{
templateId: 'puzzle',
name: '益智解谜',
description: '关卡解谜玩法:消除、连线、推箱子,轻量耐玩、留存友好。',
coverUrl: '',
examplePrompt: '做一个三消益智小游戏,消除相同颜色的方块通关',
},
{
templateId: 'trpg',
name: '桌面跑团',
description: '桌游跑团玩法:掷骰、属性、剧情推进,适合策略与角色扮演。',
coverUrl: '',
examplePrompt: '做一个掷骰探险跑团小游戏,根据骰子点数推进剧情',
},
{
templateId: 'heritage',
name: '文化传承',
description: '非遗文旅玩法:拼图、问答、打卡,承载文化科普与地域文旅。',
coverUrl: '',
examplePrompt: '做一个皮影戏拼装小游戏,拼对图案点亮非遗知识卡',
},
]
/** GET /app-api/studio/template/list —— 玩法模板列表5 品类) */
function templateListHandler(): StudioTemplate[] {
return TEMPLATES
}
/* ------------------------------- handlers创作者公开主页P-OPN-08 ------------------------------- */
/** 四级适龄(公开作品覆盖全四级,验证徽标四态) */
const AGE_CYCLE: AgeRating[] = ['all', '8+', '12+', '16+', 'all']
/** 预置某创作者的公开作品(仅 status==4 语义,故 mock 全部视为已发布) */
function makeCreatorWork(creatorId: number, idx: number): PublicCreatorProject {
const id = creatorId * 100 + idx + 1
return {
id,
title: `公开作品 ${idx + 1}`,
summary: `这是创作者 ${creatorId} 的第 ${idx + 1} 个已发布小游戏。`,
coverUrl: '',
ageRating: AGE_CYCLE[idx % AGE_CYCLE.length],
playCount: seededInt(id, 120, 9800),
likeCount: seededInt(id + 1, 10, 1200),
publishTime: new Date(Date.now() - idx * 86400_000).toISOString(),
creatorId,
creatorName: `创作者${creatorId}`,
}
}
/** 取某创作者全部公开作品mock 固定 5 条) */
function creatorWorksAll(creatorId: number): PublicCreatorProject[] {
return Array.from({ length: 5 }, (_, i) => makeCreatorWork(creatorId, i))
}
/** GET /app-api/project/public/creator/:creatorId —— 创作者公开作品pageNo 分页,仅已发布) */
function creatorProjectsHandler(req: MockRequest): { list: PublicCreatorProject[]; total: number } {
const creatorId = Number(req.params.creatorId) || 0
const pageNo = req.query.pageNo ? parseInt(req.query.pageNo, 10) || 1 : 1
const pageSize = req.query.pageSize ? parseInt(req.query.pageSize, 10) || 12 : 12
const all = creatorWorksAll(creatorId)
const total = all.length
const start = (pageNo - 1) * pageSize
return { list: all.slice(start, start + pageSize), total }
}
/** GET /app-api/telemetry/public/creator/:creatorId/summary —— 公开统计(聚合已发布作品) */
function creatorSummaryHandler(req: MockRequest): PublicCreatorSummary {
const creatorId = Number(req.params.creatorId) || 0
const all = creatorWorksAll(creatorId)
return {
creatorId,
publishedCount: all.length,
totalPlayCount: all.reduce((s, w) => s + (w.playCount ?? 0), 0),
totalLikeCount: all.reduce((s, w) => s + (w.likeCount ?? 0), 0),
}
}
/* ------------------------------- handlers内容分级P-ACC-03 回读源) ------------------------------- */
/** GET /app-api/compliance/rating/get —— 内容分级(按 gameId 稳定派生四级mock 不抛 NOT_FOUND */
function ratingGetHandler(req: MockRequest): ContentRating {
const gameId = req.query.gameId ? parseInt(req.query.gameId, 10) || 0 : 0
// 按 gameId 稳定落到四级之一(验证试玩页徽标)
const rating = AGE_CYCLE[seededInt(gameId, 0, 3)]
return { rating, ratedBy: 'gate 锁风门自动', updateTime: nowISO() }
}
/* ------------------------------- 路由表(具体 path 在 :param 之前) ------------------------------- */
export const studioRoutes: MockRoute[] = [
// 文件上传(共享 infra
{ method: 'POST', pattern: '/infra/file/upload', handler: uploadFileHandler },
// 素材中心
{ method: 'GET', pattern: '/app-api/studio/asset/browse', handler: assetBrowseHandler },
{ method: 'POST', pattern: '/app-api/studio/asset/select', handler: assetSelectHandler },
{ method: 'POST', pattern: '/app-api/studio/asset', handler: assetRegisterHandler },
// 玩法模板中心
{ method: 'GET', pattern: '/app-api/studio/template/list', handler: templateListHandler },
// 创作者公开主页P-OPN-08
{ method: 'GET', pattern: '/app-api/telemetry/public/creator/:creatorId/summary', handler: creatorSummaryHandler },
{ method: 'GET', pattern: '/app-api/project/public/creator/:creatorId', handler: creatorProjectsHandler },
// 内容分级P-ACC-03
{ method: 'GET', pattern: '/app-api/compliance/rating/get', handler: ratingGetHandler },
]