From 2eb507da4aba67af0da91ef94d4d50fa3249f500 Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 18 Jun 2026 13:40:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20U9=20=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E4=B8=AD=E5=BF=83/=E7=8E=A9=E6=B3=95=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E4=B8=AD=E5=BF=83/=E5=88=9B=E4=BD=9C=E8=80=85=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E4=B8=BB=E9=A1=B5=20+=20P-ACC-03=E9=80=82=E9=BE=84?= =?UTF-8?q?=E5=BE=BD=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- game-studio/src/api/studio.ts | 169 ++++++ game-studio/src/api/types.ts | 173 +++++++ game-studio/src/components/AgeRatingBadge.vue | 109 ++++ game-studio/src/components/Icon.vue | 15 + game-studio/src/locales/age.en.ts | 11 + game-studio/src/locales/age.zh.ts | 13 + game-studio/src/locales/studio.en.ts | 64 +++ game-studio/src/locales/studio.zh.ts | 79 +++ game-studio/src/mock/index.ts | 7 +- game-studio/src/mock/studio.ts | 261 ++++++++++ game-studio/src/router/index.ts | 28 + game-studio/src/store/studio.ts | 267 ++++++++++ game-studio/src/views/play/Play.vue | 35 +- game-studio/src/views/profile/CreatorHome.vue | 324 ++++++++++++ game-studio/src/views/project/Detail.vue | 3 + game-studio/src/views/project/Project.vue | 3 + game-studio/src/views/studio/Material.vue | 490 ++++++++++++++++++ game-studio/src/views/studio/Template.vue | 234 +++++++++ game-studio/src/views/studio/material-meta.ts | 40 ++ 19 files changed, 2322 insertions(+), 3 deletions(-) create mode 100644 game-studio/src/api/studio.ts create mode 100644 game-studio/src/components/AgeRatingBadge.vue create mode 100644 game-studio/src/locales/age.en.ts create mode 100644 game-studio/src/locales/age.zh.ts create mode 100644 game-studio/src/locales/studio.en.ts create mode 100644 game-studio/src/locales/studio.zh.ts create mode 100644 game-studio/src/mock/studio.ts create mode 100644 game-studio/src/store/studio.ts create mode 100644 game-studio/src/views/profile/CreatorHome.vue create mode 100644 game-studio/src/views/studio/Material.vue create mode 100644 game-studio/src/views/studio/Template.vue create mode 100644 game-studio/src/views/studio/material-meta.ts diff --git a/game-studio/src/api/studio.ts b/game-studio/src/api/studio.ts new file mode 100644 index 00000000..803d9beb --- /dev/null +++ b/game-studio/src/api/studio.ts @@ -0,0 +1,169 @@ +/** + * studio 模块 API(U6/U9:素材中心 + 玩法模板中心)+ P-OPN-08 创作者公开聚合 + P-ACC-03 内容分级只读。 + * ---------------------------------------------------------------------------- + * 端: + * - /app-api/studio/asset/* 素材库(浏览/登记/选用入草稿)——严格贴 contracts/api-schemas/studio.yaml + * - /app-api/studio/template/list 玩法模板列表(透传 aigc 模板注册表) + * - /infra/file/upload 共享文件服务(multipart 上传字节拿 url;huijing-module-infra,studio 不持字节) + * - /app-api/project/public/creator/{id} P-OPN-08 创作者公开作品(@PermitAll 跨创作者公开读) + * - /app-api/telemetry/public/creator/{id}/summary P-OPN-08 创作者公开统计聚合(@PermitAll) + * - /app-api/compliance/rating/get P-ACC-03 内容分级只读(无 VO ageRating 字段时回读源) + * + * 设计要点(与 biz/community/feed 等既有 api 文件同范式): + * - 复用 ./request 的 huijing CommonResult 信封解包:拦截器已把 {code,data,msg} 解包为 data,故返回 Promise。 + * - 素材库归属隔离在后端可信边界(service 按 getLoginUserId() 强制过滤),前端只带登录态 Token。 + * - 公开端点 @PermitAll:匿名可读、跨创作者读;前端无需登录态(request 拦截器有 token 则带,无亦放行)。 + * - 上传字节走 multipart(FormData),不套 JSON content-type;后端返回 CommonResult(data=文件 url),拦截器解包为 string。 + */ +import { request } from './request' +import type { + MaterialCategory, + StudioMaterialPage, + StudioAssetContextItem, + StudioMaterialRegisterReq, + StudioMaterialSelectReq, + StudioTemplate, + ContentRating, + AgeRating, + PublicCreatorProjectPage, + PublicCreatorSummary, +} from './types' + +/* ------------------------------- 素材中心(U6 R-MAT) ------------------------------- */ + +/** + * 浏览本人素材(owner-isolated 分页)。 + * GET /app-api/studio/asset/browse?category&pageNo&pageSize + * 归属隔离:service 按 getLoginUserId() 强制过滤(创作者只见自己素材);可选 category 过滤六类,按 id 倒序。 + * @param params category(可选六类过滤)/ pageNo(从 1)/ pageSize(最大 200) + */ +export function getAssetList(params: { + category?: MaterialCategory + pageNo: number + pageSize: number +}): Promise { + return request({ + url: '/app-api/studio/asset/browse', + method: 'get', + params, + }) +} + +/** + * 共享文件服务上传字节(multipart)。 + * POST /infra/file/upload(huijing-module-infra AppFileController;studio 不持字节,先拿 url 再登记)。 + * 后端 CommonResult,data=可访问文件 url;拦截器解包为 string。 + * @param file 浏览器 File 对象(来自 ) + * @returns 文件可访问 url(同时用作素材 ref 与 url) + */ +export function uploadFile(file: File): Promise { + const form = new FormData() + // 后端 AppFileUploadReqVO 表单字段名 = file(multipart 表单项) + form.append('file', file) + return request({ + url: '/infra/file/upload', + method: 'post', + data: form, + // 覆盖默认 application/json:让浏览器自动带 multipart/form-data boundary + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +/** + * 按引用登记素材(字节先经 /infra/file/upload 拿 ref)。 + * POST /app-api/studio/asset + * 编排:六类校验 → mimeType 与类目族 best-effort 一致性自检 → 落 game_material(归属本人) → 返回六类引用项。 + * @param body 登记请求(category/ref 必填,url/name/sizeBytes/mimeType 可选) + * @returns 输入态六类引用项(可直接喂 assetContext.ref) + */ +export function registerAsset(body: StudioMaterialRegisterReq): Promise { + return request({ + url: '/app-api/studio/asset', + method: 'post', + data: body, + }) +} + +/** + * 选用素材入草稿(select-into-draft,R-MAT)。 + * POST /app-api/studio/asset/select + * 把选中素材(materialIds,本人素材库)写入目标草稿会话 assetContext(按 ref 去重合并);归属校验本人会话 + 本人素材。 + * @param body 选用请求(sessionId 目标草稿会话 + materialIds 选中素材) + * @returns 写入后草稿的完整 assetContext 列表 + */ +export function selectAssetIntoDraft(body: StudioMaterialSelectReq): Promise { + return request({ + url: '/app-api/studio/asset/select', + method: 'post', + data: body, + }) +} + +/* ------------------------------- 玩法模板中心(U9 P-TPL-01/03) ------------------------------- */ + +/** + * 玩法模板列表(模板浏览/应用)。 + * GET /app-api/studio/template/list(透传 aigc 模板注册表,与 aigc TemplateRespVO 同构)。 + * 注:本路径为 studio 代理端点(已存在于 studio.yaml);与 aigc 的 /app-api/aigc/template/list 同语义, + * U9 玩法模板中心走 studio 代理路径(创作流统一收敛 studio 域)。 + */ +export function getTemplates(): Promise { + return request({ + url: '/app-api/studio/template/list', + method: 'get', + }) +} + +/* ------------------------------- 创作者公开主页(P-OPN-08,@PermitAll) ------------------------------- */ + +/** + * 创作者公开作品(跨创作者公开读,仅 status==4 已发布)。 + * GET /app-api/project/public/creator/{creatorId}?pageNo&pageSize + * @PermitAll:匿名/跨创作者可读,无 PII;后端仅返回 status==4(排除 5下架/6封禁)。 + * @param creatorId 创作者 ID + * @param params 分页 pageNo(从 1)/ pageSize + */ +export function getCreatorPublicProjects( + creatorId: number, + params: { pageNo: number; pageSize: number }, +): Promise { + return request({ + url: `/app-api/project/public/creator/${creatorId}`, + method: 'get', + params, + }) +} + +/** + * 创作者公开统计汇总(对该创作者 status==4 作品聚合 试玩/点赞)。 + * GET /app-api/telemetry/public/creator/{creatorId}/summary + * @PermitAll:匿名/跨创作者可读,无 PII。 + * @param creatorId 创作者 ID + */ +export function getCreatorPublicSummary(creatorId: number): Promise { + return request({ + url: `/app-api/telemetry/public/creator/${creatorId}/summary`, + method: 'get', + }) +} + +/* ------------------------------- 内容分级只读(P-ACC-03 回读源) ------------------------------- */ + +/** + * 取内容分级(只读;P-ACC-03 在无 VO ageRating 字段处的回读源,如试玩页)。 + * GET /app-api/compliance/rating/get?gameId&versionId + * 无记录后端报 COMPLIANCE_RATING_NOT_FOUND(调用方 catch 后不渲染徽标,诚实降级)。 + * @param gameId 游戏 ID + * @param versionId 版本 ID(无版本传 0) + * @returns 内容分级(rating∈AgeRating) + */ +export function getRating(gameId: number, versionId = 0): Promise { + return request({ + url: '/app-api/compliance/rating/get', + method: 'get', + params: { gameId, versionId }, + }) +} + +/** 内容分级取值再导出(便于消费方按 AgeRating 收口类型) */ +export type { AgeRating } diff --git a/game-studio/src/api/types.ts b/game-studio/src/api/types.ts index bb43eb7b..9859960b 100644 --- a/game-studio/src/api/types.ts +++ b/game-studio/src/api/types.ts @@ -687,3 +687,176 @@ export interface BizConfirmReq { /** 确认的 demo 版本 ID */ demoVersionId?: number } + +/* ============================== studio.yaml(U6/U9 素材中心 + 玩法模板中心) ============================== */ + +/** + * 素材六类类目(studio.yaml MaterialCategoryEnum / StudioAssetContextItem.category): + * sprite 图元 / character 角色 / effect 特效 / scene 场景 / ui 界面 / music 音乐。 + * 与 contracts db V24 game_material.category + source-project.schema.json assetSpec.category 四处同引一枚举,禁发明契约外值。 + */ +export type MaterialCategory = 'sprite' | 'character' | 'effect' | 'scene' | 'ui' | 'music' + +/** + * 素材浏览项(studio.yaml StudioMaterialRespVO,逐字段镜像)。 + * GET /app-api/studio/asset/browse 列表项;比输入态 StudioAssetContextItem 多 id + 展示元数据。 + */ +export interface StudioMaterial { + /** 素材 ID(选用入参 materialIds) */ + id: number + /** 六类素材类目 */ + category: MaterialCategory + /** 平台素材引用 ref(喂 assetContext.ref) */ + ref: string + /** 素材访问 URL(只读镜像,可空) */ + url?: string + /** 素材来源 provider(缺省 mmx-cli) */ + provider?: string + /** 素材名称(展示用) */ + name?: string + /** 文件大小(字节) */ + sizeBytes?: number + /** 文件 MIME 类型 */ + mimeType?: string + /** 上传时间(ISO date-time) */ + createTime?: string +} + +/** + * 素材浏览分页结果(studio.yaml CommonResultStudioMaterialPage.data)。 + * total 总条数(pageNo/pageSize 翻页模型,与 aigc task/my 同范式,区别于 feed/biz 的 cursor)。 + */ +export interface StudioMaterialPage { + list: StudioMaterial[] + total: number +} + +/** + * 六类素材引用项(studio.yaml StudioAssetContextItem,输入态/登记返回)。 + * 选用入草稿 assetContext 的元素;登记素材(POST /asset)返回单条此结构。 + */ +export interface StudioAssetContextItem { + /** 六类素材类目 */ + category: MaterialCategory + /** 平台 assetId/ref(主) */ + ref: string + /** 只读镜像 URL(可空) */ + url?: string + /** 素材来源 provider(可空,默认 mmx-cli) */ + provider?: string +} + +/** 按引用登记素材请求体(studio.yaml StudioMaterialRegisterReqVO;字节先经 /infra/file/upload 拿 ref) */ +export interface StudioMaterialRegisterReq { + /** 六类素材类目(必填) */ + category: MaterialCategory + /** 平台素材引用 ref(主;来自 /infra/file/upload 返回的访问路径,必填) */ + ref: string + /** 只读镜像 URL(可空;来自上传响应) */ + url?: string + /** 素材名称(可空,展示用;缺省取类目名) */ + name?: string + /** 文件大小(字节,可空;从上传响应透传) */ + sizeBytes?: number + /** 文件 MIME 类型(可空;从上传响应透传) */ + mimeType?: string +} + +/** 选用素材入草稿请求体(studio.yaml StudioMaterialSelectReqVO) */ +export interface StudioMaterialSelectReq { + /** 目标草稿会话 ID(写其 assetContext) */ + sessionId: number + /** 选用的素材 ID 列表(本人素材库) */ + materialIds: number[] +} + +/** + * 玩法模板(studio.yaml StudioTemplateRespVO,透传 aigc 模板注册表,与 AigcTemplate 同构)。 + * GET /app-api/studio/template/list 列表项。 + */ +export interface StudioTemplate { + /** 模板 ID(如 dodge/clicker/idle/tycoon/narrative) */ + templateId: string + /** 模板名称 */ + name: string + /** 模板说明 */ + description?: string + /** 模板封面/示意图 URL */ + coverUrl?: string + /** 示例 Prompt(P-CRT-11,一键填创作页) */ + examplePrompt?: string +} + +/* ============================== compliance.yaml(P-ACC-03 适龄分级只读) ============================== */ + +/** + * 内容分级(compliance.yaml ContentRatingRespVO,逐字段镜像)。 + * GET /app-api/compliance/rating/get?gameId&versionId 的 data;P-ACC-03 适龄徽标在无 VO ageRating 字段时的回读源。 + * 无记录后端报 COMPLIANCE_RATING_NOT_FOUND(前端 catch 后不渲染徽标,诚实降级)。 + */ +export interface ContentRating { + /** 分级取值:all/8+/12+/16+(与 AgeRating 一致) */ + rating: AgeRating + /** 定级来源:gate 锁风门自动 / admin 人工复核 */ + ratedBy?: string + /** 更新时间(ISO date-time) */ + updateTime?: string +} + +/* ============================== P-OPN-08 创作者公开聚合(公开读,@PermitAll) ============================== */ +/* + * 创始人默认语义:公开 = 项目 status==PUBLISHED(4) 仅此一态(排除 5下架/6封禁); + * 公开统计 = 对该创作者 status==4 作品聚合 试玩/点赞 数,无 PII。 + * ⚠️ 本两端点为 P-OPN-08 新增、后端与本契约并行实现;前端严格按下述契约镜像,不发明字段。 + */ + +/** + * 创作者公开作品项(project.yaml PublicCreatorProjectRespVO,逐字段镜像)。 + * GET /app-api/project/public/creator/{creatorId} 列表项;仅 status==4 已发布,无 PII。 + */ +export interface PublicCreatorProject { + /** 游戏 ID(project.id) */ + id: number + /** 标题 */ + title: string + /** 简介(可空) */ + summary?: string + /** 封面图 URL(可空) */ + coverUrl?: string + /** 适龄分级(P-ACC-03 公开作品卡徽标数据源;可空) */ + ageRating?: AgeRating + /** 累计试玩数 */ + playCount?: number + /** 累计点赞数 */ + likeCount?: number + /** 发布时间(ISO date-time) */ + publishTime?: string + /** 创作者 ID */ + creatorId: number + /** 创作者昵称(公开展示,非 PII) */ + creatorName?: string +} + +/** + * 创作者公开作品分页结果(PageResult,huijing 标准分页)。 + * pageNo/pageSize 翻页模型;total 为符合条件(status==4)的总数。 + */ +export interface PublicCreatorProjectPage { + list: PublicCreatorProject[] + total: number +} + +/** + * 创作者公开统计汇总(telemetry.yaml 公开聚合,逐字段镜像)。 + * GET /app-api/telemetry/public/creator/{creatorId}/summary 的 data;对该创作者 status==4 作品聚合。 + */ +export interface PublicCreatorSummary { + /** 创作者 ID */ + creatorId: number + /** 已发布作品数(status==4) */ + publishedCount: number + /** 累计试玩数(聚合已发布作品) */ + totalPlayCount: number + /** 累计点赞数(聚合已发布作品) */ + totalLikeCount: number +} diff --git a/game-studio/src/components/AgeRatingBadge.vue b/game-studio/src/components/AgeRatingBadge.vue new file mode 100644 index 00000000..3f3fa787 --- /dev/null +++ b/game-studio/src/components/AgeRatingBadge.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/game-studio/src/components/Icon.vue b/game-studio/src/components/Icon.vue index 0fcff5e9..fc2ae870 100644 --- a/game-studio/src/components/Icon.vue +++ b/game-studio/src/components/Icon.vue @@ -32,6 +32,21 @@ const PATHS: Record = { warning: '', close: '', ban: '', + // —— U9 新增(素材中心/玩法模板中心/创作者公开主页 + P-ACC-03 适龄徽标)—— + // 盾牌(适龄分级徽标 P-ACC-03,lucide shield) + shield: '', + // 图片(素材中心 sprite/scene/ui 类目 + 素材卡占位,lucide image) + image: '', + // 上传(素材上传入口,lucide upload) + upload: '', + // 图层/品类(玩法模板中心,lucide layers) + layers: '', + // 音乐(素材中心 music 类目,lucide music) + music: '', + // 角色(素材中心 character 类目,lucide user-round 复用 me 变体) + character: '', + // 特效(素材中心 effect 类目,复用 spark 同形,lucide sparkles 简化) + effect: '', } // 找不到的图标渲染空 svg(不报错) diff --git a/game-studio/src/locales/age.en.ts b/game-studio/src/locales/age.en.ts new file mode 100644 index 00000000..7b379c30 --- /dev/null +++ b/game-studio/src/locales/age.en.ts @@ -0,0 +1,11 @@ +/** + * Age-rating badge copy (English, mirrors age.zh.ts). + * Locally injected by AgeRatingBadge.vue (useScope: 'local'); only `all` is translated, + * 8+/12+/16+ render as raw symbols. + */ +export default { + age: { + all: 'All ages', + aria: 'Age rating: {level}', + }, +} diff --git a/game-studio/src/locales/age.zh.ts b/game-studio/src/locales/age.zh.ts new file mode 100644 index 00000000..ebc8eb13 --- /dev/null +++ b/game-studio/src/locales/age.zh.ts @@ -0,0 +1,13 @@ +/** + * 适龄分级徽标文案(中文基线)。命名空间:age。 + * 由 AgeRatingBadge.vue 页域局部注入(useScope: 'local'),不污染全局 i18n/index.ts。 + * 仅「全年龄」需翻译,8+/12+/16+ 为通用符号直接展示(见 AgeRatingBadge META.labelKey)。 + */ +export default { + age: { + /** all 级文案(全年龄;其余 8+/12+/16+ 直接展示符号) */ + all: '全年龄', + /** 无障碍标签插值模板(如「适龄分级:全年龄」) */ + aria: '适龄分级:{level}', + }, +} diff --git a/game-studio/src/locales/studio.en.ts b/game-studio/src/locales/studio.en.ts new file mode 100644 index 00000000..baa9f2be --- /dev/null +++ b/game-studio/src/locales/studio.en.ts @@ -0,0 +1,64 @@ +/** + * studio three-page copy (English, mirrors studio.zh.ts). + * Locally injected by Material.vue / Template.vue / CreatorHome.vue (useScope: 'local'). + * Common words reuse common.*; only page-specific copy lives here. + */ +export default { + /* —— Material Center (/studio/material) —— */ + material: { + title: 'Material Center', + subtitle: 'Upload and manage your assets, then bring them into a draft', + catAll: 'All', + catSprite: 'Sprite', + catCharacter: 'Character', + catEffect: 'Effect', + catScene: 'Scene', + catUi: 'UI', + catMusic: 'Music', + upload: 'Upload', + uploading: 'Uploading…', + uploadHint: 'Upload to "{cat}"', + uploadDone: 'Uploaded', + uploadFail: 'Upload failed, please retry', + select: 'Select', + selected: '{n} selected', + useInDraft: 'Add to draft', + selectDone: 'Added to draft', + selectEmpty: 'Select assets first', + loadMore: 'Load more', + noMore: 'No more', + countLabel: '{n} assets', + emptyTitle: 'No assets in this category', + emptyDesc: 'Tap "Upload" above to add your first asset', + sizeKb: '{n} KB', + noSession: 'Enter from the create page to add assets to the current draft', + }, + + /* —— Template Center (/studio/template) —— */ + template: { + title: 'Template Center', + subtitle: 'Pick a genre and start creating with an example', + example: 'Example', + use: 'Create with this template', + emptyTitle: 'Templates coming soon', + emptyDesc: 'Templates are being onboarded, check back later', + retry: 'Refresh', + }, + + /* —— Creator Home (/creator/:creatorId) —— */ + creator: { + fallbackName: 'Creator', + statPublished: 'Published', + statPlay: 'Plays', + statLike: 'Likes', + worksTitle: 'Published works', + loadMore: 'Load more', + noMore: 'No more', + emptyTitle: 'No public works yet', + emptyDesc: 'This creator has not published anything yet', + errorTitle: 'Failed to load', + errorDesc: 'Could not load the creator home, please retry', + playAria: '{n} plays', + likeAria: '{n} likes', + }, +} diff --git a/game-studio/src/locales/studio.zh.ts b/game-studio/src/locales/studio.zh.ts new file mode 100644 index 00000000..c182b0a7 --- /dev/null +++ b/game-studio/src/locales/studio.zh.ts @@ -0,0 +1,79 @@ +/** + * studio 三页文案(中文基线)。命名空间:material 素材中心 / template 玩法模板中心 / creator 创作者公开主页。 + * 由 Material.vue / Template.vue / CreatorHome.vue 页域局部注入(useScope: 'local'),不污染全局 i18n/index.ts。 + * 通用词(加载中/这里还没有内容/重试/更多/立即玩…)复用 common.*,本文件只放三页专属文案。 + */ +export default { + /* —— 素材中心(/studio/material,U6 R-MAT)—— */ + material: { + title: '素材中心', + subtitle: '上传与管理你的创作素材,选用后即可带入草稿', + /** 六类类目 tab(与契约 MaterialCategoryEnum 严格对齐) */ + catAll: '全部', + catSprite: '图元', + catCharacter: '角色', + catEffect: '特效', + catScene: '场景', + catUi: '界面', + catMusic: '音乐', + /** 上传入口 */ + upload: '上传素材', + uploading: '上传中…', + uploadHint: '上传到「{cat}」类目', + uploadDone: '上传成功', + uploadFail: '上传失败,请重试', + /** 选用入草稿 */ + select: '选用', + selected: '已选 {n} 个', + useInDraft: '带入草稿', + selectDone: '已带入草稿', + selectEmpty: '请先选择素材', + /** 列表 */ + loadMore: '加载更多', + noMore: '没有更多了', + countLabel: '共 {n} 个素材', + /** 空态 */ + emptyTitle: '这个类目还没有素材', + emptyDesc: '点上方「上传素材」添加你的第一个素材', + /** 素材卡元信息 */ + sizeKb: '{n} KB', + /** 草稿会话缺失提示(无草稿上下文时禁用「带入草稿」) */ + noSession: '从创作页进入可将素材带入当前草稿', + }, + + /* —— 玩法模板中心(/studio/template,P-TPL-01/03)—— */ + template: { + title: '玩法模板中心', + subtitle: '选一个玩法品类,一键带着示例去创作', + /** 模板卡 */ + example: '示例', + use: '用此模板去创作', + /** 空态 */ + emptyTitle: '模板升级中', + emptyDesc: '玩法模板正在接入,稍后再来看看', + retry: '刷新', + }, + + /* —— 创作者公开主页(/creator/:creatorId,P-OPN-08)—— */ + creator: { + /** 默认创作者名兜底(昵称缺省时) */ + fallbackName: '创作者', + /** 统计卡 */ + statPublished: '已发布', + statPlay: '总试玩', + statLike: '总点赞', + /** 作品区 */ + worksTitle: '已发布作品', + loadMore: '加载更多', + noMore: '没有更多了', + /** 空态 */ + emptyTitle: '还没有公开作品', + emptyDesc: 'TA 还没有发布任何作品', + /** 加载失败 */ + errorTitle: '加载失败', + errorDesc: '创作者主页加载失败,请稍后重试', + /** 作品卡计数无障碍 */ + playAria: '试玩 {n}', + likeAria: '点赞 {n}', + }, +} diff --git a/game-studio/src/mock/index.ts b/game-studio/src/mock/index.ts index c5212402..31a3cb54 100644 --- a/game-studio/src/mock/index.ts +++ b/game-studio/src/mock/index.ts @@ -16,6 +16,7 @@ import { projectRoutes } from './project' import { telemetryRoutes } from './telemetry' import { communityRoutes } from './community' import { bizRoutes } from './biz' +import { studioRoutes } from './studio' /** 全部 mock 路由(按模块拼接;模块内已排好 specific→param 顺序) */ const ALL_ROUTES = [ @@ -26,6 +27,7 @@ const ALL_ROUTES = [ ...telemetryRoutes, ...communityRoutes, ...bizRoutes, + ...studioRoutes, ] /** huijing 统一包络结构 */ @@ -85,5 +87,6 @@ export async function dispatch( return null } -/** 已注册的 API 前缀(插件据此判断是否拦截) */ -export const MOCK_PREFIXES = ['/app-api', '/admin-api'] as const +/** 已注册的 API 前缀(插件据此判断是否拦截)。 + * /infra:共享文件服务(huijing-module-infra,如 /infra/file/upload),U9 素材中心上传链需 mock 兜底。 */ +export const MOCK_PREFIXES = ['/app-api', '/admin-api', '/infra'] as const diff --git a/game-studio/src/mock/studio.ts b/game-studio/src/mock/studio.ts new file mode 100644 index 00000000..e239c0dc --- /dev/null +++ b/game-studio/src/mock/studio.ts @@ -0,0 +1,261 @@ +/** + * 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 = { + 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,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 + // 贴契约必填: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 + 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 }, +] diff --git a/game-studio/src/router/index.ts b/game-studio/src/router/index.ts index 2b3ee873..006be688 100644 --- a/game-studio/src/router/index.ts +++ b/game-studio/src/router/index.ts @@ -134,6 +134,34 @@ const routes: RouteRecordRaw[] = [ props: true, }, + // —— studio 创作工具(素材中心 / 玩法模板中心;从创作流进入,归属「创作」Tab)—— + { + path: '/studio/material', + name: 'studio-material', + component: () => import('../views/studio/Material.vue'), // U9 素材中心(U6 R-MAT) + // requiresAuth:素材库为登录态私有数据(后端按 getLoginUserId() 归属隔离),上传/选用为写入口(§9.3); + // hideTabBar:二级页自带返回按钮(与 dashboard/publish 同口径),归属「创作」Tab。 + meta: { title: '素材中心', tab: 'create', hideTabBar: true, requiresAuth: true }, + }, + { + path: '/studio/template', + name: 'studio-template', + component: () => import('../views/studio/Template.vue'), // U9 玩法模板中心(P-TPL-01/03) + // requiresAuth:玩法模板中心是创作起步入口(一键去创作,下游 /create 即写入口),统一登录门(§9.3); + // hideTabBar:二级页自带返回按钮,归属「创作」Tab。 + meta: { title: '玩法模板中心', tab: 'create', hideTabBar: true, requiresAuth: true }, + }, + + // —— 创作者公开主页(P-OPN-08,PUBLIC:匿名/跨创作者可读,NOT requiresAuth)—— + { + path: '/creator/:creatorId', + name: 'creator-home', + component: () => import('../views/profile/CreatorHome.vue'), // U9 创作者公开聚合主页 + // 公开读:无 requiresAuth(守卫放行);hideTabBar 沉浸二级页自带返回按钮。 + meta: { title: '创作者主页', hideTabBar: true }, + props: true, + }, + // —— 企业定制(B 端,承接 biz 客户侧;从个人中心进入,归属「我的」Tab)—— // 全部 requiresAuth:询单/进度/报价为登录态私有数据(后端 Mapper 强制 customer_user_id = getLoginUserId() 归属), // 未真实登录跳 /login(§9.3)。列表保留 TabBar;提单/详情为二级页自带返回按钮,hideTabBar 沉浸。 diff --git a/game-studio/src/store/studio.ts b/game-studio/src/store/studio.ts new file mode 100644 index 00000000..8eefb21f --- /dev/null +++ b/game-studio/src/store/studio.ts @@ -0,0 +1,267 @@ +/** + * useStudioStore —— U9 studio 三页共享态:素材中心 + 玩法模板中心 + 创作者公开主页。 + * ---------------------------------------------------------------------------- + * 对应契约端点: + * GET /app-api/studio/asset/browse 素材浏览(owner-isolated 分页,U6) + * POST /infra/file/upload + POST /app-api/studio/asset 上传字节→登记素材(U6) + * POST /app-api/studio/asset/select 选用入草稿(U6) + * GET /app-api/studio/template/list 玩法模板列表(P-TPL-01/03) + * GET /app-api/project/public/creator/{id} 创作者公开作品(P-OPN-08,@PermitAll) + * GET /app-api/telemetry/public/creator/{id}/summary 创作者公开统计(P-OPN-08,@PermitAll) + * + * 范式(与 biz/feed/project store 对齐):state + loading 防并发 + 失败由 request.ts 统一 Toast,store 只管态与刷新。 + * 职责边界: + * - 素材:materials(当前类目分页累计)/ materialTotal / materialLoading / activeCategory / materialPageNo(pageNo 翻页模型)。 + * - 模板:templates / templatesLoading。 + * - 创作者公开主页:creatorSummary / creatorWorks(分页累计)/ creatorTotal / creatorPageNo / creatorLoading。 + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as studioApi from '../api/studio' +import type { + MaterialCategory, + StudioMaterial, + StudioTemplate, + StudioMaterialRegisterReq, + PublicCreatorProject, + PublicCreatorSummary, +} from '../api/types' + +export const useStudioStore = defineStore('studio', () => { + /* ------------------------------- 素材中心 ------------------------------- */ + + /** 当前类目素材列表(pageNo 翻页累计) */ + const materials = ref([]) + /** 当前类目素材总数(控制「加载更多」显隐) */ + const materialTotal = ref(0) + /** 素材加载中(防并发) */ + const materialLoading = ref(false) + /** 当前类目筛选(undefined=全部六类) */ + const activeCategory = ref(undefined) + /** 当前已加载页码(从 0 起,0=未加载;翻页 +1) */ + const materialPageNo = ref(0) + /** 上传中(上传字节 + 登记期间禁用入口,防重复提交) */ + const uploading = ref(false) + + /** 单页条数(贴契约默认) */ + const MATERIAL_PAGE_SIZE = 12 + + /** 是否还有更多素材(已加载数 < 总数) */ + function hasMoreMaterials(): boolean { + return materials.value.length < materialTotal.value + } + + /** + * 内部:拉一页素材(首屏或翻页)。 + * @param reset true=首屏(清空,用当前 activeCategory,从第 1 页);false=追加下一页 + */ + async function fetchMaterialPage(reset: boolean) { + if (materialLoading.value) return + if (!reset && !hasMoreMaterials()) return + materialLoading.value = true + try { + const pageNo = reset ? 1 : materialPageNo.value + 1 + const resp = await studioApi.getAssetList({ + category: activeCategory.value, + pageNo, + pageSize: MATERIAL_PAGE_SIZE, + }) + materials.value = reset ? resp.list : [...materials.value, ...resp.list] + materialTotal.value = resp.total + materialPageNo.value = pageNo + } finally { + materialLoading.value = false + } + } + + /** + * 加载素材首屏(进入页面 / 切换类目时调用)。 + * @param category 类目筛选(undefined=全部) + */ + async function loadMaterials(category?: MaterialCategory) { + activeCategory.value = category + materials.value = [] + materialTotal.value = 0 + materialPageNo.value = 0 + await fetchMaterialPage(true) + } + + /** 加载下一页素材(上拉/点击加载更多) */ + async function loadMoreMaterials() { + await fetchMaterialPage(false) + } + + /** + * 上传并登记一条素材(U6 上传链:infra 上传字节拿 url → studio 按引用登记 → 刷新当前类目首屏)。 + * 失败由 request.ts 统一 Toast 后向上抛;调用方据此停在选择态。 + * @param file 浏览器 File 对象 + * @param category 目标六类类目 + * @returns 登记成功的素材引用项 + */ + async function uploadAndRegister(file: File, category: MaterialCategory) { + if (uploading.value) return + uploading.value = true + try { + // 1) 字节先上共享文件服务拿 url(studio 不持字节) + const url = await studioApi.uploadFile(file) + // 2) 按引用登记到本人素材库(ref 主用 url,透传 name/size/mime 供配额/审计与类目族自检) + const body: StudioMaterialRegisterReq = { + category, + ref: url, + url, + name: file.name, + sizeBytes: file.size, + mimeType: file.type || undefined, + } + const item = await studioApi.registerAsset(body) + // 3) 登记成功 → 刷新当前类目首屏(新上传在前) + await loadMaterials(activeCategory.value) + return item + } finally { + uploading.value = false + } + } + + /** + * 选用素材入草稿会话 assetContext(U6 select-into-draft)。 + * @param sessionId 目标草稿会话 ID + * @param materialIds 选中素材 ID 列表 + * @returns 写入后草稿完整 assetContext + */ + async function selectIntoDraft(sessionId: number, materialIds: number[]) { + return studioApi.selectAssetIntoDraft({ sessionId, materialIds }) + } + + /* ------------------------------- 玩法模板中心 ------------------------------- */ + + /** 玩法模板列表 */ + const templates = ref([]) + /** 模板加载中 */ + const templatesLoading = ref(false) + + /** 加载玩法模板列表(失败静默清空,由视图空态兜底) */ + async function loadTemplates() { + if (templatesLoading.value) return + templatesLoading.value = true + try { + templates.value = await studioApi.getTemplates() + } catch { + // 模板列表拉取失败不阻断(视图 EmptyState 兜底 + 重试入口) + templates.value = [] + } finally { + templatesLoading.value = false + } + } + + /* ------------------------------- 创作者公开主页(P-OPN-08) ------------------------------- */ + + /** 创作者公开统计汇总(已发布数/累计试玩/累计点赞) */ + const creatorSummary = ref(null) + /** 创作者公开作品列表(pageNo 翻页累计,仅 status==4) */ + const creatorWorks = ref([]) + /** 创作者公开作品总数 */ + const creatorTotal = ref(0) + /** 已加载页码(从 0 起) */ + const creatorPageNo = ref(0) + /** 创作者主页加载中(汇总 + 作品首屏共用一个加载态) */ + const creatorLoading = ref(false) + /** 当前查看的创作者 ID(翻页校验,换人即重置) */ + const creatorId = ref(null) + + /** 单页条数 */ + const CREATOR_PAGE_SIZE = 12 + + /** 是否还有更多公开作品 */ + function hasMoreWorks(): boolean { + return creatorWorks.value.length < creatorTotal.value + } + + /** + * 加载创作者公开主页首屏(汇总 + 作品第 1 页)。 + * 汇总失败不阻断作品(统计卡单独兜底为 0);作品失败由拦截器 Toast。 + * @param id 创作者 ID + */ + async function loadCreatorHome(id: number) { + if (creatorLoading.value) return + creatorLoading.value = true + // 换创作者:重置全部公开态,避免闪现上一位数据 + creatorId.value = id + creatorSummary.value = null + creatorWorks.value = [] + creatorTotal.value = 0 + creatorPageNo.value = 0 + try { + // 汇总与作品首屏并行;汇总失败降级(统计卡兜 0),作品失败上抛由视图空态/拦截器处理 + const [summaryRes, worksRes] = await Promise.allSettled([ + studioApi.getCreatorPublicSummary(id), + studioApi.getCreatorPublicProjects(id, { pageNo: 1, pageSize: CREATOR_PAGE_SIZE }), + ]) + if (summaryRes.status === 'fulfilled') { + creatorSummary.value = summaryRes.value + } else { + // 统计聚合失败:兜底为 0(不阻断作品展示,统计是辅助信息) + creatorSummary.value = { + creatorId: id, + publishedCount: 0, + totalPlayCount: 0, + totalLikeCount: 0, + } + } + if (worksRes.status === 'fulfilled') { + creatorWorks.value = worksRes.value.list + creatorTotal.value = worksRes.value.total + creatorPageNo.value = 1 + } else { + // 作品首屏失败:保留空列表(视图 EmptyState 兜底 + 重试);拦截器已 Toast + throw worksRes.reason + } + } finally { + creatorLoading.value = false + } + } + + /** 加载下一页公开作品(上拉/点击加载更多) */ + async function loadMoreWorks() { + if (creatorLoading.value || creatorId.value == null || !hasMoreWorks()) return + creatorLoading.value = true + try { + const next = creatorPageNo.value + 1 + const resp = await studioApi.getCreatorPublicProjects(creatorId.value, { + pageNo: next, + pageSize: CREATOR_PAGE_SIZE, + }) + creatorWorks.value = [...creatorWorks.value, ...resp.list] + creatorTotal.value = resp.total + creatorPageNo.value = next + } finally { + creatorLoading.value = false + } + } + + return { + // 素材中心 + materials, + materialTotal, + materialLoading, + activeCategory, + uploading, + hasMoreMaterials, + loadMaterials, + loadMoreMaterials, + uploadAndRegister, + selectIntoDraft, + // 玩法模板中心 + templates, + templatesLoading, + loadTemplates, + // 创作者公开主页 + creatorSummary, + creatorWorks, + creatorTotal, + creatorLoading, + creatorId, + hasMoreWorks, + loadCreatorHome, + loadMoreWorks, + } +}) diff --git a/game-studio/src/views/play/Play.vue b/game-studio/src/views/play/Play.vue index 1292e9d6..8d62211c 100644 --- a/game-studio/src/views/play/Play.vue +++ b/game-studio/src/views/play/Play.vue @@ -15,16 +15,19 @@ * traceId 贯穿:本页生成一个 traceId,透传给 GamePlayer 与 session.start,使 * 加载→运行→会话→遥测同一条链路可对账(与 runtime.yaml traceId 语义一致)。 */ -import { ref, computed, onBeforeUnmount } from 'vue' +import { ref, computed, onBeforeUnmount, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import GamePlayer from '../../host/GamePlayer.vue' import { LoadingBar, EmptyState } from '../../components' import Icon from '../../components/Icon.vue' +import AgeRatingBadge from '../../components/AgeRatingBadge.vue' import { useSessionStore } from '../../store/session' +import { getRating } from '../../api/studio' import { track, perfBeacon, type TelemetryEventName } from '../../telemetry' import entryZh from '../../locales/entry.zh' import entryEn from '../../locales/entry.en' +import type { AgeRating } from '../../api/types' // 页域 i18n:play.* 局部 scope 注入(不污染全局 i18n/index.ts); // 通用词(返回/重试)走 common.*,经 vue-i18n 根回落仍可 t() 取到。 @@ -57,6 +60,29 @@ const hasVersion = computed(() => !!props.versionId && props.versionId !== '') const numGameId = computed(() => Number(props.gameId) || 0) const numVersionId = computed(() => Number(props.versionId) || 0) +/** + * P-ACC-03 适龄分级(试玩页无 project VO,回读 compliance/rating/get 拿分级)。 + * null=无分级数据(无记录/拉取失败),徽标组件据此不渲染(诚实降级,不阻断试玩)。 + */ +const rating = ref(null) + +/** 拉取本游戏内容分级(失败/无记录静默:不渲染徽标,不影响试玩主流程) */ +async function fetchRating(): Promise { + if (numGameId.value <= 0) return + try { + const res = await getRating(numGameId.value, numVersionId.value) + rating.value = res.rating ?? null + } catch { + // 无分级记录(COMPLIANCE_RATING_NOT_FOUND)或网络异常:不展示徽标 + rating.value = null + } +} + +// 进入即拉分级(独立于宿主加载,徽标就绪即显示在顶栏) +onMounted(() => { + void fetchRating() +}) + /* ============================================================================ * GamePlayer 生命周期回调(与 A2 emits 对齐) * ========================================================================== */ @@ -184,6 +210,8 @@ function genTraceId(): string {
+ + @@ -273,6 +301,11 @@ function genTraceId(): string { flex: 1; pointer-events: none; } +/* P-ACC-03 适龄徽标:顶栏右侧(加载态由 progress flex:1 顶右;非加载态用 margin-left:auto 顶右) */ +.play__age { + margin-left: auto; + pointer-events: none; +} /* 宿主舞台:占满全屏 */ .play__stage { diff --git a/game-studio/src/views/profile/CreatorHome.vue b/game-studio/src/views/profile/CreatorHome.vue new file mode 100644 index 00000000..62dd8938 --- /dev/null +++ b/game-studio/src/views/profile/CreatorHome.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/game-studio/src/views/project/Detail.vue b/game-studio/src/views/project/Detail.vue index a526c81f..614826d4 100644 --- a/game-studio/src/views/project/Detail.vue +++ b/game-studio/src/views/project/Detail.vue @@ -25,6 +25,7 @@ import { useProjectStore } from '../../store/project' import { EmptyState, LoadingBar, AppButton } from '../../components' import Icon from '../../components/Icon.vue' import StatusBadge from './StatusBadge.vue' +import AgeRatingBadge from '../../components/AgeRatingBadge.vue' import { getStatusMeta } from './status' import type { AgeRating, ProjectDraftReq } from '../../api/types' import projectZh from '../../locales/project.zh' @@ -194,6 +195,8 @@ onMounted(fetchDetail)

{{ isDraft ? t('detail.titleEdit') : t('detail.titleView') }}

+ + diff --git a/game-studio/src/views/project/Project.vue b/game-studio/src/views/project/Project.vue index d1e0661f..06692b0a 100644 --- a/game-studio/src/views/project/Project.vue +++ b/game-studio/src/views/project/Project.vue @@ -21,6 +21,7 @@ import { useUserStore } from '../../store/user' import { EmptyState, LoadingBar, AppButton } from '../../components' import Icon from '../../components/Icon.vue' import StatusBadge from './StatusBadge.vue' +import AgeRatingBadge from '../../components/AgeRatingBadge.vue' import { STATUS_FILTERS } from './status' import projectZh from '../../locales/project.zh' import projectEn from '../../locales/project.en' @@ -185,6 +186,8 @@ onMounted(() => {

{{ p.title }}

+ +

diff --git a/game-studio/src/views/studio/Material.vue b/game-studio/src/views/studio/Material.vue new file mode 100644 index 00000000..ced21255 --- /dev/null +++ b/game-studio/src/views/studio/Material.vue @@ -0,0 +1,490 @@ + + + + + diff --git a/game-studio/src/views/studio/Template.vue b/game-studio/src/views/studio/Template.vue new file mode 100644 index 00000000..1ef3a32d --- /dev/null +++ b/game-studio/src/views/studio/Template.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/game-studio/src/views/studio/material-meta.ts b/game-studio/src/views/studio/material-meta.ts new file mode 100644 index 00000000..5b5f6211 --- /dev/null +++ b/game-studio/src/views/studio/material-meta.ts @@ -0,0 +1,40 @@ +/** + * 素材中心展示元数据(六类类目:tab 文案 key + 图标名)。 + * + * 单一事实源:契约 studio.yaml MaterialCategoryEnum(sprite/character/effect/scene/ui/music), + * 与 src/api/types.ts 的 MaterialCategory 联合类型严格对齐,禁止发明契约外类目。 + * + * 文案 key 指向 material.* 命名空间(页域局部注入,消费方 t() 取译文); + * 图标名指向 Icon.vue 已注册 SVG(满足 no-emoji-icons 红线)。 + */ +import type { MaterialCategory } from '../../api/types' + +/** 单类目展示配置 */ +export interface MaterialCatMeta { + /** 类目枚举值(透传后端 category 参数) */ + value: MaterialCategory + /** tab 文案 i18n key(material.cat*) */ + labelKey: string + /** Icon.vue 已注册图标名(类目标识 + 素材卡占位) */ + icon: string +} + +/** 六类类目展示元数据(顺序即 tab 顺序,与契约枚举顺序一致) */ +export const MATERIAL_CATS: MaterialCatMeta[] = [ + { value: 'sprite', labelKey: 'material.catSprite', icon: 'image' }, + { value: 'character', labelKey: 'material.catCharacter', icon: 'character' }, + { value: 'effect', labelKey: 'material.catEffect', icon: 'effect' }, + { value: 'scene', labelKey: 'material.catScene', icon: 'image' }, + { value: 'ui', labelKey: 'material.catUi', icon: 'layers' }, + { value: 'music', labelKey: 'material.catMusic', icon: 'music' }, +] + +/** 类目 → 图标名(素材卡按类目取占位图标;未知兜底 image) */ +export function catIcon(category: MaterialCategory | undefined): string { + return MATERIAL_CATS.find((c) => c.value === category)?.icon ?? 'image' +} + +/** 类目 → 文案 key(素材卡类目标签;未知兜底 sprite 文案) */ +export function catLabelKey(category: MaterialCategory | undefined): string { + return MATERIAL_CATS.find((c) => c.value === category)?.labelKey ?? 'material.catSprite' +}