feat: init

This commit is contained in:
ygx
2026-03-05 23:45:39 +08:00
commit 8fab91c5c7
214 changed files with 33682 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-card :bordered="false" :style="cardStyle">
<div class="content-wrap">
<div class="content">
<a-statistic :title="title" :value="renderData.count" :value-from="0" animation show-group-separator />
<div class="desc">
<a-typography-text type="secondary" class="label">
{{ $t('dataAnalysis.card.yesterday') }}
</a-typography-text>
<a-typography-text type="danger">
{{ renderData.growth }}
<icon-arrow-rise />
</a-typography-text>
</div>
</div>
<div class="chart">
<Chart v-if="!loading" :option="chartOption" />
</div>
</div>
</a-card>
</a-spin>
</template>
<script lang="ts" setup>
import { PublicOpinionAnalysis, PublicOpinionAnalysisRes, queryPublicOpinionAnalysis } from '@/api/visualization'
import useChartOption from '@/hooks/chart-option'
import useLoading from '@/hooks/loading'
import { CSSProperties, PropType, ref } from 'vue'
const barChartOptionsFactory = () => {
const data = ref<any>([])
// @ts-ignore
const { chartOption } = useChartOption(() => {
return {
grid: {
left: 0,
right: 0,
top: 10,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
},
yAxis: {
show: false,
},
tooltip: {
show: true,
trigger: 'axis',
},
series: {
name: 'total',
data,
type: 'bar',
barWidth: 7,
itemStyle: {
borderRadius: 2,
},
},
}
})
return {
data,
chartOption,
}
}
const lineChartOptionsFactory = () => {
const data = ref<number[][]>([[], []])
const { chartOption } = useChartOption(() => {
return {
grid: {
left: 0,
right: 0,
top: 10,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
},
yAxis: {
show: false,
},
tooltip: {
show: true,
trigger: 'axis',
},
series: [
{
name: '2001',
data: data.value[0],
type: 'line',
showSymbol: false,
smooth: true,
lineStyle: {
color: '#165DFF',
width: 3,
},
},
{
name: '2002',
data: data.value[1],
type: 'line',
showSymbol: false,
smooth: true,
lineStyle: {
color: '#6AA1FF',
width: 3,
type: 'dashed',
},
},
],
}
})
return {
data,
chartOption,
}
}
const pieChartOptionsFactory = () => {
const data = ref<any>([])
// @ts-ignore
const { chartOption } = useChartOption(() => {
return {
grid: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
legend: {
show: true,
top: 'center',
right: '0',
orient: 'vertical',
icon: 'circle',
itemWidth: 6,
itemHeight: 6,
textStyle: {
color: '#4E5969',
},
},
tooltip: {
show: true,
},
series: [
{
name: '总计',
type: 'pie',
radius: ['50%', '70%'],
label: {
show: false,
},
data,
},
],
}
})
return {
data,
chartOption,
}
}
const props = defineProps({
title: {
type: String,
default: '',
},
quota: {
type: String,
default: '',
},
chartType: {
type: String,
default: '',
},
cardStyle: {
type: Object as PropType<CSSProperties>,
default: () => {
return {}
},
},
})
const { loading, setLoading } = useLoading(true)
const { chartOption: lineChartOption, data: lineData } = lineChartOptionsFactory()
const { chartOption: barChartOption, data: barData } = barChartOptionsFactory()
const { chartOption: pieChartOption, data: pieData } = pieChartOptionsFactory()
const renderData = ref<PublicOpinionAnalysisRes>({
count: 0,
growth: 0,
chartData: [],
})
const chartOption = ref({})
const fetchData = async (params: PublicOpinionAnalysis) => {
try {
const { data } = await queryPublicOpinionAnalysis(params)
renderData.value = data
const { chartData } = data
if (props.chartType === 'bar') {
chartData.forEach((el, idx) => {
barData.value.push({
value: el.y,
itemStyle: {
color: idx % 2 ? '#2CAB40' : '#86DF6C',
},
})
})
chartOption.value = barChartOption.value
} else if (props.chartType === 'line') {
chartData.forEach((el) => {
if (el.name === '2021') {
lineData.value[0].push(el.y)
} else {
lineData.value[1].push(el.y)
}
})
chartOption.value = lineChartOption.value
} else {
chartData.forEach((el) => {
pieData.value.push(el)
})
chartOption.value = pieChartOption.value
}
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData({ quota: props.quota })
</script>
<style scoped lang="less">
:deep(.arco-card) {
border-radius: 4px;
}
:deep(.arco-card-body) {
width: 100%;
height: 134px;
padding: 0;
}
.content-wrap {
width: 100%;
padding: 16px;
white-space: nowrap;
}
:deep(.content) {
float: left;
width: 108px;
height: 102px;
}
:deep(.arco-statistic) {
.arco-statistic-title {
font-size: 16px;
font-weight: bold;
white-space: nowrap;
}
.arco-statistic-content {
margin-top: 10px;
}
}
.chart {
float: right;
width: calc(100% - 108px);
height: 90px;
vertical-align: bottom;
}
.label {
padding-right: 8px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-card class="general-card" :header-style="{ paddingBottom: '16px' }">
<template #title>
{{ $t('dataAnalysis.contentPeriodAnalysis') }}
</template>
<Chart style="width: 100%; height: 370px" :option="chartOption" />
</a-card>
</a-spin>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import useLoading from '@/hooks/loading'
import { queryContentPeriodAnalysis } from '@/api/visualization'
import { ToolTipFormatterParams } from '@/types/echarts'
import useChartOption from '@/hooks/chart-option'
const tooltipItemsHtmlString = (items: ToolTipFormatterParams[]) => {
return items
.map(
(el) => `<div class="content-panel">
<p>
<span style="background-color: ${el.color}" class="tooltip-item-icon"></span>
<span>${el.seriesName}</span>
</p>
<span class="tooltip-value">
${el.value}%
</span>
</div>`
)
.join('')
}
const { loading, setLoading } = useLoading(true)
const xAxis = ref<string[]>([])
const textChartsData = ref<number[]>([])
const imgChartsData = ref<number[]>([])
const videoChartsData = ref<number[]>([])
const { chartOption } = useChartOption((isDark) => {
return {
grid: {
left: '40',
right: 0,
top: '20',
bottom: '100',
},
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#4E5969',
},
},
xAxis: {
type: 'category',
data: xAxis.value,
boundaryGap: false,
axisLine: {
lineStyle: {
color: isDark ? '#3f3f3f' : '#A9AEB8',
},
},
axisTick: {
show: true,
alignWithLabel: true,
lineStyle: {
color: '#86909C',
},
interval(idx: number) {
if (idx === 0) return false
if (idx === xAxis.value.length - 1) return false
return true
},
},
axisLabel: {
color: '#86909C',
formatter(value: string, idx: number) {
if (idx === 0) return ''
if (idx === xAxis.value.length - 1) return ''
return `${value}`
},
},
},
yAxis: {
type: 'value',
axisLabel: {
color: '#86909C',
formatter: '{value}%',
},
splitLine: {
lineStyle: {
color: isDark ? '#3F3F3F' : '#E5E6EB',
},
},
},
tooltip: {
show: true,
trigger: 'axis',
formatter(params) {
const [firstElement] = params as ToolTipFormatterParams[]
return `<div>
<p class="tooltip-title">${firstElement.axisValueLabel}</p>
${tooltipItemsHtmlString(params as ToolTipFormatterParams[])}
</div>`
},
className: 'echarts-tooltip-diy',
},
series: [
{
name: '纯文本',
data: textChartsData.value,
type: 'line',
smooth: true,
showSymbol: false,
color: isDark ? '#3D72F6' : '#246EFF',
symbol: 'circle',
symbolSize: 10,
emphasis: {
focus: 'series',
itemStyle: {
borderWidth: 2,
borderColor: '#E0E3FF',
},
},
},
{
name: '图文类',
data: imgChartsData.value,
type: 'line',
smooth: true,
showSymbol: false,
color: isDark ? '#A079DC' : '#00B2FF',
symbol: 'circle',
symbolSize: 10,
emphasis: {
focus: 'series',
itemStyle: {
borderWidth: 2,
borderColor: '#E2F2FF',
},
},
},
{
name: '视频类',
data: videoChartsData.value,
type: 'line',
smooth: true,
showSymbol: false,
color: isDark ? '#6CAAF5' : '#81E2FF',
symbol: 'circle',
symbolSize: 10,
emphasis: {
focus: 'series',
itemStyle: {
borderWidth: 2,
borderColor: '#D9F6FF',
},
},
},
],
dataZoom: [
{
bottom: 40,
type: 'slider',
left: 40,
right: 14,
height: 14,
borderColor: 'transparent',
handleIcon:
'image://http://p3-armor.byteimg.com/tos-cn-i-49unhts6dw/1ee5a8c6142b2bcf47d2a9f084096447.svg~tplv-49unhts6dw-image.image',
handleSize: '20',
handleStyle: {
shadowColor: 'rgba(0, 0, 0, 0.2)',
shadowBlur: 4,
},
brushSelect: false,
backgroundColor: isDark ? '#313132' : '#F2F3F5',
},
{
type: 'inside',
start: 0,
end: 100,
zoomOnMouseWheel: false,
},
],
}
})
const fetchData = async () => {
setLoading(true)
try {
const { data: chartData } = await queryContentPeriodAnalysis()
xAxis.value = chartData.xAxis
chartData.data.forEach((el) => {
if (el.name === '纯文本') {
textChartsData.value = el.value
} else if (el.name === '图文类') {
imgChartsData.value = el.value
}
videoChartsData.value = el.value
})
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less">
.chart-box {
width: 100%;
height: 230px;
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-card class="general-card" :header-style="{ paddingBottom: '14px' }">
<template #title>
{{ $t('dataAnalysis.contentPublishRatio') }}
</template>
<template #extra>
<a-link>{{ $t('workplace.viewMore') }}</a-link>
</template>
<Chart style="width: 100%; height: 347px" :option="chartOption" />
</a-card>
</a-spin>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { ToolTipFormatterParams } from '@/types/echarts'
import useLoading from '@/hooks/loading'
import { queryContentPublish, ContentPublishRecord } from '@/api/visualization'
import useChartOption from '@/hooks/chart-option'
const tooltipItemsHtmlString = (items: ToolTipFormatterParams[]) => {
return items
.map(
(el) => `<div class="content-panel">
<p>
<span style="background-color: ${el.color}" class="tooltip-item-icon"></span>
<span>
${el.seriesName}
</span>
</p>
<span class="tooltip-value">
${Number(el.value).toLocaleString()}
</span>
</div>`
)
.join('')
}
const { loading, setLoading } = useLoading(true)
const xAxis = ref<string[]>([])
const textChartsData = ref<number[]>([])
const imgChartsData = ref<number[]>([])
const videoChartsData = ref<number[]>([])
const { chartOption } = useChartOption((isDark) => {
return {
grid: {
left: '4%',
right: 0,
top: '20',
bottom: '60',
},
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#4E5969',
},
},
xAxis: {
type: 'category',
data: xAxis.value,
axisLine: {
lineStyle: {
color: isDark ? '#3f3f3f' : '#A9AEB8',
},
},
axisTick: {
show: true,
alignWithLabel: true,
lineStyle: {
color: '#86909C',
},
},
axisLabel: {
color: '#86909C',
},
},
yAxis: {
type: 'value',
axisLabel: {
color: '#86909C',
formatter(value: number, idx: number) {
if (idx === 0) return `${value}`
return `${value / 1000}k`
},
},
splitLine: {
lineStyle: {
color: isDark ? '#3F3F3F' : '#E5E6EB',
},
},
},
tooltip: {
show: true,
trigger: 'axis',
formatter(params) {
const [firstElement] = params as ToolTipFormatterParams[]
return `<div>
<p class="tooltip-title">${firstElement.axisValueLabel}</p>
${tooltipItemsHtmlString(params as ToolTipFormatterParams[])}
</div>`
},
className: 'echarts-tooltip-diy',
},
series: [
{
name: '纯文本',
data: textChartsData.value,
stack: 'one',
type: 'bar',
barWidth: 16,
color: isDark ? '#4A7FF7' : '#246EFF',
},
{
name: '图文类',
data: imgChartsData.value,
stack: 'one',
type: 'bar',
color: isDark ? '#085FEF' : '#00B2FF',
},
{
name: '视频类',
data: videoChartsData.value,
stack: 'one',
type: 'bar',
color: isDark ? '#01349F' : '#81E2FF',
itemStyle: {
borderRadius: 2,
},
},
],
}
})
const fetchData = async () => {
setLoading(true)
try {
const { data: chartData } = await queryContentPublish()
xAxis.value = chartData[0].x
chartData.forEach((el: ContentPublishRecord) => {
if (el.name === '纯文本') {
textChartsData.value = el.y
} else if (el.name === '图文类') {
imgChartsData.value = el.y
}
videoChartsData.value = el.y
})
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,59 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-card class="general-card" :header-style="{ paddingBottom: '14px' }">
<template #title>
{{ $t('dataAnalysis.popularAuthor') }}
</template>
<template #extra>
<a-link>{{ $t('workplace.viewMore') }}</a-link>
</template>
<a-table :data="tableData.list" :pagination="false" :bordered="false" style="margin-bottom: 20px" :scroll="{ x: '100%', y: '350px' }">
<template #columns>
<a-table-column :title="$t('dataAnalysis.popularAuthor.column.ranking')" data-index="ranking"></a-table-column>
<a-table-column :title="$t('dataAnalysis.popularAuthor.column.author')" data-index="author"></a-table-column>
<a-table-column
:title="$t('dataAnalysis.popularAuthor.column.content')"
data-index="contentCount"
:sortable="{
sortDirections: ['ascend', 'descend'],
}"
></a-table-column>
<a-table-column
:title="$t('dataAnalysis.popularAuthor.column.click')"
data-index="clickCount"
:sortable="{
sortDirections: ['ascend', 'descend'],
}"
></a-table-column>
</template>
</a-table>
</a-card>
</a-spin>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import useLoading from '@/hooks/loading'
import { queryPopularAuthor, PopularAuthorRes } from '@/api/visualization'
const { loading, setLoading } = useLoading()
const tableData = ref<PopularAuthorRes>({ list: [] })
const fetchData = async () => {
try {
setLoading(true)
const { data } = await queryPopularAuthor()
tableData.value = data
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less">
.general-card {
max-height: 425px;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<a-card class="general-card" :title="$t('dataAnalysis.title.publicOpinion')" :header-style="{ paddingBottom: '12px' }">
<a-grid :cols="24" :col-gap="12" :row-gap="12">
<a-grid-item :span="{ xs: 12, sm: 12, md: 12, lg: 12, xl: 6, xxl: 6 }">
<ChainItem
:title="$t('dataAnalysis.card.title.allVisitors')"
quota="visitors"
chart-type="line"
:card-style="{
background: isDark ? 'linear-gradient(180deg, #284991 0%, #122B62 100%)' : 'linear-gradient(180deg, #f2f9fe 0%, #e6f4fe 100%)',
}"
/>
</a-grid-item>
<a-grid-item :span="{ xs: 12, sm: 12, md: 12, lg: 12, xl: 6, xxl: 6 }">
<ChainItem
:title="$t('dataAnalysis.card.title.contentPublished')"
quota="published"
chart-type="bar"
:card-style="{
background: isDark ? ' linear-gradient(180deg, #3D492E 0%, #263827 100%)' : 'linear-gradient(180deg, #F5FEF2 0%, #E6FEEE 100%)',
}"
/>
</a-grid-item>
<a-grid-item :span="{ xs: 12, sm: 12, md: 12, lg: 12, xl: 6, xxl: 6 }">
<ChainItem
:title="$t('dataAnalysis.card.title.totalComment')"
quota="comment"
chart-type="line"
:card-style="{
background: isDark ? 'linear-gradient(180deg, #294B94 0%, #0F275C 100%)' : 'linear-gradient(180deg, #f2f9fe 0%, #e6f4fe 100%)',
}"
/>
</a-grid-item>
<a-grid-item :span="{ xs: 12, sm: 12, md: 12, lg: 12, xl: 6, xxl: 6 }">
<ChainItem
:title="$t('dataAnalysis.card.title.totalShare')"
quota="share"
chart-type="pie"
:card-style="{
background: isDark ? 'linear-gradient(180deg, #312565 0%, #201936 100%)' : 'linear-gradient(180deg, #F7F7FF 0%, #ECECFF 100%)',
}"
/>
</a-grid-item>
</a-grid>
</a-card>
</template>
<script lang="ts" setup>
import useThemes from '@/hooks/themes'
import ChainItem from './chain-item.vue'
const { isDark } = useThemes()
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.visualization', 'menu.visualization.dataAnalysis']" />
<a-space direction="vertical" :size="12" fill>
<a-space direction="vertical" :size="16" fill>
<div class="space-unit">
<PublicOpinion />
</div>
<div>
<a-grid :cols="24" :col-gap="16" :row-gap="16">
<a-grid-item :span="{ xs: 24, sm: 24, md: 24, lg: 24, xl: 16, xxl: 16 }">
<ContentPublishRatio />
</a-grid-item>
<a-grid-item :span="{ xs: 24, sm: 24, md: 24, lg: 24, xl: 8, xxl: 8 }">
<PopularAuthor />
</a-grid-item>
</a-grid>
</div>
<div>
<ContentPeriodAnalysis />
</div>
</a-space>
</a-space>
</div>
</template>
<script lang="ts" setup>
import PublicOpinion from './components/public-opinion.vue'
import ContentPeriodAnalysis from './components/content-period-analysis.vue'
import ContentPublishRatio from './components/content-publish-ratio.vue'
import PopularAuthor from './components/popular-author.vue'
</script>
<script lang="ts">
export default {
name: 'DataAnalysis',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
margin-bottom: 20px;
}
.space-unit {
background-color: var(--color-bg-2);
border-radius: 4px;
}
.title-fix {
margin: 0 0 12px 0;
font-size: 14;
}
:deep(.section-title) {
margin: 0 0 12px 0;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,16 @@
export default {
'menu.visualization.dataAnalysis': 'Analysis',
'dataAnalysis.title.publicOpinion': 'Public Opinion Analysis',
'dataAnalysis.card.title.allVisitors': 'All Visitors',
'dataAnalysis.card.title.contentPublished': 'Content Published',
'dataAnalysis.card.title.totalComment': 'Total Comment',
'dataAnalysis.card.title.totalShare': 'Total Share',
'dataAnalysis.card.yesterday': 'Yesterday',
'dataAnalysis.contentPublishRatio': 'Content Publishing Ratio',
'dataAnalysis.popularAuthor': 'Popular Author',
'dataAnalysis.popularAuthor.column.ranking': 'ranking',
'dataAnalysis.popularAuthor.column.author': 'author',
'dataAnalysis.popularAuthor.column.content': 'Content Number',
'dataAnalysis.popularAuthor.column.click': 'Click Number',
'dataAnalysis.contentPeriodAnalysis': 'Content Period Analysis',
}

View File

@@ -0,0 +1,16 @@
export default {
'menu.visualization.dataAnalysis': '分析页',
'dataAnalysis.title.publicOpinion': '舆情分析',
'dataAnalysis.card.title.allVisitors': '访问总人次',
'dataAnalysis.card.title.contentPublished': '内容发布量',
'dataAnalysis.card.title.totalComment': '评论总量',
'dataAnalysis.card.title.totalShare': '分享总量',
'dataAnalysis.card.yesterday': '较昨日',
'dataAnalysis.contentPublishRatio': '内容发布比例',
'dataAnalysis.popularAuthor': '热门作者榜单',
'dataAnalysis.popularAuthor.column.ranking': '排名',
'dataAnalysis.popularAuthor.column.author': '作者',
'dataAnalysis.popularAuthor.column.content': '内容量',
'dataAnalysis.popularAuthor.column.click': '点击量',
'dataAnalysis.contentPeriodAnalysis': '内容时段分析',
}

View File

@@ -0,0 +1,97 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
import { PostData } from '@/types/global'
setupMock({
setup() {
Mock.mock(new RegExp('/api/public-opinion-analysis'), (params: PostData) => {
const { quota = 'visitors' } = JSON.parse(params.body)
if (['visitors', 'comment'].includes(quota)) {
const year = new Date().getFullYear()
const getLineData = (name: number) => {
return new Array(12).fill(0).map((_item, index) => ({
x: `${index + 1}`,
y: Mock.Random.natural(0, 100),
name: String(name),
}))
}
return successResponseWrap({
count: 5670,
growth: 206.32,
chartData: [...getLineData(year), ...getLineData(year - 1)],
})
}
if (['published'].includes(quota)) {
const year = new Date().getFullYear()
const getLineData = (name: number) => {
return new Array(12).fill(0).map((_item, index) => ({
x: `${index + 1}`,
y: Mock.Random.natural(20, 100),
name: String(name),
}))
}
return successResponseWrap({
count: 5670,
growth: 206.32,
chartData: [...getLineData(year)],
})
}
return successResponseWrap({
count: 5670,
growth: 206.32,
chartData: [
// itemStyle for demo
{ name: '文本类', value: 25, itemStyle: { color: '#8D4EDA' } },
{ name: '图文类', value: 35, itemStyle: { color: '#165DFF' } },
{ name: '视频类', value: 40, itemStyle: { color: '#00B2FF' } },
],
})
})
Mock.mock(new RegExp('/api/content-period-analysis'), () => {
const getLineData = (name: string) => {
return {
name,
value: new Array(12).fill(0).map(() => Mock.Random.natural(30, 90)),
}
}
return successResponseWrap({
xAxis: new Array(12).fill(0).map((_item, index) => `${index * 2}:00`),
data: [getLineData('纯文本'), getLineData('图文类'), getLineData('视频类')],
})
})
Mock.mock(new RegExp('/api/content-publish'), () => {
const generateLineData = (name: string) => {
const result = {
name,
x: [] as string[],
y: [] as number[],
}
new Array(12).fill(0).forEach((_item, index) => {
result.x.push(`${index * 2}:00`)
result.y.push(Mock.Random.natural(1000, 3000))
})
return result
}
return successResponseWrap([generateLineData('纯文本'), generateLineData('图文类'), generateLineData('视频类')])
})
Mock.mock(new RegExp('/api/popular-author/list'), () => {
const generateData = () => {
const list = new Array(7).fill(0).map((_item, index) => ({
ranking: index + 1,
author: Mock.mock('@ctitle(5)'),
contentCount: Mock.mock(/[0-9]{4}/),
clickCount: Mock.mock(/[0-9]{4}/),
}))
return {
list,
}
}
return successResponseWrap({
...generateData(),
})
})
},
})

View File

@@ -0,0 +1,140 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-card class="general-card" :title="title" :header-style="{ paddingBottom: '12px' }">
<div class="content">
<a-statistic :value="count" :show-group-separator="true" :value-from="0" animation />
<a-typography-text class="percent-text" :type="isUp ? 'danger' : 'success'">
{{ growth }}%
<icon-arrow-rise v-if="isUp" />
<icon-arrow-fall v-else />
</a-typography-text>
</div>
<div class="chart">
<Chart :option="chartOption" />
</div>
</a-card>
</a-spin>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import useLoading from '@/hooks/loading'
import { queryDataChainGrowth, DataChainGrowth } from '@/api/visualization'
import useChartOption from '@/hooks/chart-option'
const props = defineProps({
title: {
type: String,
default: '',
},
quota: {
type: String,
default: '',
},
chartType: {
type: String,
default: '',
},
})
const { loading, setLoading } = useLoading(true)
const count = ref(0)
const growth = ref(100)
const isUp = computed(() => growth.value > 50)
const chartData = ref<any>([])
const { chartOption } = useChartOption(() => {
return {
grid: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
},
yAxis: {
show: false,
},
tooltip: {
show: true,
trigger: 'axis',
formatter: '{c}',
},
series: [
{
data: chartData.value,
...(props.chartType === 'bar'
? {
type: 'bar',
barWidth: 7,
barGap: '0',
}
: {
type: 'line',
showSymbol: false,
smooth: true,
lineStyle: {
color: '#4080FF',
},
}),
},
],
}
})
const fetchData = async (params: DataChainGrowth) => {
try {
const { data } = await queryDataChainGrowth(params)
const { chartData: resChartData } = data
count.value = data.count
growth.value = data.growth
resChartData.data.value.forEach((el, idx) => {
if (props.chartType === 'bar') {
chartData.value.push({
value: el,
itemStyle: {
color: idx % 2 ? '#468DFF' : '#86DF6C',
},
})
} else {
chartData.value.push(el)
}
})
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData({ quota: props.quota })
</script>
<style scoped lang="less">
.general-card {
min-height: 204px;
}
.content {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 12px;
}
.percent-text {
margin-left: 16px;
}
.chart {
width: 100%;
height: 80px;
vertical-align: bottom;
}
.unit {
padding-left: 8px;
font-size: 12px;
}
.label {
padding-right: 8px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-card class="general-card" :title="$t('multiDAnalysis.card.title.contentPublishingSource')">
<Chart style="width: 100%; height: 300px" :option="chartOption" />
</a-card>
</a-spin>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading'
import useChartOption from '@/hooks/chart-option'
const { chartOption } = useChartOption((isDark) => {
const graphicElementStyle = {
textAlign: 'center',
fill: isDark ? 'rgba(255,255,255,0.7)' : '#4E5969',
fontSize: 14,
lineWidth: 10,
fontWeight: 'bold',
}
return {
legend: {
left: 'center',
data: ['UGC原创', '国外网站', '转载文章', '行业报告', '其他'],
bottom: 0,
icon: 'circle',
itemWidth: 8,
textStyle: {
color: isDark ? 'rgba(255,255,255,0.7)' : '#4E5969',
},
itemStyle: {
borderWidth: 0,
},
},
tooltip: {
show: true,
trigger: 'item',
},
graphic: {
elements: [
{
type: 'text',
left: '9.6%',
top: 'center',
style: {
text: '纯文本',
...graphicElementStyle,
},
},
{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '图文类',
...graphicElementStyle,
},
},
{
type: 'text',
left: '86.6%',
top: 'center',
style: {
text: '视频类',
...graphicElementStyle,
},
},
],
},
series: [
{
type: 'pie',
radius: ['50%', '70%'],
center: ['11%', '50%'],
label: {
formatter: '{d}% ',
color: isDark ? 'rgba(255, 255, 255, 0.7)' : '#4E5969',
},
itemStyle: {
borderColor: isDark ? '#000' : '#fff',
borderWidth: 1,
},
data: [
{
value: [148564],
name: 'UGC原创',
itemStyle: {
color: '#249EFF',
},
},
{
value: [334271],
name: '国外网站',
itemStyle: {
color: '#846BCE',
},
},
{
value: [445694],
name: '转载文章',
itemStyle: {
color: '#21CCFF',
},
},
{
value: [445694],
name: '行业报告',
itemStyle: {
color: '#0E42D2',
},
},
{
value: [445694],
name: '其他',
itemStyle: {
color: '#86DF6C',
},
},
],
},
{
type: 'pie',
radius: ['50%', '70%'],
center: ['50%', '50%'],
label: {
formatter: '{d}% ',
color: isDark ? 'rgba(255, 255, 255, 0.7)' : '#4E5969',
},
itemStyle: {
borderColor: isDark ? '#000' : '#fff',
borderWidth: 1,
},
data: [
{
value: [148564],
name: 'UGC原创',
itemStyle: {
color: '#249EFF',
},
},
{
value: [334271],
name: '国外网站',
itemStyle: {
color: '#846BCE',
},
},
{
value: [445694],
name: '转载文章',
itemStyle: {
color: '#21CCFF',
},
},
{
value: [445694],
name: '行业报告',
itemStyle: {
color: '#0E42D2',
},
},
{
value: [445694],
name: '其他',
itemStyle: {
color: '#86DF6C',
},
},
],
},
{
type: 'pie',
radius: ['50%', '70%'],
center: ['88%', '50%'],
label: {
formatter: '{d}% ',
color: isDark ? 'rgba(255, 255, 255, 0.7)' : '#4E5969',
},
itemStyle: {
borderColor: isDark ? '#000' : '#fff',
borderWidth: 1,
},
data: [
{
value: [148564],
name: 'UGC原创',
itemStyle: {
color: '#249EFF',
},
},
{
value: [334271],
name: '国外网站',
itemStyle: {
color: '#846BCE',
},
},
{
value: [445694],
name: '转载文章',
itemStyle: {
color: '#21CCFF',
},
},
{
value: [445694],
name: '行业报告',
itemStyle: {
color: '#0E42D2',
},
},
{
value: [445694],
name: '其他',
itemStyle: {
color: '#86DF6C',
},
},
],
},
],
}
})
const { loading } = useLoading(false)
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,99 @@
<template>
<a-card class="general-card" :title="$t('multiDAnalysis.card.title.contentTypeDistribution')" :header-style="{ paddingBottom: 0 }">
<Chart style="height: 222px" :option="chartOption" />
</a-card>
</template>
<script lang="ts" setup>
import useChartOption from '@/hooks/chart-option'
const { chartOption } = useChartOption((isDark) => {
return {
grid: {
left: 0,
right: 0,
top: 0,
bottom: 20,
},
legend: {
show: true,
top: 'center',
right: '0',
orient: 'vertical',
icon: 'circle',
itemWidth: 10,
itemHeight: 10,
itemGap: 20,
textStyle: {
color: isDark ? '#ffffff' : '#4E5969',
},
},
radar: {
center: ['40%', '50%'],
radius: 80,
indicator: [
{ name: '国际', max: 6500 },
{ name: '财经', max: 22000 },
{ name: '科技', max: 30000 },
{ name: '其他', max: 38000 },
{ name: '体育', max: 52000 },
{ name: '娱乐', max: 25000 },
],
axisName: {
color: isDark ? '#ffffff' : '#1D2129',
},
axisLine: {
lineStyle: {
color: isDark ? '#484849' : '#E5E6EB',
},
},
splitLine: {
lineStyle: {
color: isDark ? '#484849' : '#E5E6EB',
},
},
splitArea: {
areaStyle: {
color: [],
},
},
},
series: [
{
type: 'radar',
areaStyle: {
opacity: 0.2,
},
data: [
{
value: [4850, 19000, 19000, 29500, 35200, 20000],
name: '纯文本',
symbol: 'none',
itemStyle: {
color: isDark ? '#6CAAF5' : '#249EFF',
},
},
{
value: [2250, 17000, 21000, 23500, 42950, 22000],
name: '图文类',
symbol: 'none',
itemStyle: {
color: isDark ? '#A079DC' : '#313CA9',
},
},
{
value: [5850, 11000, 26000, 27500, 46950, 18000],
name: '视频类',
symbol: 'none',
itemStyle: {
color: isDark ? '#3D72F6' : '#21CCFF',
},
},
],
},
],
}
})
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,22 @@
<template>
<div>
<a-row :gutter="16">
<a-col :span="6">
<ChainItem :title="$t('multiDAnalysis.card.title.retentionTrends')" quota="retentionTrends" chart-type="line" />
</a-col>
<a-col :span="6">
<ChainItem :title="$t('multiDAnalysis.card.title.userRetention')" quota="userRetention" chart-type="bar" />
</a-col>
<a-col :span="6">
<ChainItem :title="$t('multiDAnalysis.card.title.contentConsumptionTrends')" quota="contentConsumptionTrends" chart-type="line" />
</a-col>
<a-col :span="6">
<ChainItem :title="$t('multiDAnalysis.card.title.contentConsumption')" quota="contentConsumption" chart-type="bar" />
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import ChainItem from './chain-item.vue'
</script>

View File

@@ -0,0 +1,268 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-card class="general-card" :title="$t('multiDAnalysis.card.title.dataOverview')">
<a-row justify="space-between">
<a-col v-for="(item, idx) in renderData" :key="idx" :span="6">
<a-statistic :title="item.title" :value="item.value" show-group-separator :value-from="0" animation>
<template #prefix>
<span class="statistic-prefix" :style="{ background: item.prefix.background }">
<component :is="item.prefix.icon" :style="{ color: item.prefix.iconColor }" />
</span>
</template>
</a-statistic>
</a-col>
</a-row>
<Chart style="height: 328px; margin-top: 20px" :option="chartOption" />
</a-card>
</a-spin>
</template>
<script lang="ts" setup>
import { queryDataOverview } from '@/api/visualization'
import useChartOption from '@/hooks/chart-option'
import useLoading from '@/hooks/loading'
import useThemes from '@/hooks/themes'
import { ToolTipFormatterParams } from '@/types/echarts'
import { LineSeriesOption } from 'echarts'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const tooltipItemsHtmlString = (items: ToolTipFormatterParams[]) => {
return items
.map(
(el) => `<div class="content-panel">
<p>
<span style="background-color: ${el.color}" class="tooltip-item-icon"></span><span>${el.seriesName}</span>
</p>
<span class="tooltip-value">${el.value?.toLocaleString()}</span>
</div>`
)
.reverse()
.join('')
}
const generateSeries = (name: string, lineColor: string, itemBorderColor: string, data: number[]): LineSeriesOption => {
return {
name,
data,
stack: 'Total',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 10,
itemStyle: {
color: lineColor,
},
emphasis: {
focus: 'series',
itemStyle: {
color: lineColor,
borderWidth: 2,
borderColor: itemBorderColor,
},
},
lineStyle: {
width: 2,
color: lineColor,
},
showSymbol: false,
areaStyle: {
opacity: 0.1,
color: lineColor,
},
}
}
const { t } = useI18n()
const { loading, setLoading } = useLoading(true)
const { isDark } = useThemes()
const renderData = computed(() => [
{
title: t('multiDAnalysis.dataOverview.contentProduction'),
value: 1902,
prefix: {
icon: 'icon-edit',
background: isDark.value ? '#593E2F' : '#FFE4BA',
iconColor: isDark.value ? '#F29A43' : '#F77234',
},
},
{
title: t('multiDAnalysis.dataOverview.contentClick'),
value: 2445,
prefix: {
icon: 'icon-thumb-up',
background: isDark.value ? '#3D5A62' : '#E8FFFB',
iconColor: isDark.value ? '#6ED1CE' : '#33D1C9',
},
},
{
title: t('multiDAnalysis.dataOverview.contentExposure'),
value: 3034,
prefix: {
icon: 'icon-heart',
background: isDark.value ? '#354276' : '#E8F3FF',
iconColor: isDark.value ? '#4A7FF7' : '#165DFF',
},
},
{
title: t('multiDAnalysis.dataOverview.activeUsers'),
value: 1275,
prefix: {
icon: 'icon-user',
background: isDark.value ? '#3F385E' : '#F5E8FF',
iconColor: isDark.value ? '#8558D3' : '#722ED1',
},
},
])
const xAxis = ref<string[]>([])
const contentProductionData = ref<number[]>([])
const contentClickData = ref<number[]>([])
const contentExposureData = ref<number[]>([])
const activeUsersData = ref<number[]>([])
const { chartOption } = useChartOption((dark) => {
return {
grid: {
left: '2.6%',
right: '4',
top: '40',
bottom: '40',
},
xAxis: {
type: 'category',
offset: 2,
data: xAxis.value,
boundaryGap: false,
axisLabel: {
color: '#4E5969',
formatter(value: string, idx: number) {
if (idx === 0) return ''
if (idx === xAxis.value.length - 1) return ''
return `${value}`
},
},
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
show: false,
},
axisPointer: {
show: true,
lineStyle: {
color: '#23ADFF',
width: 2,
},
},
},
yAxis: {
type: 'value',
axisLine: {
show: false,
},
axisLabel: {
formatter(value: number, idx: number) {
if (idx === 0) return String(value)
return `${value / 1000}k`
},
},
splitLine: {
lineStyle: {
color: dark ? '#2E2E30' : '#F2F3F5',
},
},
},
tooltip: {
trigger: 'axis',
formatter(params) {
const [firstElement] = params as ToolTipFormatterParams[]
return `<div>
<p class="tooltip-title">${firstElement.axisValueLabel}</p>
${tooltipItemsHtmlString(params as ToolTipFormatterParams[])}
</div>`
},
className: 'echarts-tooltip-diy',
},
graphic: {
elements: [
{
type: 'text',
left: '2.6%',
bottom: '18',
style: {
text: '12.10',
textAlign: 'center',
fill: '#4E5969',
fontSize: 12,
},
},
{
type: 'text',
right: '0',
bottom: '18',
style: {
text: '12.17',
textAlign: 'center',
fill: '#4E5969',
fontSize: 12,
},
},
],
},
series: [
generateSeries('内容生产量', '#722ED1', '#F5E8FF', contentProductionData.value),
generateSeries('内容点击量', '#F77234', '#FFE4BA', contentClickData.value),
generateSeries('内容曝光量', '#33D1C9', '#E8FFFB', contentExposureData.value),
generateSeries('活跃用户数', '#3469FF', '#E8F3FF', activeUsersData.value),
],
}
})
const fetchData = async () => {
setLoading(true)
try {
const { data } = await queryDataOverview()
xAxis.value = data.xAxis
data.data.forEach((el) => {
if (el.name === '内容生产量') {
contentProductionData.value = el.value
} else if (el.name === '内容点击量') {
contentClickData.value = el.value
} else if (el.name === '内容曝光量') {
contentExposureData.value = el.value
}
activeUsersData.value = el.value
})
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less">
:deep(.arco-statistic) {
.arco-statistic-title {
color: rgb(var(--gray-10));
font-weight: bold;
}
.arco-statistic-value {
display: flex;
align-items: center;
}
}
.statistic-prefix {
display: inline-block;
width: 32px;
height: 32px;
margin-right: 8px;
color: var(--color-white);
font-size: 16px;
line-height: 32px;
text-align: center;
vertical-align: middle;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<a-card class="general-card" :title="$t('multiDAnalysis.card.title.userActions')">
<Chart height="122px" :option="chartOption" />
</a-card>
</template>
<script lang="ts" setup>
import useChartOption from '@/hooks/chart-option'
const { chartOption } = useChartOption((isDark) => {
return {
grid: {
left: 44,
right: 20,
top: 0,
bottom: 20,
},
xAxis: {
type: 'value',
axisLabel: {
show: true,
formatter(value: number, idx: number) {
if (idx === 0) return String(value)
return `${Number(value) / 1000}k`
},
},
splitLine: {
lineStyle: {
color: isDark ? '#484849' : '#E5E8EF',
},
},
},
yAxis: {
type: 'category',
data: ['点赞量', '评论量', '分享量'],
axisLabel: {
show: true,
color: '#4E5969',
},
axisTick: {
show: true,
length: 2,
lineStyle: {
color: '#A9AEB8',
},
alignWithLabel: true,
},
axisLine: {
lineStyle: {
color: isDark ? '#484849' : '#A9AEB8',
},
},
},
tooltip: {
show: true,
trigger: 'axis',
},
series: [
{
data: [1033, 1244, 1520],
type: 'bar',
barWidth: 7,
itemStyle: {
color: '#4086FF',
borderRadius: 4,
},
},
],
}
})
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.visualization', 'menu.visualization.multiDimensionDataAnalysis']" />
<a-space direction="vertical" :size="16" fill>
<a-grid :cols="24" :col-gap="16" :row-gap="16">
<a-grid-item :span="{ xs: 24, sm: 24, md: 24, lg: 18, xl: 18, xxl: 18 }">
<DataOverview />
</a-grid-item>
<a-grid-item :span="{ xs: 24, sm: 24, md: 24, lg: 6, xl: 6, xxl: 6 }">
<UserActions style="margin-bottom: 16px" />
<ContentTypeDistribution />
</a-grid-item>
</a-grid>
<DataChainGrowth />
<ContentPublishingSource />
</a-space>
</div>
</template>
<script lang="ts" setup>
import DataOverview from './components/data-overview.vue'
import DataChainGrowth from './components/data-chain-growth.vue'
import UserActions from './components/user-actions.vue'
import ContentTypeDistribution from './components/content-type-distribution.vue'
import ContentPublishingSource from './components/content-publishing-source.vue'
</script>
<script lang="ts">
export default {
name: 'MultiDimensionDataAnalysis',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
:deep(.section-title) {
margin-top: 0;
margin-bottom: 16px;
font-size: 16px;
}
:deep(.chart-wrap) {
height: 264px;
}
</style>

View File

@@ -0,0 +1,15 @@
export default {
'menu.visualization.multiDimensionDataAnalysis': 'Multi-D Analysis',
'multiDAnalysis.card.title.dataOverview': 'Overview',
'multiDAnalysis.dataOverview.contentProduction': 'Content Production',
'multiDAnalysis.dataOverview.contentClick': 'Content Click',
'multiDAnalysis.dataOverview.contentExposure': 'Content Exposure',
'multiDAnalysis.dataOverview.activeUsers': 'Active Users',
'multiDAnalysis.card.title.userActions': 'User Actions',
'multiDAnalysis.card.title.contentTypeDistribution': 'Content Type Distribution',
'multiDAnalysis.card.title.retentionTrends': 'Retention Trends',
'multiDAnalysis.card.title.userRetention': 'User Retention',
'multiDAnalysis.card.title.contentConsumptionTrends': 'Content Consumption Trends',
'multiDAnalysis.card.title.contentConsumption': 'Content Consumption',
'multiDAnalysis.card.title.contentPublishingSource': 'Content Publishing Source',
}

View File

@@ -0,0 +1,15 @@
export default {
'menu.visualization.multiDimensionDataAnalysis': '多维数据分析',
'multiDAnalysis.card.title.dataOverview': '数据总览',
'multiDAnalysis.dataOverview.contentProduction': '内容生产量',
'multiDAnalysis.dataOverview.contentClick': '内容点击量',
'multiDAnalysis.dataOverview.contentExposure': '内容曝光量',
'multiDAnalysis.dataOverview.activeUsers': '活跃用户数',
'multiDAnalysis.card.title.userActions': '今日转评赞统计',
'multiDAnalysis.card.title.contentTypeDistribution': '内容题材分布',
'multiDAnalysis.card.title.retentionTrends': '用户留存趋势',
'multiDAnalysis.card.title.userRetention': '用户留存量',
'multiDAnalysis.card.title.contentConsumptionTrends': '内容消费趋势',
'multiDAnalysis.card.title.contentConsumption': '内容消费量',
'multiDAnalysis.card.title.contentPublishingSource': '内容发布来源',
}

View File

@@ -0,0 +1,47 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
import { PostData } from '@/types/global'
setupMock({
setup() {
Mock.mock(new RegExp('/api/data-chain-growth'), (params: PostData) => {
const { quota } = JSON.parse(params.body)
const getLineData = () => {
return {
xAxis: new Array(12).fill(0).map((_item, index) => `${index + 1}`),
data: {
name: quota,
value: new Array(12).fill(0).map(() => Mock.Random.natural(1000, 3000)),
},
}
}
return successResponseWrap({
count: Mock.Random.natural(1000, 3000),
growth: Mock.Random.float(20, 100, 2, 2),
chartData: getLineData(),
})
})
// v2
Mock.mock(new RegExp('/api/data-overview'), () => {
const generateLineData = (name: string) => {
return {
name,
count: Mock.Random.natural(20, 2000),
value: new Array(8).fill(0).map(() => Mock.Random.natural(800, 4000)),
}
}
const xAxis = new Array(8).fill(0).map((_item, index) => {
return `12.1${index}`
})
return successResponseWrap({
xAxis,
data: [
generateLineData('内容生产量'),
generateLineData('内容点击量'),
generateLineData('内容曝光量'),
generateLineData('活跃用户数'),
],
})
})
},
})