style(creator-center): 数据看板改用管理端卡片布局
This commit is contained in:
1 parent
837266ce7e
commit
8535305185
1 file changed
+453
-20
@@ -1,39 +1,315 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<!-- 创作者数据看板:布局与视觉对齐管理端首页(BusinessDashboardView),指标口径复用 /admin/creator-center/dashboard。 -->
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
import CreatorCenterPage from './components/CreatorCenterPage.vue'
|
||||
import { DASHBOARD_GROUPS, useCreatorDashboard } from './composables/useCreatorDashboard'
|
||||
import { useCreatorDashboard } from './composables/useCreatorDashboard'
|
||||
import { formatCount } from '@/utils/format'
|
||||
import echarts from '@/plugins/echarts'
|
||||
|
||||
const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDashboard()
|
||||
const { loading, board, lastUpdated, load } = useCreatorDashboard()
|
||||
|
||||
const playbackRef = ref(null)
|
||||
const statusRef = ref(null)
|
||||
const mediaRef = ref(null)
|
||||
const interactionRef = ref(null)
|
||||
const revenueRef = ref(null)
|
||||
const chartInstances = []
|
||||
|
||||
const COLORS = ['#5B8FF9', '#5AD8A6', '#F6BD16', '#E86452', '#945FB9', '#C9C9C9']
|
||||
|
||||
const statusLabel = [
|
||||
{ key: 'draft', name: '草稿' },
|
||||
{ key: 'reviewing', name: '审核中' },
|
||||
{ key: 'approved', name: '待发布' },
|
||||
{ key: 'published', name: '已上线' },
|
||||
{ key: 'offline', name: '已下线' }
|
||||
]
|
||||
|
||||
function num(group, key) {
|
||||
const value = board.value?.[group]?.[key]
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function metricValue(group, key) {
|
||||
const value = board.value?.[group]?.[key]
|
||||
if (value === null || value === undefined) return null
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function metricText(group, key) {
|
||||
const value = metricValue(group, key)
|
||||
return value === null ? '--' : formatCount(value)
|
||||
}
|
||||
|
||||
const finishRate = computed(() => {
|
||||
const value = metricValue('playback', 'finishRate')
|
||||
if (value === null) return null
|
||||
return Math.max(0, Math.min(100, value <= 1 ? value * 100 : value))
|
||||
})
|
||||
|
||||
const finishRateText = computed(() =>
|
||||
finishRate.value === null ? '--' : `${finishRate.value.toFixed(1)}%`
|
||||
)
|
||||
|
||||
const kpis = computed(() => [
|
||||
{ label: '作品总数', value: metricText('drama', 'total'), accent: '#5B8FF9' },
|
||||
{ label: '累计观看', value: metricText('playback', 'viewTotal'), accent: '#5AD8A6' },
|
||||
{ label: '累计播放', value: metricText('playback', 'playTotal'), accent: '#5B8FF9' },
|
||||
{ label: '完播率', value: finishRateText.value, accent: '#F6BD16' },
|
||||
{ label: '解锁笔数', value: metricText('revenue', 'unlockCount'), accent: '#E86452' },
|
||||
{ label: '金币收入', value: metricText('revenue', 'coinIncome'), accent: '#945FB9' }
|
||||
])
|
||||
|
||||
const statusItems = computed(() =>
|
||||
statusLabel.map((item) => ({ name: item.name, value: num('drama', item.key) }))
|
||||
)
|
||||
|
||||
const mediaItems = computed(() => {
|
||||
const total = num('drama', 'episodeTotal')
|
||||
const withMedia = num('drama', 'episodeWithMedia')
|
||||
return [
|
||||
{ name: '有片源', value: withMedia },
|
||||
{ name: '无片源', value: Math.max(0, total - withMedia) }
|
||||
]
|
||||
})
|
||||
|
||||
const playbackItems = computed(() => [
|
||||
{ name: '累计观看', value: num('playback', 'viewTotal') },
|
||||
{ name: '累计播放', value: num('playback', 'playTotal') },
|
||||
{ name: '近 7 天播放流水', value: num('playback', 'playEvents7d') },
|
||||
{ name: '播放流水总量', value: num('playback', 'playEventsTotal') },
|
||||
{ name: '完播事件', value: num('playback', 'finishEvents') }
|
||||
])
|
||||
|
||||
const interactionItems = computed(() => [
|
||||
{ name: '收藏', value: num('interaction', 'favoriteTotal') },
|
||||
{ name: '评论', value: num('interaction', 'commentTotal') },
|
||||
{ name: '待审评论', value: num('interaction', 'commentPending') }
|
||||
])
|
||||
|
||||
const revenueItems = computed(() => [
|
||||
{ name: '解锁笔数', value: num('revenue', 'unlockCount') },
|
||||
{ name: '金币收入', value: num('revenue', 'coinIncome') },
|
||||
{ name: '近 7 天金币', value: num('revenue', 'coinIncome7d') },
|
||||
{ name: '法币金额', value: num('revenue', 'paidOrderAmount') }
|
||||
])
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '收藏总数', value: metricText('interaction', 'favoriteTotal') },
|
||||
{ label: '评论总数', value: metricText('interaction', 'commentTotal') },
|
||||
{ label: '待审评论', value: metricText('interaction', 'commentPending') },
|
||||
{ label: '金币收入', value: metricText('revenue', 'coinIncome') },
|
||||
{ label: '近 7 天金币', value: metricText('revenue', 'coinIncome7d') },
|
||||
{ label: '法币金额', value: metricText('revenue', 'paidOrderAmount') }
|
||||
])
|
||||
|
||||
const pieBase = {
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false, position: 'center' },
|
||||
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } }
|
||||
}
|
||||
|
||||
function renderPlayback() {
|
||||
if (!playbackRef.value) return
|
||||
const chart = echarts.init(playbackRef.value)
|
||||
chartInstances.push(chart)
|
||||
const items = playbackItems.value
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 90, right: 40, top: 12, bottom: 12 },
|
||||
xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
inverse: true,
|
||||
data: items.map((item) => item.name),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barWidth: 12,
|
||||
data: items.map((item) => item.value),
|
||||
itemStyle: { borderRadius: [0, 4, 4, 0], color: '#5B8FF9' },
|
||||
label: { show: true, position: 'right', formatter: ({ value }) => formatCount(value) }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function renderPie(el, name, data) {
|
||||
if (!el) return
|
||||
const chart = echarts.init(el)
|
||||
chartInstances.push(chart)
|
||||
const valid = data.filter((item) => item.value > 0)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0, itemWidth: 12, itemHeight: 12, textStyle: { fontSize: 12 } },
|
||||
color: COLORS,
|
||||
series: [
|
||||
{
|
||||
...pieBase,
|
||||
name,
|
||||
data: valid.length
|
||||
? valid
|
||||
: [{ name: '暂无数据', value: 1, itemStyle: { color: '#E6E8EB' } }]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function renderBar(el, items) {
|
||||
if (!el) return
|
||||
const chart = echarts.init(el)
|
||||
chartInstances.push(chart)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 12, right: 16, top: 24, bottom: 12, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: items.map((item) => item.name),
|
||||
axisTick: { show: false },
|
||||
axisLabel: { interval: 0, fontSize: 11 }
|
||||
},
|
||||
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barWidth: 22,
|
||||
data: items.map((item, index) => ({
|
||||
value: item.value,
|
||||
itemStyle: { color: COLORS[index % COLORS.length], borderRadius: [4, 4, 0, 0] }
|
||||
})),
|
||||
label: { show: true, position: 'top', fontSize: 11 }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function disposeAll() {
|
||||
chartInstances.forEach((chart) => chart.dispose())
|
||||
chartInstances.length = 0
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
disposeAll()
|
||||
renderPlayback()
|
||||
renderPie(statusRef.value, '作品状态', statusItems.value)
|
||||
renderPie(mediaRef.value, '片源覆盖', mediaItems.value)
|
||||
renderBar(interactionRef.value, interactionItems.value)
|
||||
renderBar(revenueRef.value, revenueItems.value)
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
chartInstances.forEach((chart) => chart.resize())
|
||||
}
|
||||
|
||||
watch(
|
||||
() => board.value,
|
||||
async (value) => {
|
||||
if (!value) return
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
disposeAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreatorCenterPage>
|
||||
<PageHeading
|
||||
eyebrow="创作者中心"
|
||||
title="数据看板"
|
||||
description="我的作品在播放、互动与解锁收入上的表现。"
|
||||
eyebrow="数据看板"
|
||||
title="创作者数据"
|
||||
description="本人作品的播放、互动与解锁收入总览。"
|
||||
>
|
||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||
</PageHeading>
|
||||
|
||||
<section v-loading="loading" class="panel">
|
||||
<p class="console-hint">数据时间:{{ lastUpdated }}(统计范围仅限本人作品)</p>
|
||||
<div v-for="group in DASHBOARD_GROUPS" :key="group.key" class="board-group">
|
||||
<h3 class="board-title">{{ group.title }}</h3>
|
||||
<div class="board-grid">
|
||||
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
|
||||
<span class="board-label">{{ metric.label }}</span>
|
||||
<span
|
||||
class="board-value"
|
||||
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
|
||||
>
|
||||
{{ display(groupValue(group.key, metric.key)) }}
|
||||
</span>
|
||||
<div v-loading="loading" class="board-wrap">
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>创作概览</h2>
|
||||
<p>作品规模、播放与收入核心指标</p>
|
||||
</div>
|
||||
<span class="muted">数据时间:{{ lastUpdated || '--' }}</span>
|
||||
</div>
|
||||
<div class="kpi-row">
|
||||
<article v-for="item in kpis" :key="item.label" class="kpi">
|
||||
<i :style="{ background: item.accent }"></i>
|
||||
<div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>播放与片源</h2>
|
||||
<p>播放漏斗、作品状态与分集片源覆盖</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viz-row">
|
||||
<div class="viz-cell">
|
||||
<h3>播放表现</h3>
|
||||
<div ref="playbackRef" class="chart chart-bar"></div>
|
||||
</div>
|
||||
<div class="viz-cell">
|
||||
<h3>作品状态</h3>
|
||||
<div ref="statusRef" class="chart chart-pie"></div>
|
||||
</div>
|
||||
<div class="viz-cell">
|
||||
<h3>片源覆盖</h3>
|
||||
<div ref="mediaRef" class="chart chart-pie"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>互动与收入</h2>
|
||||
<p>收藏、评论与解锁收入</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viz-row">
|
||||
<div class="viz-cell">
|
||||
<h3>互动</h3>
|
||||
<div ref="interactionRef" class="chart chart-bar"></div>
|
||||
</div>
|
||||
<div class="viz-cell">
|
||||
<h3>解锁收入</h3>
|
||||
<div ref="revenueRef" class="chart chart-bar"></div>
|
||||
</div>
|
||||
<div class="viz-cell">
|
||||
<h3>关键指标</h3>
|
||||
<ul class="metric-list">
|
||||
<li v-for="item in metrics" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="board?.notes?.length"
|
||||
class="board-notes"
|
||||
@@ -46,6 +322,163 @@ const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDas
|
||||
<li v-for="note in board.notes" :key="note">{{ note }}</li>
|
||||
</ul>
|
||||
</el-alert>
|
||||
</section>
|
||||
</div>
|
||||
</CreatorCenterPage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.board-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px 24px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 72px;
|
||||
padding: 12px 14px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
|
||||
i {
|
||||
flex: none;
|
||||
width: 3px;
|
||||
height: 32px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
color: var(--el-text-color-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.viz-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 0.8fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.viz-cell {
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.chart-pie {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.metric-list {
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px dashed var(--el-border-color-lighter);
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.board-notes {
|
||||
:deep(ul) {
|
||||
padding-left: 18px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1100px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.viz-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user