style(creator-center): 数据看板改用管理端卡片布局

This commit is contained in:
Zhou Weidong committed 2026-11-09 10:27:47 +08:00
1 parent 837266ce7e
commit 8535305185
1 file changed
+453 -20
+453 -20
View File
@@ -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>