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:
zizi 2026-06-16 07:19:07 +00:00
parent 3147b11d60
commit 5463796f52
3 changed files with 451 additions and 0 deletions

View File

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

View 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 读端点
*
* 入口个人中心创作数据二级入口 /dashboardrequiresAuth私有数据
*
* 边缘失败与既有视图同口径
* - 任一数据源失败由 request.ts 拦截器统一 Toast不白屏指标缺值兜底 0 或占位不抛断页面
* - 未登录 路由守卫前置拦截跳登录本页 requiresAuth
*
* 契约纪律仅消费 projectApiproject.yaml+ communityApicommunity.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>

View File

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