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 -->
|
<!-- Author: fruit.com.cn -->
|
||||||
|
<!-- 创作者数据看板:布局与视觉对齐管理端首页(BusinessDashboardView),指标口径复用 /admin/creator-center/dashboard。 -->
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import PageHeading from '@/views/components/PageHeading.vue'
|
import PageHeading from '@/views/components/PageHeading.vue'
|
||||||
import CreatorCenterPage from './components/CreatorCenterPage.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CreatorCenterPage>
|
<CreatorCenterPage>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="创作者中心"
|
eyebrow="数据看板"
|
||||||
title="数据看板"
|
title="创作者数据"
|
||||||
description="我的作品在播放、互动与解锁收入上的表现。"
|
description="本人作品的播放、互动与解锁收入总览。"
|
||||||
>
|
>
|
||||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||||
</PageHeading>
|
</PageHeading>
|
||||||
|
|
||||||
<section v-loading="loading" class="panel">
|
<div v-loading="loading" class="board-wrap">
|
||||||
<p class="console-hint">数据时间:{{ lastUpdated }}(统计范围仅限本人作品)</p>
|
<section class="panel">
|
||||||
<div v-for="group in DASHBOARD_GROUPS" :key="group.key" class="board-group">
|
<div class="panel-heading">
|
||||||
<h3 class="board-title">{{ group.title }}</h3>
|
<div>
|
||||||
<div class="board-grid">
|
<h2>创作概览</h2>
|
||||||
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
|
<p>作品规模、播放与收入核心指标</p>
|
||||||
<span class="board-label">{{ metric.label }}</span>
|
</div>
|
||||||
<span
|
<span class="muted">数据时间:{{ lastUpdated || '--' }}</span>
|
||||||
class="board-value"
|
</div>
|
||||||
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
|
<div class="kpi-row">
|
||||||
>
|
<article v-for="item in kpis" :key="item.label" class="kpi">
|
||||||
{{ display(groupValue(group.key, metric.key)) }}
|
<i :style="{ background: item.accent }"></i>
|
||||||
</span>
|
<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>
|
||||||
</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
|
<el-alert
|
||||||
v-if="board?.notes?.length"
|
v-if="board?.notes?.length"
|
||||||
class="board-notes"
|
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>
|
<li v-for="note in board.notes" :key="note">{{ note }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
</section>
|
</div>
|
||||||
</CreatorCenterPage>
|
</CreatorCenterPage>
|
||||||
</template>
|
</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