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>
This commit is contained in:
parent
da5ec28c49
commit
2eb507da4a
169
game-studio/src/api/studio.ts
Normal file
169
game-studio/src/api/studio.ts
Normal file
@ -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<data>。
|
||||
* - 素材库归属隔离在后端可信边界(service 按 getLoginUserId() 强制过滤),前端只带登录态 Token。
|
||||
* - 公开端点 @PermitAll:匿名可读、跨创作者读;前端无需登录态(request 拦截器有 token 则带,无亦放行)。
|
||||
* - 上传字节走 multipart(FormData),不套 JSON content-type;后端返回 CommonResult<String>(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<StudioMaterialPage> {
|
||||
return request<StudioMaterialPage>({
|
||||
url: '/app-api/studio/asset/browse',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 共享文件服务上传字节(multipart)。
|
||||
* POST /infra/file/upload(huijing-module-infra AppFileController;studio 不持字节,先拿 url 再登记)。
|
||||
* 后端 CommonResult<String>,data=可访问文件 url;拦截器解包为 string。
|
||||
* @param file 浏览器 File 对象(来自 <input type=file>)
|
||||
* @returns 文件可访问 url(同时用作素材 ref 与 url)
|
||||
*/
|
||||
export function uploadFile(file: File): Promise<string> {
|
||||
const form = new FormData()
|
||||
// 后端 AppFileUploadReqVO 表单字段名 = file(multipart 表单项)
|
||||
form.append('file', file)
|
||||
return request<string>({
|
||||
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<StudioAssetContextItem> {
|
||||
return request<StudioAssetContextItem>({
|
||||
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<StudioAssetContextItem[]> {
|
||||
return request<StudioAssetContextItem[]>({
|
||||
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<StudioTemplate[]> {
|
||||
return request<StudioTemplate[]>({
|
||||
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<PublicCreatorProjectPage> {
|
||||
return request<PublicCreatorProjectPage>({
|
||||
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<PublicCreatorSummary> {
|
||||
return request<PublicCreatorSummary>({
|
||||
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<ContentRating> {
|
||||
return request<ContentRating>({
|
||||
url: '/app-api/compliance/rating/get',
|
||||
method: 'get',
|
||||
params: { gameId, versionId },
|
||||
})
|
||||
}
|
||||
|
||||
/** 内容分级取值再导出(便于消费方按 AgeRating 收口类型) */
|
||||
export type { AgeRating }
|
||||
@ -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<PublicCreatorProjectRespVO>,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
|
||||
}
|
||||
|
||||
109
game-studio/src/components/AgeRatingBadge.vue
Normal file
109
game-studio/src/components/AgeRatingBadge.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AgeRatingBadge —— 适龄分级徽标(P-ACC-03,共享原子组件)。
|
||||
* ----------------------------------------------------------------------------
|
||||
* 单一事实源 = 契约 ageRating 四级枚举(project.yaml ProjectDraftReqVO.ageRating /
|
||||
* compliance.yaml ContentRatingRespVO.rating):all / 8+ / 12+ / 16+。
|
||||
* 与 src/api/types.ts 的 AgeRating 联合类型严格对齐,禁止发明契约外取值。
|
||||
*
|
||||
* 职责(纯展示 + 事件无):
|
||||
* - 接收 rating(AgeRating | null | undefined),按四级渲染统一圆角徽标;
|
||||
* - rating 缺省(null/undefined)时不渲染(v-if 由调用方控制,本组件再兜底返回空)。
|
||||
* - 颜色按分级语义递进:all 全年龄 success(最宽松)/ 8+ accent / 12+ warning / 16+ danger(最严)。
|
||||
*
|
||||
* 设计体系(HJ-FE-DS-001):
|
||||
* - 复用语义 token(--success/--accent/--warning/--danger + 对应前景),统一圆角 --radius-sm(禁药丸/正圆);
|
||||
* - 文案走页域局部 i18n 注入(age 命名空间,与 biz/profile 同范式),仅「全年龄」需翻译,8+/12+/16+ 为通用符号直接展示;
|
||||
* - SVG 图标 shield(lucide 取向),禁 emoji/Unicode 字形。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from './Icon.vue'
|
||||
import type { AgeRating } from '../api/types'
|
||||
import ageZh from '../locales/age.zh'
|
||||
import ageEn from '../locales/age.en'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 适龄分级(契约四级枚举;null/undefined=无分级数据,不渲染) */
|
||||
rating?: AgeRating | null
|
||||
/** 尺寸:sm 紧凑(卡片角标)/ md 默认(详情/账号行) */
|
||||
size?: 'sm' | 'md'
|
||||
/** 是否显示盾牌图标(卡片角标可关,节省空间) */
|
||||
icon?: boolean
|
||||
}>(),
|
||||
{ rating: null, size: 'md', icon: true },
|
||||
)
|
||||
|
||||
// 页域 i18n:age.* 局部注入(不污染全局 i18n/index.ts);fallbackRoot 仍可取 common.*
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
messages: { 'zh-CN': ageZh, 'en-US': ageEn },
|
||||
})
|
||||
|
||||
/**
|
||||
* 四级分级 → 展示元数据(颜色语义递进 + 文案 i18n key + 前景令牌)。
|
||||
* 仅 all 用 i18n 译文(全年龄/All ages),8+/12+/16+ 为通用符号直接展示(labelKey 为 null)。
|
||||
*/
|
||||
const META: Record<AgeRating, { color: string; on: string; labelKey: string | null }> = {
|
||||
all: { color: 'var(--success)', on: 'var(--on-accent)', labelKey: 'age.all' },
|
||||
'8+': { color: 'var(--accent)', on: 'var(--on-accent)', labelKey: null },
|
||||
'12+': { color: 'var(--warning)', on: 'var(--on-accent)', labelKey: null },
|
||||
'16+': { color: 'var(--danger)', on: 'var(--on-danger)', labelKey: null },
|
||||
}
|
||||
|
||||
/** 当前分级元数据(rating 合法才有;非法/缺省返回 null → 不渲染) */
|
||||
const meta = computed(() => (props.rating ? (META[props.rating] ?? null) : null))
|
||||
|
||||
/** 徽标文案:all 取译文,其余直接展示原符号(8+/12+/16+) */
|
||||
const label = computed(() => {
|
||||
if (!props.rating || !meta.value) return ''
|
||||
return meta.value.labelKey ? t(meta.value.labelKey) : props.rating
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- rating 缺省/非法不渲染(调用方亦可外层 v-if 控制,此处再兜底) -->
|
||||
<span
|
||||
v-if="meta"
|
||||
class="age-badge"
|
||||
:class="`age-badge--${size}`"
|
||||
:style="{ color: meta.on, background: meta.color, borderColor: meta.color }"
|
||||
:aria-label="t('age.aria', { level: label })"
|
||||
:title="t('age.aria', { level: label })"
|
||||
>
|
||||
<Icon v-if="icon" name="shield" :size="size === 'sm' ? 11 : 13" class="age-badge__ico" />
|
||||
<span class="age-badge__text">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 统一圆角矩形徽标(设计体系禁药丸/正圆;半径 --radius-sm 按尺寸比例) */
|
||||
.age-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.age-badge--md {
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.age-badge--sm {
|
||||
height: 17px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.age-badge__ico {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.age-badge__text {
|
||||
/* 数字符号(8+/12+)顶对齐更稳 */
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
</style>
|
||||
@ -32,6 +32,21 @@ const PATHS: Record<string, string> = {
|
||||
warning: '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>',
|
||||
close: '<path d="M18 6 6 18M6 6l12 12"/>',
|
||||
ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>',
|
||||
// —— U9 新增(素材中心/玩法模板中心/创作者公开主页 + P-ACC-03 适龄徽标)——
|
||||
// 盾牌(适龄分级徽标 P-ACC-03,lucide shield)
|
||||
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||
// 图片(素材中心 sprite/scene/ui 类目 + 素材卡占位,lucide image)
|
||||
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
|
||||
// 上传(素材上传入口,lucide upload)
|
||||
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>',
|
||||
// 图层/品类(玩法模板中心,lucide layers)
|
||||
layers: '<path d="m12 2 9 5-9 5-9-5 9-5z"/><path d="m21 12-9 5-9-5M21 17l-9 5-9-5"/>',
|
||||
// 音乐(素材中心 music 类目,lucide music)
|
||||
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
||||
// 角色(素材中心 character 类目,lucide user-round 复用 me 变体)
|
||||
character: '<circle cx="12" cy="8" r="4"/><path d="M5.5 21a7 7 0 0 1 13 0"/>',
|
||||
// 特效(素材中心 effect 类目,复用 spark 同形,lucide sparkles 简化)
|
||||
effect: '<path d="m12 3 1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8z"/><path d="M19 14v3M17.5 15.5h3"/>',
|
||||
}
|
||||
|
||||
// 找不到的图标渲染空 svg(不报错)
|
||||
|
||||
11
game-studio/src/locales/age.en.ts
Normal file
11
game-studio/src/locales/age.en.ts
Normal file
@ -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}',
|
||||
},
|
||||
}
|
||||
13
game-studio/src/locales/age.zh.ts
Normal file
13
game-studio/src/locales/age.zh.ts
Normal file
@ -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}',
|
||||
},
|
||||
}
|
||||
64
game-studio/src/locales/studio.en.ts
Normal file
64
game-studio/src/locales/studio.en.ts
Normal file
@ -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',
|
||||
},
|
||||
}
|
||||
79
game-studio/src/locales/studio.zh.ts
Normal file
79
game-studio/src/locales/studio.zh.ts
Normal file
@ -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}',
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
|
||||
261
game-studio/src/mock/studio.ts
Normal file
261
game-studio/src/mock/studio.ts
Normal file
@ -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<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 },
|
||||
]
|
||||
@ -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 沉浸。
|
||||
|
||||
267
game-studio/src/store/studio.ts
Normal file
267
game-studio/src/store/studio.ts
Normal file
@ -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<StudioMaterial[]>([])
|
||||
/** 当前类目素材总数(控制「加载更多」显隐) */
|
||||
const materialTotal = ref<number>(0)
|
||||
/** 素材加载中(防并发) */
|
||||
const materialLoading = ref<boolean>(false)
|
||||
/** 当前类目筛选(undefined=全部六类) */
|
||||
const activeCategory = ref<MaterialCategory | undefined>(undefined)
|
||||
/** 当前已加载页码(从 0 起,0=未加载;翻页 +1) */
|
||||
const materialPageNo = ref<number>(0)
|
||||
/** 上传中(上传字节 + 登记期间禁用入口,防重复提交) */
|
||||
const uploading = ref<boolean>(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<StudioTemplate[]>([])
|
||||
/** 模板加载中 */
|
||||
const templatesLoading = ref<boolean>(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<PublicCreatorSummary | null>(null)
|
||||
/** 创作者公开作品列表(pageNo 翻页累计,仅 status==4) */
|
||||
const creatorWorks = ref<PublicCreatorProject[]>([])
|
||||
/** 创作者公开作品总数 */
|
||||
const creatorTotal = ref<number>(0)
|
||||
/** 已加载页码(从 0 起) */
|
||||
const creatorPageNo = ref<number>(0)
|
||||
/** 创作者主页加载中(汇总 + 作品首屏共用一个加载态) */
|
||||
const creatorLoading = ref<boolean>(false)
|
||||
/** 当前查看的创作者 ID(翻页校验,换人即重置) */
|
||||
const creatorId = ref<number | null>(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,
|
||||
}
|
||||
})
|
||||
@ -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<AgeRating | null>(null)
|
||||
|
||||
/** 拉取本游戏内容分级(失败/无记录静默:不渲染徽标,不影响试玩主流程) */
|
||||
async function fetchRating(): Promise<void> {
|
||||
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 {
|
||||
<div v-if="isLoading && !errorMsg" class="play__progress">
|
||||
<LoadingBar indeterminate color="var(--accent)" />
|
||||
</div>
|
||||
<!-- P-ACC-03 适龄徽标(compliance/rating/get 回读;无分级数据则不渲染) -->
|
||||
<AgeRatingBadge v-if="rating" class="play__age" :rating="rating" size="sm" />
|
||||
</header>
|
||||
|
||||
<!-- 缺 versionId:无法即玩,错误兜底。emoji ⚠ 改 <Icon name="warning">。 -->
|
||||
@ -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 {
|
||||
|
||||
324
game-studio/src/views/profile/CreatorHome.vue
Normal file
324
game-studio/src/views/profile/CreatorHome.vue
Normal file
@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* CreatorHome.vue —— 创作者公开主页(路由 /creator/:creatorId,P-OPN-08,PUBLIC 无需登录)。
|
||||
* ----------------------------------------------------------------------------
|
||||
* 职责(贴 P-OPN-08 公开聚合契约;创始人默认语义:公开=status==4 仅此一态,公开统计无 PII):
|
||||
* 1. 创作者名 + 公开统计卡(publishedCount / totalPlayCount / totalLikeCount)。
|
||||
* → GET /app-api/telemetry/public/creator/{id}/summary(@PermitAll)。
|
||||
* 2. 已发布作品网格(每卡:封面/标题/试玩/点赞 + P-ACC-03 适龄徽标),分页。
|
||||
* → GET /app-api/project/public/creator/{id}(@PermitAll,仅 status==4)。
|
||||
* 3. 点开作品卡 → 跳试玩 /play/:gameId(公开作品,零门槛试玩)。
|
||||
*
|
||||
* 契约纪律:仅消费 useStudioStore(→ api/studio → mock,严格贴 P-OPN-08 契约),不直连后端、不发明字段。
|
||||
* 公开路由:本页 NOT requiresAuth(匿名/跨创作者可访问);统计失败 store 内已降级兜 0,作品失败走 EmptyState。
|
||||
*/
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useStudioStore } from '../../store/studio'
|
||||
import { LoadingBar, EmptyState } from '../../components'
|
||||
import Icon from '../../components/Icon.vue'
|
||||
import AgeRatingBadge from '../../components/AgeRatingBadge.vue'
|
||||
import studioZh from '../../locales/studio.zh'
|
||||
import studioEn from '../../locales/studio.en'
|
||||
import type { PublicCreatorProject } from '../../api/types'
|
||||
|
||||
/** 路由 props(creatorId 由 props:true 注入,string) */
|
||||
const props = defineProps<{ creatorId: string }>()
|
||||
|
||||
// 页域 i18n:creator.* 局部注入;common.* 经 fallbackRoot 仍可取
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
messages: { 'zh-CN': studioZh, 'en-US': studioEn },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const studioStore = useStudioStore()
|
||||
const { creatorSummary, creatorWorks, creatorTotal, creatorLoading } = storeToRefs(studioStore)
|
||||
|
||||
/** 数值化 creatorId(契约 int64) */
|
||||
const numCreatorId = computed(() => Number(props.creatorId) || 0)
|
||||
|
||||
/** 创作者名(取首条作品的 creatorName;缺省兜底文案) */
|
||||
const creatorName = computed(() => creatorWorks.value[0]?.creatorName || t('creator.fallbackName'))
|
||||
|
||||
/** 是否还有更多公开作品 */
|
||||
const hasMore = computed(() => creatorWorks.value.length < creatorTotal.value)
|
||||
|
||||
/** 首屏加载(汇总 + 作品第 1 页) */
|
||||
function load(): void {
|
||||
void studioStore.loadCreatorHome(numCreatorId.value)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
// creatorId 路由变化(同组件切换创作者)时重载
|
||||
watch(numCreatorId, load)
|
||||
|
||||
/** 加载更多作品 */
|
||||
function onLoadMore(): void {
|
||||
void studioStore.loadMoreWorks()
|
||||
}
|
||||
|
||||
/** 点开作品卡 → 跳试玩(公开作品零门槛;仅有 gameId,versionId 由试玩页/后端解析当前版本) */
|
||||
function onOpen(work: PublicCreatorProject): void {
|
||||
router.push(`/play/${work.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="creator">
|
||||
<!-- 顶部返回 + 创作者名 -->
|
||||
<header class="creator__head">
|
||||
<button class="creator__back" :aria-label="t('common.action.back')" @click="router.back()">
|
||||
<Icon name="back" :size="22" />
|
||||
</button>
|
||||
<h1 class="creator__name">{{ creatorName }}</h1>
|
||||
</header>
|
||||
|
||||
<!-- 公开统计卡(summary 就绪即显示;失败 store 已降级兜 0) -->
|
||||
<section v-if="creatorSummary" class="creator__stats">
|
||||
<div class="creator__stat">
|
||||
<span class="creator__stat-val">{{ creatorSummary.publishedCount }}</span>
|
||||
<span class="creator__stat-label">{{ t('creator.statPublished') }}</span>
|
||||
</div>
|
||||
<div class="creator__stat">
|
||||
<span class="creator__stat-val">{{ creatorSummary.totalPlayCount }}</span>
|
||||
<span class="creator__stat-label">{{ t('creator.statPlay') }}</span>
|
||||
</div>
|
||||
<div class="creator__stat">
|
||||
<span class="creator__stat-val">{{ creatorSummary.totalLikeCount }}</span>
|
||||
<span class="creator__stat-label">{{ t('creator.statLike') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 作品区标题 -->
|
||||
<h2 class="creator__works-title">{{ t('creator.worksTitle') }}</h2>
|
||||
|
||||
<!-- 首屏加载 -->
|
||||
<div v-if="creatorLoading && creatorWorks.length === 0" class="creator__loading">
|
||||
<LoadingBar indeterminate />
|
||||
</div>
|
||||
|
||||
<!-- 空态(无公开作品) -->
|
||||
<EmptyState
|
||||
v-else-if="!creatorLoading && creatorWorks.length === 0"
|
||||
icon-name="projects"
|
||||
:title="t('creator.emptyTitle')"
|
||||
:description="t('creator.emptyDesc')"
|
||||
/>
|
||||
|
||||
<!-- 作品网格 -->
|
||||
<main v-else class="creator__grid">
|
||||
<button
|
||||
v-for="work in creatorWorks"
|
||||
:key="work.id"
|
||||
class="creator__card"
|
||||
@click="onOpen(work)"
|
||||
>
|
||||
<!-- 封面:有 coverUrl 用图,否则标题底纹兜底 -->
|
||||
<div
|
||||
class="creator__cover"
|
||||
:style="work.coverUrl ? { backgroundImage: `url(${work.coverUrl})` } : undefined"
|
||||
>
|
||||
<span v-if="!work.coverUrl" class="creator__cover-title">{{ work.title }}</span>
|
||||
<!-- P-ACC-03 适龄徽标(公开作品 VO 携带 ageRating;缺省则组件内不渲染) -->
|
||||
<AgeRatingBadge
|
||||
v-if="work.ageRating"
|
||||
class="creator__age"
|
||||
:rating="work.ageRating"
|
||||
size="sm"
|
||||
:icon="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="creator__info">
|
||||
<h3 class="creator__work-title">{{ work.title }}</h3>
|
||||
<div class="creator__work-stats">
|
||||
<span class="creator__work-stat" :aria-label="t('creator.playAria', { n: work.playCount ?? 0 })">
|
||||
<Icon name="play" :size="12" /> {{ work.playCount ?? 0 }}
|
||||
</span>
|
||||
<span class="creator__work-stat" :aria-label="t('creator.likeAria', { n: work.likeCount ?? 0 })">
|
||||
<Icon name="heart" :size="12" /> {{ work.likeCount ?? 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="creatorWorks.length > 0" class="creator__more">
|
||||
<button v-if="hasMore" class="creator__more-btn" :disabled="creatorLoading" @click="onLoadMore">
|
||||
{{ creatorLoading ? t('common.state.loading') : t('creator.loadMore') }}
|
||||
</button>
|
||||
<span v-else class="creator__nomore">{{ t('creator.noMore') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.creator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 顶部 */
|
||||
.creator__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 14px 12px;
|
||||
}
|
||||
.creator__back {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
}
|
||||
.creator__name {
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 公开统计卡(三栏) */
|
||||
.creator__stats {
|
||||
display: flex;
|
||||
margin: 0 14px 16px;
|
||||
padding: 16px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.creator__stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.creator__stat-val {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
}
|
||||
.creator__stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.creator__works-title {
|
||||
margin: 0;
|
||||
padding: 0 16px 10px;
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.creator__loading {
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
/* 作品网格(两列,宽屏三列) */
|
||||
.creator__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
@media (min-width: 420px) {
|
||||
.creator__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
.creator__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
.creator__cover {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
background-color: var(--panel-2);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
.creator__cover-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--ink);
|
||||
text-shadow: 0 1px 8px color-mix(in srgb, #000 40%, transparent);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
/* 适龄徽标定位(封面右上角) */
|
||||
.creator__age {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
.creator__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
.creator__work-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.creator__work-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.creator__work-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 加载更多 */
|
||||
.creator__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.creator__more-btn {
|
||||
height: 34px;
|
||||
padding: 0 18px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.creator__more-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.creator__nomore {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@ -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)
|
||||
<Icon name="back" :size="22" />
|
||||
</button>
|
||||
<h1 class="proj-detail__title">{{ isDraft ? t('detail.titleEdit') : t('detail.titleView') }}</h1>
|
||||
<!-- P-ACC-03 适龄徽标(Project VO ageRating;非草稿态展示已定级,草稿态以下方编辑器为准故不重复) -->
|
||||
<AgeRatingBadge v-if="current && !isDraft && current.ageRating" :rating="current.ageRating" size="sm" />
|
||||
<StatusBadge v-if="current" :status="current.status" />
|
||||
</header>
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
<div class="proj-card__info">
|
||||
<div class="proj-card__row">
|
||||
<h3 class="proj-card__title">{{ p.title }}</h3>
|
||||
<!-- P-ACC-03 适龄徽标(我的作品 Project VO ageRating;缺省不渲染) -->
|
||||
<AgeRatingBadge v-if="p.ageRating" :rating="p.ageRating" size="sm" :icon="false" />
|
||||
<StatusBadge :status="p.status" />
|
||||
</div>
|
||||
<p class="proj-card__sub">
|
||||
|
||||
490
game-studio/src/views/studio/Material.vue
Normal file
490
game-studio/src/views/studio/Material.vue
Normal file
@ -0,0 +1,490 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Material.vue —— 素材中心(路由 /studio/material,U6 R-MAT)。
|
||||
* ----------------------------------------------------------------------------
|
||||
* 职责(贴 contracts/api-schemas/studio.yaml 素材库三端点):
|
||||
* 1. 六类类目 tab(sprite/character/effect/scene/ui/music)+「全部」→ studioStore.loadMaterials 浏览(owner-isolated 分页)。
|
||||
* 2. 上传素材:选文件 → POST /infra/file/upload 拿 url → POST /app-api/studio/asset 按引用登记(studioStore.uploadAndRegister)。
|
||||
* 3. 选用入草稿:多选素材 → POST /app-api/studio/asset/select 写入草稿会话 assetContext(需 ?sessionId= 上下文)。
|
||||
* 无 sessionId(非创作流进入)时禁用「带入草稿」,仅作素材浏览/管理(诚实降级,给提示)。
|
||||
*
|
||||
* 契约纪律:仅消费 useStudioStore(→ api/studio → mock 中间件,严格贴 studio.yaml),不直连后端、不发明端点。
|
||||
* 边缘失败:上传/浏览/选用失败由 request.ts 统一 Toast;本页对空态走 EmptyState、对无 session 走禁用提示。
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { showToast } from 'vant'
|
||||
import { useStudioStore } from '../../store/studio'
|
||||
import { LoadingBar, EmptyState, AppButton } from '../../components'
|
||||
import Icon from '../../components/Icon.vue'
|
||||
import { MATERIAL_CATS, catIcon } from './material-meta'
|
||||
import studioZh from '../../locales/studio.zh'
|
||||
import studioEn from '../../locales/studio.en'
|
||||
import type { MaterialCategory, StudioMaterial } from '../../api/types'
|
||||
|
||||
// 页域 i18n:material.* 局部注入(不污染全局);common.* 经 fallbackRoot 仍可取
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
messages: { 'zh-CN': studioZh, 'en-US': studioEn },
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const studioStore = useStudioStore()
|
||||
const { materials, materialTotal, materialLoading, activeCategory, uploading } = storeToRefs(studioStore)
|
||||
|
||||
/** 当前选中类目(undefined=全部六类;tab 高亮用) */
|
||||
const currentCat = computed(() => activeCategory.value)
|
||||
|
||||
/**
|
||||
* 草稿会话 ID(从创作流带入:/studio/material?sessionId=123)。
|
||||
* 缺省=非创作流进入,仅浏览/管理素材,禁用「带入草稿」(诚实降级)。
|
||||
*/
|
||||
const sessionId = computed<number | undefined>(() => {
|
||||
const q = route.query.sessionId
|
||||
const n = q != null ? Number(q) : NaN
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined
|
||||
})
|
||||
|
||||
/** 上传目标类目(默认取当前 tab,全部时默认 sprite);隐藏 file input 触发用 */
|
||||
const uploadCat = ref<MaterialCategory>('sprite')
|
||||
/** 隐藏文件选择器引用 */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 已选素材 ID 集合(选用入草稿用) */
|
||||
const selectedIds = ref<Set<number>>(new Set())
|
||||
/** 选中数(按钮文案 + 禁用判定) */
|
||||
const selectedCount = computed(() => selectedIds.value.size)
|
||||
|
||||
/** 是否还有更多(加载更多按钮显隐) */
|
||||
const hasMore = computed(() => materials.value.length < materialTotal.value)
|
||||
|
||||
/* ============================================================================
|
||||
* 加载 + 类目切换
|
||||
* ========================================================================== */
|
||||
|
||||
/** 切换类目(重置选择 + 拉首屏) */
|
||||
async function switchCat(cat?: MaterialCategory): Promise<void> {
|
||||
selectedIds.value = new Set()
|
||||
// 上传目标类目跟随当前 tab(全部时默认 sprite)
|
||||
uploadCat.value = cat ?? 'sprite'
|
||||
await studioStore.loadMaterials(cat)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void switchCat(undefined)
|
||||
})
|
||||
|
||||
/** 加载更多(翻页) */
|
||||
function onLoadMore(): void {
|
||||
void studioStore.loadMoreMaterials()
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 上传:选文件 → infra 上传 → 登记
|
||||
* ========================================================================== */
|
||||
|
||||
/** 触发隐藏 file input(上传到当前 uploadCat 类目) */
|
||||
function triggerUpload(): void {
|
||||
// 全部 tab 下默认 sprite;具体 tab 下用该类目
|
||||
uploadCat.value = currentCat.value ?? 'sprite'
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
/** 文件选中 → 上传并登记(成功刷新当前类目首屏) */
|
||||
async function onFileChange(ev: Event): Promise<void> {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// 重置 input 以便同一文件可再次触发 change
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
try {
|
||||
await studioStore.uploadAndRegister(file, uploadCat.value)
|
||||
showToast({ message: t('material.uploadDone'), type: 'success' })
|
||||
} catch {
|
||||
// request.ts 已统一 Toast 错误码;此处补一条上传语义提示
|
||||
showToast({ message: t('material.uploadFail'), type: 'fail' })
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 选用入草稿
|
||||
* ========================================================================== */
|
||||
|
||||
/** 切换某素材选中态 */
|
||||
function toggleSelect(m: StudioMaterial): void {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(m.id)) next.delete(m.id)
|
||||
else next.add(m.id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
/** 是否已选中 */
|
||||
function isSelected(m: StudioMaterial): boolean {
|
||||
return selectedIds.value.has(m.id)
|
||||
}
|
||||
|
||||
/** 带入草稿(需 sessionId 上下文 + 至少选 1 个) */
|
||||
async function onUseInDraft(): Promise<void> {
|
||||
if (sessionId.value === undefined) {
|
||||
showToast(t('material.noSession'))
|
||||
return
|
||||
}
|
||||
if (selectedCount.value === 0) {
|
||||
showToast(t('material.selectEmpty'))
|
||||
return
|
||||
}
|
||||
await studioStore.selectIntoDraft(sessionId.value, [...selectedIds.value])
|
||||
showToast({ message: t('material.selectDone'), type: 'success' })
|
||||
selectedIds.value = new Set()
|
||||
// 带入后回创作页(携带 sessionId 便于续作)
|
||||
router.push({ path: '/create', query: { sessionId: String(sessionId.value) } })
|
||||
}
|
||||
|
||||
/** 素材是否有可展示的真实图片预览(图类 + 有 url + 非 music) */
|
||||
function hasPreview(m: StudioMaterial): boolean {
|
||||
return m.category !== 'music' && !!m.url
|
||||
}
|
||||
|
||||
/** 字节 → KB 文案 */
|
||||
function sizeKb(bytes?: number): string {
|
||||
if (!bytes) return ''
|
||||
return t('material.sizeKb', { n: Math.max(1, Math.round(bytes / 1024)) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="material">
|
||||
<!-- 顶部返回 + 标题 -->
|
||||
<header class="material__head">
|
||||
<button class="material__back" :aria-label="t('common.action.back')" @click="router.back()">
|
||||
<Icon name="back" :size="22" />
|
||||
</button>
|
||||
<div class="material__head-text">
|
||||
<h1 class="material__title">{{ t('material.title') }}</h1>
|
||||
<p class="material__subtitle">{{ t('material.subtitle') }}</p>
|
||||
</div>
|
||||
<!-- 上传入口(loading 时禁用,防重复提交) -->
|
||||
<button class="material__upload" :disabled="uploading" @click="triggerUpload">
|
||||
<Icon name="upload" :size="16" />
|
||||
<span>{{ uploading ? t('material.uploading') : t('material.upload') }}</span>
|
||||
</button>
|
||||
<!-- 隐藏 file input(图类 image/*;music 类目放开 audio/*) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="material__file"
|
||||
type="file"
|
||||
:accept="uploadCat === 'music' ? 'audio/*' : 'image/*'"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<!-- 六类类目 tab(+ 全部) -->
|
||||
<nav class="material__tabs">
|
||||
<button
|
||||
class="material__tab"
|
||||
:class="{ 'is-active': currentCat == null }"
|
||||
@click="switchCat(undefined)"
|
||||
>
|
||||
{{ t('material.catAll') }}
|
||||
</button>
|
||||
<button
|
||||
v-for="c in MATERIAL_CATS"
|
||||
:key="c.value"
|
||||
class="material__tab"
|
||||
:class="{ 'is-active': currentCat === c.value }"
|
||||
@click="switchCat(c.value)"
|
||||
>
|
||||
{{ t(c.labelKey) }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 计数条 -->
|
||||
<p v-if="materialTotal > 0" class="material__count">{{ t('material.countLabel', { n: materialTotal }) }}</p>
|
||||
|
||||
<!-- 首屏加载 -->
|
||||
<div v-if="materialLoading && materials.length === 0" class="material__loading">
|
||||
<LoadingBar indeterminate />
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<EmptyState
|
||||
v-else-if="!materialLoading && materials.length === 0"
|
||||
icon-name="image"
|
||||
:title="t('material.emptyTitle')"
|
||||
:description="t('material.emptyDesc')"
|
||||
:action-text="t('material.upload')"
|
||||
@action="triggerUpload"
|
||||
/>
|
||||
|
||||
<!-- 素材网格 -->
|
||||
<main v-else class="material__grid">
|
||||
<button
|
||||
v-for="m in materials"
|
||||
:key="m.id"
|
||||
class="material__card"
|
||||
:class="{ 'is-selected': isSelected(m) }"
|
||||
@click="toggleSelect(m)"
|
||||
>
|
||||
<!-- 预览:图类用真实图片背景;music/无 url 用类目图标兜底 -->
|
||||
<div
|
||||
class="material__thumb"
|
||||
:style="hasPreview(m) ? { backgroundImage: `url(${m.url})` } : undefined"
|
||||
>
|
||||
<Icon v-if="!hasPreview(m)" :name="catIcon(m.category)" :size="28" class="material__thumb-ico" />
|
||||
<!-- 选中勾标 -->
|
||||
<span v-if="isSelected(m)" class="material__check"><Icon name="check" :size="14" /></span>
|
||||
</div>
|
||||
<div class="material__info">
|
||||
<span class="material__name">{{ m.name }}</span>
|
||||
<span class="material__size">{{ sizeKb(m.sizeBytes) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="materials.length > 0" class="material__more">
|
||||
<button v-if="hasMore" class="material__more-btn" :disabled="materialLoading" @click="onLoadMore">
|
||||
{{ materialLoading ? t('common.state.loading') : t('material.loadMore') }}
|
||||
</button>
|
||||
<span v-else class="material__nomore">{{ t('material.noMore') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 底部选用栏(始终显示选中数;带入草稿需 sessionId) -->
|
||||
<footer class="material__footer">
|
||||
<span class="material__selected">{{ t('material.selected', { n: selectedCount }) }}</span>
|
||||
<AppButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="selectedCount === 0"
|
||||
@click="onUseInDraft"
|
||||
>
|
||||
{{ t('material.useInDraft') }}
|
||||
</AppButton>
|
||||
</footer>
|
||||
<!-- 无草稿上下文提示(仅浏览态) -->
|
||||
<p v-if="sessionId === undefined" class="material__hint">{{ t('material.noSession') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.material {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
padding-bottom: 72px; /* 给底部选用栏留位 */
|
||||
}
|
||||
|
||||
/* 顶部 */
|
||||
.material__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 14px 12px;
|
||||
}
|
||||
.material__back {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
}
|
||||
.material__head-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.material__title {
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.material__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
.material__upload {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--on-accent);
|
||||
background: var(--grad-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.material__upload:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.material__file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 类目 tab(横向滚动条) */
|
||||
.material__tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 14px 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.material__tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.material__tab {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.material__tab.is-active {
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.material__count {
|
||||
margin: 0;
|
||||
padding: 0 16px 8px;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.material__loading {
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
/* 素材网格(移动端两列,宽屏自适应三列) */
|
||||
.material__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
@media (min-width: 420px) {
|
||||
.material__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
.material__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
.material__card.is-selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent) inset;
|
||||
}
|
||||
.material__thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--panel-2);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
.material__thumb-ico {
|
||||
color: var(--muted);
|
||||
}
|
||||
.material__check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.material__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
.material__name {
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.material__size {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 加载更多 */
|
||||
.material__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.material__more-btn {
|
||||
height: 34px;
|
||||
padding: 0 18px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.material__more-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.material__nomore {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 底部选用栏(固定,移动端拇指可达) */
|
||||
.material__footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||
border-top: 1px solid var(--border);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.material__selected {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.material__hint {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 56px;
|
||||
margin: 0;
|
||||
padding: 4px 16px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
234
game-studio/src/views/studio/Template.vue
Normal file
234
game-studio/src/views/studio/Template.vue
Normal file
@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Template.vue —— 玩法模板中心(路由 /studio/template,P-TPL-01/03)。
|
||||
* ----------------------------------------------------------------------------
|
||||
* 职责(贴 contracts/api-schemas/studio.yaml /template/list):
|
||||
* 1. 拉取玩法模板列表(studioStore.loadTemplates → GET /app-api/studio/template/list,5 品类)。
|
||||
* 2. 每张卡展示:名称 + 说明 + 示例 Prompt(examplePrompt)。
|
||||
* 3. 一键「用此模板去创作」→ 跳 /create,带 templateId(+ 示例 prompt 预填,降低创作起步成本)。
|
||||
*
|
||||
* 契约纪律:仅消费 useStudioStore(→ api/studio → mock,严格贴 studio.yaml),不直连后端、不发明端点。
|
||||
* 边缘失败:列表拉取失败 store 内已静默清空,本页走 EmptyState + 重试入口(模板升级中文案)。
|
||||
*/
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useStudioStore } from '../../store/studio'
|
||||
import { LoadingBar, EmptyState } from '../../components'
|
||||
import Icon from '../../components/Icon.vue'
|
||||
import studioZh from '../../locales/studio.zh'
|
||||
import studioEn from '../../locales/studio.en'
|
||||
import type { StudioTemplate } from '../../api/types'
|
||||
|
||||
// 页域 i18n:template.* 局部注入;common.* 经 fallbackRoot 仍可取
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
messages: { 'zh-CN': studioZh, 'en-US': studioEn },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const studioStore = useStudioStore()
|
||||
const { templates, templatesLoading } = storeToRefs(studioStore)
|
||||
|
||||
onMounted(() => {
|
||||
void studioStore.loadTemplates()
|
||||
})
|
||||
|
||||
/**
|
||||
* 用此模板去创作:跳 /create,携带 templateId(创作页据此选模板)+ 示例 prompt 预填。
|
||||
* /create 为 requiresAuth 路由,未登录会被守卫导向 /login?redirect=...(带回跳,登录后回到创作页)。
|
||||
*/
|
||||
function useTemplate(tpl: StudioTemplate): void {
|
||||
router.push({
|
||||
path: '/create',
|
||||
query: {
|
||||
templateId: tpl.templateId,
|
||||
// 示例 prompt 预填(可空);创作页可据 query.prompt 预填一句话输入框
|
||||
prompt: tpl.examplePrompt ?? '',
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tpl">
|
||||
<!-- 顶部返回 + 标题 -->
|
||||
<header class="tpl__head">
|
||||
<button class="tpl__back" :aria-label="t('common.action.back')" @click="router.back()">
|
||||
<Icon name="back" :size="22" />
|
||||
</button>
|
||||
<div class="tpl__head-text">
|
||||
<h1 class="tpl__title">{{ t('template.title') }}</h1>
|
||||
<p class="tpl__subtitle">{{ t('template.subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 首屏加载 -->
|
||||
<div v-if="templatesLoading && templates.length === 0" class="tpl__loading">
|
||||
<LoadingBar indeterminate />
|
||||
</div>
|
||||
|
||||
<!-- 空态(模板升级中) -->
|
||||
<EmptyState
|
||||
v-else-if="!templatesLoading && templates.length === 0"
|
||||
icon-name="layers"
|
||||
:title="t('template.emptyTitle')"
|
||||
:description="t('template.emptyDesc')"
|
||||
:action-text="t('template.retry')"
|
||||
@action="studioStore.loadTemplates()"
|
||||
/>
|
||||
|
||||
<!-- 模板卡列表 -->
|
||||
<main v-else class="tpl__list">
|
||||
<article v-for="tpl in templates" :key="tpl.templateId" class="tpl__card">
|
||||
<!-- 封面/图标头:有 coverUrl 用图,否则品类图标兜底 -->
|
||||
<div
|
||||
class="tpl__cover"
|
||||
:style="tpl.coverUrl ? { backgroundImage: `url(${tpl.coverUrl})` } : undefined"
|
||||
>
|
||||
<Icon v-if="!tpl.coverUrl" name="layers" :size="26" class="tpl__cover-ico" />
|
||||
</div>
|
||||
<div class="tpl__body">
|
||||
<h2 class="tpl__name">{{ tpl.name }}</h2>
|
||||
<p v-if="tpl.description" class="tpl__desc">{{ tpl.description }}</p>
|
||||
<!-- 示例 Prompt(examplePrompt) -->
|
||||
<div v-if="tpl.examplePrompt" class="tpl__example">
|
||||
<span class="tpl__example-tag">{{ t('template.example') }}</span>
|
||||
<span class="tpl__example-text">{{ tpl.examplePrompt }}</span>
|
||||
</div>
|
||||
<!-- 用此模板去创作 -->
|
||||
<button class="tpl__use" @click="useTemplate(tpl)">
|
||||
<Icon name="create" :size="15" />
|
||||
<span>{{ t('template.use') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tpl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 顶部 */
|
||||
.tpl__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 14px 12px;
|
||||
}
|
||||
.tpl__back {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
}
|
||||
.tpl__head-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tpl__title {
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.tpl__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tpl__loading {
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
/* 模板卡列表(单列卡,移动优先) */
|
||||
.tpl__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 14px;
|
||||
}
|
||||
.tpl__card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.tpl__cover {
|
||||
flex: 0 0 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--panel-2);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.tpl__cover-ico {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tpl__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.tpl__name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.tpl__desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* 示例 Prompt 块 */
|
||||
.tpl__example {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: var(--panel-2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.tpl__example-tag {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.tpl__example-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* 用此模板去创作 */
|
||||
.tpl__use {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 32px;
|
||||
margin-top: 2px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--on-accent);
|
||||
background: var(--grad-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
40
game-studio/src/views/studio/material-meta.ts
Normal file
40
game-studio/src/views/studio/material-meta.ts
Normal file
@ -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'
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user