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:
lili 2026-06-18 13:40:14 -07:00
parent da5ec28c49
commit 2eb507da4a
19 changed files with 2322 additions and 3 deletions

View File

@ -0,0 +1,169 @@
/**
* studio APIU6/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 urlhuijing-module-infrastudio
* - /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
* - @PermitAllrequest token
* - multipartFormData 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/uploadhuijing-module-infra AppFileControllerstudio 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 表单字段名 = filemultipart 表单项)
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-draftR-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 ratingAgeRating
*/
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 }

View File

@ -687,3 +687,176 @@ export interface BizConfirmReq {
/** 确认的 demo 版本 ID */
demoVersionId?: number
}
/* ============================== studio.yamlU6/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
/** 示例 PromptP-CRT-11一键填创作页 */
examplePrompt?: string
}
/* ============================== compliance.yamlP-ACC-03 适龄分级只读) ============================== */
/**
* compliance.yaml ContentRatingRespVO
* GET /app-api/compliance/rating/get?gameId&versionId dataP-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 {
/** 游戏 IDproject.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
}

View File

@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* AgeRatingBadge 适龄分级徽标P-ACC-03共享原子组件
* ----------------------------------------------------------------------------
* 单一事实源 = 契约 ageRating 四级枚举project.yaml ProjectDraftReqVO.ageRating /
* compliance.yaml ContentRatingRespVO.ratingall / 8+ / 12+ / 16+
* src/api/types.ts AgeRating 联合类型严格对齐禁止发明契约外取值
*
* 职责纯展示 + 事件无
* - 接收 ratingAgeRating | 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 图标 shieldlucide 取向 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 },
)
// i18nage.* i18n/index.tsfallbackRoot common.*
const { t } = useI18n({
useScope: 'local',
messages: { 'zh-CN': ageZh, 'en-US': ageEn },
})
/**
* 四级分级 展示元数据颜色语义递进 + 文案 i18n key + 前景令牌
* all i18n 译文全年龄/All ages8+/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>

View File

@ -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-03lucide 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

View 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}',
},
}

View 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}',
},
}

View 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',
},
}

View File

@ -0,0 +1,79 @@
/**
* studio 线material / template / creator
* Material.vue / Template.vue / CreatorHome.vue useScope: 'local' i18n/index.ts
* //// common.*
*/
export default {
/* —— 素材中心(/studio/materialU6 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/templateP-TPL-01/03—— */
template: {
title: '玩法模板中心',
subtitle: '选一个玩法品类,一键带着示例去创作',
/** 模板卡 */
example: '示例',
use: '用此模板去创作',
/** 空态 */
emptyTitle: '模板升级中',
emptyDesc: '玩法模板正在接入,稍后再来看看',
retry: '刷新',
},
/* —— 创作者公开主页(/creator/:creatorIdP-OPN-08—— */
creator: {
/** 默认创作者名兜底(昵称缺省时) */
fallbackName: '创作者',
/** 统计卡 */
statPublished: '已发布',
statPlay: '总试玩',
statLike: '总点赞',
/** 作品区 */
worksTitle: '已发布作品',
loadMore: '加载更多',
noMore: '没有更多了',
/** 空态 */
emptyTitle: '还没有公开作品',
emptyDesc: 'TA 还没有发布任何作品',
/** 加载失败 */
errorTitle: '加载失败',
errorDesc: '创作者主页加载失败,请稍后重试',
/** 作品卡计数无障碍 */
playAria: '试玩 {n}',
likeAria: '点赞 {n}',
},
}

View File

@ -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
* /infrahuijing-module-infra /infra/file/uploadU9 mock */
export const MOCK_PREFIXES = ['/app-api', '/admin-api', '/infra'] as const

View File

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

View File

@ -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-08PUBLIC匿名/跨创作者可读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 沉浸。

View 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 Toaststore
*
* - materials/ materialTotal / materialLoading / activeCategory / materialPageNopageNo
* - 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) 字节先上共享文件服务拿 urlstudio 不持字节)
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
}
}
/**
* 稿 assetContextU6 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,
}
})

View File

@ -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'
// i18nplay.* 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 {

View File

@ -0,0 +1,324 @@
<script setup lang="ts">
/**
* CreatorHome.vue 创作者公开主页路由 /creator/:creatorIdP-OPN-08PUBLIC 无需登录
* ----------------------------------------------------------------------------
* 职责 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'
/** 路由 propscreatorId 由 props:true 注入string */
const props = defineProps<{ creatorId: string }>()
// i18ncreator.* 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()
}
/** 点开作品卡 → 跳试玩(公开作品零门槛;仅有 gameIdversionId 由试玩页/后端解析当前版本) */
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>

View File

@ -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>

View File

@ -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">

View File

@ -0,0 +1,490 @@
<script setup lang="ts">
/**
* Material.vue 素材中心路由 /studio/materialU6 R-MAT
* ----------------------------------------------------------------------------
* 职责 contracts/api-schemas/studio.yaml 素材库三端点
* 1. 六类类目 tabsprite/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'
// i18nmaterial.* 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>

View File

@ -0,0 +1,234 @@
<script setup lang="ts">
/**
* Template.vue 玩法模板中心路由 /studio/templateP-TPL-01/03
* ----------------------------------------------------------------------------
* 职责 contracts/api-schemas/studio.yaml /template/list
* 1. 拉取玩法模板列表studioStore.loadTemplates GET /app-api/studio/template/list5 品类
* 2. 每张卡展示名称 + 说明 + 示例 PromptexamplePrompt
* 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'
// i18ntemplate.* 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>
<!-- 示例 PromptexamplePrompt -->
<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>

View File

@ -0,0 +1,40 @@
/**
* tab key +
*
* studio.yaml MaterialCategoryEnumsprite/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 keymaterial.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'
}