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>
262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
/**
|
||
* studio 模块 mock handler(U9:素材中心 + 玩法模板中心 + 创作者公开主页 + 内容分级 + 文件上传)。
|
||
* 严格贴 contracts/api-schemas/studio.yaml + compliance.yaml + P-OPN-08 公开聚合契约结构。
|
||
* 默认 dev/preview 离线可跑(与 biz/community/project 等同范式),三新页无后端亦可走通交互。
|
||
*
|
||
* 覆盖端点:
|
||
* POST /infra/file/upload → String(文件 url,multipart 上传兜底)
|
||
* 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.ref);mock 用类目+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=文件 url;mock 直接返回一个伪 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 },
|
||
]
|