feat(studio): 创作者数据看板(P-OPS-01/02)——指标卡接真 + 趋势诚实占位
- 看板视图(views/dashboard):累计播放/点赞(project /my 聚合)+已发布数(community /level)+累计奖励(community /rewards)接真;完玩率/广告收益/七日趋势=诚实占位"数据接口建设中"(契约无创作者侧读端点,不伪造)
- 入口:个人中心"创作数据"→/dashboard(requiresAuth+hideTabBar)
- 实证缺口:契约仅 admin-api 有 telemetry game-stat / ad revenue(RBAC=admin),无 /app-api 创作者侧;建议后端补 /app-api/telemetry/my/{summary,trend}(creator 归属隔离)
verify-as-you-go:vite build + vue-tsc 双绿;接的3端点逐条命中契约;3处gap逐条grep证伪;真机走查留合并点
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3147b11d60
commit
5463796f52
@ -111,6 +111,14 @@ const routes: RouteRecordRaw[] = [
|
||||
// requiresAuth:我的项目区为登录态私有数据,未真实登录跳 /login(§9.3)
|
||||
meta: { title: '我的项目', tab: 'project', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/dashboard/Dashboard.vue'), // L0 创作者数据看板(P-OPS-01/02)
|
||||
// requiresAuth:看板为创作者私有数据(归属隔离在后端),未真实登录跳 /login(§9.3);
|
||||
// hideTabBar:二级沉浸页,自带返回按钮(与 publish 同口径),归属「我的」Tab。
|
||||
meta: { title: '创作数据', tab: 'project', hideTabBar: true, requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/project/:id',
|
||||
name: 'project-detail',
|
||||
|
||||
432
game-studio/src/views/dashboard/Dashboard.vue
Normal file
432
game-studio/src/views/dashboard/Dashboard.vue
Normal file
@ -0,0 +1,432 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Dashboard.vue —— 「创作者数据看板」(P-OPS-01 数据看板 / P-OPS-02 七日留存与播放趋势)。
|
||||
*
|
||||
* 闭环职责(严格贴 contracts,只消费创作者侧 /app-api 读端点,不发明端点):
|
||||
* - 核心指标卡(P-OPS-01):
|
||||
* · 累计播放 / 累计点赞 = 聚合 projectApi.getMy() 各作品 playCount/likeCount(创作者私有,归属隔离在后端)。
|
||||
* · 已发布作品数 = communityApi.getLevel().publishedCount(计数权威源;getMy 不可得时用项目里 status=4 数兜底)。
|
||||
* · 累计奖励 = communityApi.getRewards() 各条 rewardAmount 求和(P-INC-01;当前唯一创作者可见的收益型数据)。
|
||||
* - 诚实缺口(无创作者侧 /app-api 数据源,前端只占位、不伪造):
|
||||
* · 完玩率:仅 admin 侧 telemetry/game-stat 的 completedCount 有,/app-api 无 → 占位「数据接口建设中」。
|
||||
* · 广告收益:仅 /admin-api/ad/revenue/page,且契约注明 ad/income 聚合为 Wave2 变现范围外 → 占位。
|
||||
* · 七日留存 + 播放趋势(P-OPS-02):无任何创作者侧时序读端点(GameStatRespVO 含 statDate 但在 admin-api)
|
||||
* → 趋势区整体占位「数据接口建设中」,并在交付中明确建议后端补 /app-api 创作者侧 stats 读端点。
|
||||
*
|
||||
* 入口:个人中心「创作数据」二级入口 → /dashboard(requiresAuth,私有数据)。
|
||||
*
|
||||
* 边缘失败(与既有视图同口径):
|
||||
* - 任一数据源失败由 request.ts 拦截器统一 Toast,不白屏;指标缺值兜底 0 或占位,不抛断页面。
|
||||
* - 未登录 → 路由守卫前置拦截跳登录(本页 requiresAuth)。
|
||||
*
|
||||
* 契约纪律:仅消费 projectApi(project.yaml)+ communityApi(community.yaml),均为已封装的创作者侧读端点。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as projectApi from '../../api/project'
|
||||
import * as communityApi from '../../api/community'
|
||||
import { LoadingBar } from '../../components'
|
||||
import type { Project, CommunityLevel, CommunityReward } from '../../api/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 我的项目全量(聚合累计播放/点赞用;一次性拉取,分页上限内足够看板聚合) */
|
||||
const projects = ref<Project[]>([])
|
||||
/** 创作者等级(已发布作品数权威源;失败保持 null,回退项目内 status=4 计数) */
|
||||
const level = ref<CommunityLevel | null>(null)
|
||||
/** 我的奖励触发记录(累计奖励额度求和;失败保持空数组,奖励指标显 0) */
|
||||
const rewards = ref<CommunityReward[]>([])
|
||||
/** 首屏加载中(控制骨架/加载条) */
|
||||
const loading = ref(true)
|
||||
|
||||
/** 累计播放数 = 各作品 playCount 求和(缺值按 0) */
|
||||
const totalPlay = computed(() =>
|
||||
projects.value.reduce((sum, p) => sum + (p.playCount ?? 0), 0),
|
||||
)
|
||||
/** 累计点赞数 = 各作品 likeCount 求和(缺值按 0) */
|
||||
const totalLike = computed(() =>
|
||||
projects.value.reduce((sum, p) => sum + (p.likeCount ?? 0), 0),
|
||||
)
|
||||
/**
|
||||
* 已发布作品数:优先 community.level.publishedCount(计数权威源);
|
||||
* level 未就绪时回退「项目列表里 status=4 已发布」的计数(兜底,不伪造)。
|
||||
*/
|
||||
const publishedCount = computed(() => {
|
||||
if (level.value) return level.value.publishedCount
|
||||
return projects.value.filter((p) => p.status === 4).length
|
||||
})
|
||||
/**
|
||||
* 累计奖励额度(分=现金 / 曝光量=流量包,单位混合,看板按「奖励点数」汇总展示)。
|
||||
* 说明:这是当前唯一创作者侧可见的收益型数据;真实现金收益(广告分成)端点为 Wave2,暂缺。
|
||||
*/
|
||||
const totalRewardAmount = computed(() =>
|
||||
rewards.value.reduce((sum, r) => sum + (r.rewardAmount ?? 0), 0),
|
||||
)
|
||||
|
||||
/**
|
||||
* 大数格式化:≥1万显示「x.x万」,否则原值带千分位。
|
||||
* 仅用于指标卡展示,降低视觉噪声。
|
||||
* @param n 原始数值
|
||||
*/
|
||||
function formatNum(n: number): string {
|
||||
if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + '万'
|
||||
return n.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取看板数据:项目全量 + 创作者等级 + 奖励记录。
|
||||
* 三个数据源相互独立、并发拉取;任一失败由各自 catch 静默降级(拦截器已 Toast),不互相阻断。
|
||||
*/
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
await Promise.all([
|
||||
// 我的项目全量:聚合累计播放/点赞(请求一页足量上限,看板不做分页)
|
||||
projectApi
|
||||
.getMy(undefined, { pageNo: 1, pageSize: 100 })
|
||||
.then((resp) => {
|
||||
projects.value = resp.list
|
||||
})
|
||||
.catch(() => {
|
||||
// 失败:项目聚合指标显 0(拦截器已 Toast),不白屏
|
||||
projects.value = []
|
||||
}),
|
||||
// 创作者等级:已发布作品数权威源
|
||||
communityApi
|
||||
.getLevel()
|
||||
.then((lv) => {
|
||||
level.value = lv
|
||||
})
|
||||
.catch(() => {
|
||||
// 失败:回退项目内 status=4 计数(computed 已处理 null)
|
||||
level.value = null
|
||||
}),
|
||||
// 奖励记录:累计奖励额度
|
||||
communityApi
|
||||
.getRewards()
|
||||
.then((list) => {
|
||||
rewards.value = list
|
||||
})
|
||||
.catch(() => {
|
||||
// 失败:奖励指标显 0
|
||||
rewards.value = []
|
||||
}),
|
||||
])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
/** 返回个人中心 */
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
/** 去「我的项目」查看单作品明细(看板汇总,明细仍在项目页) */
|
||||
function goProjects() {
|
||||
router.push('/project')
|
||||
}
|
||||
|
||||
/** 首屏加载 */
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dash">
|
||||
<!-- 顶部标题栏(返回 + 标题) -->
|
||||
<header class="dash__head">
|
||||
<button class="dash__back" aria-label="返回" @click="goBack">‹</button>
|
||||
<h1 class="dash__title">创作数据</h1>
|
||||
</header>
|
||||
|
||||
<!-- 首屏加载条 -->
|
||||
<div v-if="loading" class="dash__loading">
|
||||
<LoadingBar indeterminate />
|
||||
</div>
|
||||
|
||||
<main class="dash__body">
|
||||
<!-- ============ P-OPS-01 核心指标卡 ============ -->
|
||||
<section class="dash__section">
|
||||
<h2 class="dash__section-title">核心指标</h2>
|
||||
<div class="dash__grid">
|
||||
<!-- 累计播放(真实:聚合 project.playCount) -->
|
||||
<div class="metric">
|
||||
<span class="metric__icon">▶</span>
|
||||
<span class="metric__val">{{ formatNum(totalPlay) }}</span>
|
||||
<span class="metric__label">累计播放</span>
|
||||
</div>
|
||||
<!-- 累计点赞(真实:聚合 project.likeCount) -->
|
||||
<div class="metric">
|
||||
<span class="metric__icon metric__icon--pink">♥</span>
|
||||
<span class="metric__val">{{ formatNum(totalLike) }}</span>
|
||||
<span class="metric__label">累计点赞</span>
|
||||
</div>
|
||||
<!-- 已发布作品(真实:community.level.publishedCount,兜底 status=4 计数) -->
|
||||
<div class="metric">
|
||||
<span class="metric__icon metric__icon--green">◰</span>
|
||||
<span class="metric__val">{{ formatNum(publishedCount) }}</span>
|
||||
<span class="metric__label">已发布作品</span>
|
||||
</div>
|
||||
<!-- 累计奖励(真实:community.rewards 求和;现金收益端点 Wave2 暂缺,此为奖励点数) -->
|
||||
<div class="metric">
|
||||
<span class="metric__icon metric__icon--amber">★</span>
|
||||
<span class="metric__val">{{ formatNum(totalRewardAmount) }}</span>
|
||||
<span class="metric__label">累计奖励</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 待后端补端点的指标(诚实占位,不伪造数据) ============ -->
|
||||
<section class="dash__section">
|
||||
<h2 class="dash__section-title">运营指标</h2>
|
||||
<div class="dash__pending-list">
|
||||
<!-- 完玩率:仅 admin 侧 telemetry/game-stat 有 completedCount,创作者侧 /app-api 无 -->
|
||||
<div class="pending-row">
|
||||
<span class="pending-row__label">完玩率</span>
|
||||
<span class="pending-row__hint">数据接口建设中</span>
|
||||
</div>
|
||||
<!-- 广告收益:仅 /admin-api/ad/revenue/page,且契约注明 ad/income 聚合为 Wave2 变现范围外 -->
|
||||
<div class="pending-row">
|
||||
<span class="pending-row__label">广告收益</span>
|
||||
<span class="pending-row__hint">数据接口建设中</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="dash__note">
|
||||
完玩率与广告收益需后端补创作者侧聚合读端点(当前仅运营后台 admin-api 可见),稍后开放。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ============ P-OPS-02 七日留存与播放趋势(诚实占位) ============ -->
|
||||
<section class="dash__section">
|
||||
<h2 class="dash__section-title">七日趋势</h2>
|
||||
<!-- 占位图:无创作者侧时序读端点(GameStatRespVO 含 statDate 但在 admin-api)→ 不画假折线 -->
|
||||
<div class="trend-placeholder">
|
||||
<!-- 静态网格底纹,明确标注「建设中」,避免误导为真实数据 -->
|
||||
<div class="trend-placeholder__grid">
|
||||
<span v-for="i in 7" :key="i" class="trend-placeholder__bar"></span>
|
||||
</div>
|
||||
<div class="trend-placeholder__mask">
|
||||
<span class="trend-placeholder__icon">📈</span>
|
||||
<p class="trend-placeholder__title">七日留存与播放趋势</p>
|
||||
<p class="trend-placeholder__desc">数据接口建设中,开放后将展示每日播放与留存曲线</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 去项目页查看单作品明细 -->
|
||||
<div class="dash__more">
|
||||
<button class="dash__more-btn" @click="goProjects">查看单作品明细 ›</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.dash__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 14px 12px 8px;
|
||||
}
|
||||
.dash__back {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
}
|
||||
.dash__title {
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dash__loading {
|
||||
padding: 4px 16px 8px;
|
||||
}
|
||||
|
||||
.dash__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 12px 24px;
|
||||
}
|
||||
|
||||
/* 区块 */
|
||||
.dash__section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.dash__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin: 0 4px 10px;
|
||||
}
|
||||
|
||||
/* —— 核心指标卡(2 列网格)—— */
|
||||
.dash__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.metric__icon {
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
line-height: 1;
|
||||
}
|
||||
.metric__icon--pink {
|
||||
color: var(--pink);
|
||||
}
|
||||
.metric__icon--green {
|
||||
color: var(--green);
|
||||
}
|
||||
.metric__icon--amber {
|
||||
color: var(--amber);
|
||||
}
|
||||
.metric__val {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.metric__label {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* —— 待补指标行 —— */
|
||||
.dash__pending-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pending-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pending-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.pending-row__label {
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* 占位提示:弱化 + 虚线徽标,明确「非真实数据」 */
|
||||
.pending-row__hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.dash__note {
|
||||
margin: 8px 4px 0;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* —— 七日趋势占位 —— */
|
||||
.trend-placeholder {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 底层静态柱状网格(纯装饰,弱透明,绝不可被误读为真实数据) */
|
||||
.trend-placeholder__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
padding: 16px;
|
||||
opacity: 0.14;
|
||||
}
|
||||
.trend-placeholder__bar {
|
||||
flex: 0 0 18px;
|
||||
background: var(--cyan);
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
/* 高度做出错落感(纯静态装饰,不代表任何数据) */
|
||||
.trend-placeholder__bar:nth-child(1) { height: 40%; }
|
||||
.trend-placeholder__bar:nth-child(2) { height: 65%; }
|
||||
.trend-placeholder__bar:nth-child(3) { height: 50%; }
|
||||
.trend-placeholder__bar:nth-child(4) { height: 80%; }
|
||||
.trend-placeholder__bar:nth-child(5) { height: 55%; }
|
||||
.trend-placeholder__bar:nth-child(6) { height: 70%; }
|
||||
.trend-placeholder__bar:nth-child(7) { height: 45%; }
|
||||
/* 上层蒙层文案(明确建设中) */
|
||||
.trend-placeholder__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.trend-placeholder__icon {
|
||||
font-size: 28px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.trend-placeholder__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.trend-placeholder__desc {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
/* 查看明细入口 */
|
||||
.dash__more {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.dash__more-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
color: var(--cyan);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.dash__more-btn:active {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
</style>
|
||||
@ -86,6 +86,11 @@ function goProjects() {
|
||||
router.push('/project')
|
||||
}
|
||||
|
||||
/** 跳「创作数据」看板(P-OPS-01/02:核心指标卡 + 七日趋势,创作者私有数据) */
|
||||
function goDashboard() {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
/** 跳「消息」中心 */
|
||||
function goMessages() {
|
||||
router.push('/message')
|
||||
@ -175,6 +180,12 @@ onMounted(() => {
|
||||
<span class="profile__item-label">我的项目</span>
|
||||
<span class="profile__item-chevron">›</span>
|
||||
</button>
|
||||
<!-- 创作数据看板(P-OPS-01/02:累计播放/点赞/作品/奖励 + 七日趋势占位) -->
|
||||
<button class="profile__item" @click="goDashboard">
|
||||
<span class="profile__item-icon">▤</span>
|
||||
<span class="profile__item-label">创作数据</span>
|
||||
<span class="profile__item-chevron">›</span>
|
||||
</button>
|
||||
<!-- 消息(与底部 Tab 同入口,带未读角标,方便从个人中心进入) -->
|
||||
<button class="profile__item" @click="goMessages">
|
||||
<span class="profile__item-icon">✉</span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user