feat: 应用概览增加监控统计

This commit is contained in:
wangdan-fit2cloud 2024-03-29 15:45:04 +08:00
parent 05d01144f9
commit e382bb6bba
10 changed files with 618 additions and 78 deletions

View File

@ -14,6 +14,7 @@
},
"dependencies": {
"axios": "^0.28.0",
"echarts": "^5.5.0",
"element-plus": "^2.5.6",
"install": "^0.13.0",
"lodash": "^4.17.21",
@ -29,6 +30,7 @@
"md-editor-v3": "^4.12.1",
"medium-zoom": "^1.1.0",
"mitt": "^3.0.0",
"moment": "^2.30.1",
"npm": "^10.2.4",
"nprogress": "^0.2.0",
"pinia": "^2.1.6",

View File

@ -55,9 +55,22 @@ const putAPIKey: (
return put(`${prefix}/${applicaiton_id}/api_key/${api_key_id}`, data, undefined, loading)
}
/**
*
* @param applicaiton_id, data
*/
const getStatistics: (
applicaiton_id: string,
data: any,
loading?: Ref<boolean>
) => Promise<Result<any>> = (applicaiton_id, data, loading) => {
return get(`${prefix}/${applicaiton_id}/statistics/chat_record_aggregate_trend`, data, loading)
}
export default {
getAPIKey,
postAPIKey,
delAPIKey,
putAPIKey
putAPIKey,
getStatistics
}

View File

@ -0,0 +1,128 @@
<template>
<div :id="id" ref="PieChartRef" :style="{ height: height, width: width }" />
</template>
<script lang="ts" setup>
import { onMounted, nextTick, watch, onBeforeUnmount } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
id: {
type: String,
default: 'lineChartId'
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '200px'
},
option: {
type: Object,
required: true
} // option: { title , data }
})
const color = ['rgba(82, 133, 255, 1)', 'rgba(255, 207, 47, 1)']
const areaColor = ['rgba(82, 133, 255, 0.2)', 'rgba(255, 207, 47, 0.2)']
function initChart() {
let myChart = echarts?.getInstanceByDom(document.getElementById(props.id)!)
if (myChart === null || myChart === undefined) {
myChart = echarts.init(document.getElementById(props.id))
}
const series: any = []
if (props.option?.yDatas?.length) {
props.option?.yDatas.forEach((item: any, index: number) => {
series.push({
itemStyle: {
color: color[index]
},
areaStyle: item.area
? {
color: areaColor[index]
}
: null,
...item
})
})
}
const option = {
title: {
text: props.option?.title,
textStyle: {
fontSize: '16px'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
}
},
legend: {
right: 0,
itemWidth: 8,
textStyle: {
color: '#646A73'
},
icon: 'circle'
},
grid: {
left: '1%',
right: '1%',
bottom: '0',
top: '18%',
containLabel: true
},
xAxis: {
type: 'category',
data: props.option.xDatas
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: '#EFF0F1'
}
}
},
series: series
}
//
myChart.setOption(option, true)
}
function changeChartSize() {
echarts.getInstanceByDom(document.getElementById(props.id)!)?.resize()
}
watch(
() => props.option,
(val) => {
if (val) {
nextTick(() => {
initChart()
})
}
}
)
onMounted(() => {
nextTick(() => {
initChart()
window.addEventListener('resize', changeChartSize)
})
})
onBeforeUnmount(() => {
echarts.getInstanceByDom(document.getElementById(props.id)!)?.dispose()
window.removeEventListener('resize', changeChartSize)
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,30 @@
<template>
<component
:is="typeComponentMap[type]"
:height="height"
:option="option"
:dataZoom="dataZoom"
class="v-charts"
/>
</template>
<script lang="ts" setup>
import line from './components/LineCharts.vue'
defineOptions({ name: 'AppCharts' })
defineProps({
type: {
type: String,
default: 'line'
},
height: {
type: String,
default: '200px'
},
dataZoom: Boolean,
option: {
type: Object,
required: true
} // { title , xDatas, yDatas, formatStr }
})
const typeComponentMap = { line } as any
</script>

View File

@ -640,5 +640,93 @@ export const iconMap: any = {
)
])
}
},
'app-user': {
iconReader: () => {
return h('i', [
h(
'svg',
{
style: { height: '100%', width: '100%' },
viewBox: '0 0 24 24',
version: '1.1',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M15 13H9C6.23858 13 3 14.9314 3 18.4V21.1C3 21.597 3.44772 22 4 22H20C20.5523 22 21 21.597 21 21.1V18.4C21 14.9285 17.7614 13 15 13Z',
fill: 'currentColor'
}),
h('path', {
d: 'M7 6.99997C7 9.76139 9.23858 12 12 12C14.7614 12 17 9.76139 17 6.99997C17 4.23855 14.7614 1.99997 12 1.99997C9.23858 1.99997 7 4.23855 7 6.99997Z',
fill: 'currentColor'
})
]
)
])
}
},
'app-question': {
iconReader: () => {
return h('i', [
h(
'svg',
{
style: { height: '100%', width: '100%' },
viewBox: '0 0 24 24',
version: '1.1',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M12.7071 22.2009L17 18.5111H21.5C22.0523 18.5111 22.5 18.0539 22.5 17.4899V2.52112C22.5 1.95715 22.0523 1.49997 21.5 1.49997H2C1.44772 1.49997 1 1.95715 1 2.52112V17.4899C1 18.0539 1.44772 18.5111 2 18.5111H7L11.2929 22.2009C11.6834 22.5997 12.3166 22.5997 12.7071 22.2009ZM6.5 8.49997H7.5C8.05228 8.49997 8.5 8.94768 8.5 9.49997V10.5C8.5 11.0523 8.05228 11.5 7.5 11.5H6.5C5.94772 11.5 5.5 11.0523 5.5 10.5V9.49997C5.5 8.94768 5.94772 8.49997 6.5 8.49997ZM10.5 9.49997C10.5 8.94768 10.9477 8.49997 11.5 8.49997H12.5C13.0523 8.49997 13.5 8.94768 13.5 9.49997V10.5C13.5 11.0523 13.0523 11.5 12.5 11.5H11.5C10.9477 11.5 10.5 11.0523 10.5 10.5V9.49997ZM16.5 8.49997H17.5C18.0523 8.49997 18.5 8.94768 18.5 9.49997V10.5C18.5 11.0523 18.0523 11.5 17.5 11.5H16.5C15.9477 11.5 15.5 11.0523 15.5 10.5V9.49997C15.5 8.94768 15.9477 8.49997 16.5 8.49997Z',
fill: 'currentColor'
})
]
)
])
}
},
'app-tokens': {
iconReader: () => {
return h('i', [
h(
'svg',
{
style: { height: '100%', width: '100%' },
viewBox: '0 0 24 24',
version: '1.1',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M15.6 2.39996C12.288 2.39996 9.60002 5.08796 9.60002 8.39996C9.60002 9.11996 9.74402 9.79196 9.97202 10.428L2.47325 17.9267C2.42636 17.9736 2.40002 18.0372 2.40002 18.1035V21.1C2.40002 21.3761 2.62388 21.6 2.90002 21.6H4.30002C4.57617 21.6 4.80002 21.3761 4.80002 21.1V20.4H6.70003C6.97617 20.4 7.20002 20.1761 7.20002 19.9V18H8.40002L10.8 15.6H12L13.572 14.028C14.208 14.256 14.88 14.4 15.6 14.4C18.912 14.4 21.6 11.712 21.6 8.39996C21.6 5.08796 18.912 2.39996 15.6 2.39996ZM17.4 8.39996C16.404 8.39996 15.6 7.59596 15.6 6.59996C15.6 5.60396 16.404 4.79996 17.4 4.79996C18.396 4.79996 19.2 5.60396 19.2 6.59996C19.2 7.59596 18.396 8.39996 17.4 8.39996Z',
fill: 'currentColor'
})
]
)
])
}
},
'app-user-stars': {
iconReader: () => {
return h('i', [
h(
'svg',
{
style: { height: '100%', width: '100%' },
viewBox: '0 0 24 24',
version: '1.1',
xmlns: 'http://www.w3.org/2000/svg'
},
[
h('path', {
d: 'M12 23C18.0751 23 23 18.0751 23 12C23 5.92484 18.0751 0.999969 12 0.999969C5.92487 0.999969 1 5.92484 1 12C1 18.0751 5.92487 23 12 23ZM8.5 10.5C7.67157 10.5 7 9.8284 7 8.99997C7 8.17154 7.67157 7.49997 8.5 7.49997C9.32843 7.49997 10 8.17154 10 8.99997C10 9.8284 9.32843 10.5 8.5 10.5ZM17 8.99997C17 9.8284 16.3284 10.5 15.5 10.5C14.6716 10.5 14 9.8284 14 8.99997C14 8.17154 14.6716 7.49997 15.5 7.49997C16.3284 7.49997 17 8.17154 17 8.99997ZM16.9779 13.4994C16.7521 16.0264 14.8169 18 12 18C9.18312 18 7.24789 16.0264 7.02213 13.4994C6.99756 13.2244 7.22386 13 7.5 13H16.5C16.7761 13 17.0024 13.2244 16.9779 13.4994Z',
fill: 'currentColor'
})
]
)
])
}
}
}

View File

@ -105,6 +105,9 @@ h4 {
.h-full {
height: 100%;
}
.w-120 {
width: 120px;
}
.w-240 {
width: 240px;
}
@ -206,6 +209,10 @@ h4 {
align-items: center;
}
.align-baseline {
align-items: baseline;
}
.text-left {
text-align: left;
}
@ -424,9 +431,13 @@ h4 {
color: var(--el-color-info);
}
.color-secondary {
color: var(--app-text-color-secondary);
}
.app-warning-icon {
font-size: 16px;
color: #646a73;
color: var(--app-text-color-secondary);
}
.dotting {

View File

@ -1,3 +1,13 @@
import moment from 'moment'
// 当天日期 YYYY-MM-DD
export const nowDate = moment().format('YYYY-MM-DD')
// 当前时间的前n天
export function beforeDay(n) {
return moment().subtract(n, 'days').format('YYYY-MM-DD')
}
const getCheckDate = (timestamp: any) => {
if (!timestamp) return false
const dt = new Date(timestamp)

View File

@ -64,3 +64,15 @@ export function isAllPropertiesEmpty(obj: object) {
value === null || typeof value === 'undefined' || (typeof value === 'string' && !value)
)
}
// 数组对象中某一属性值的集合
export function getAttrsArray(array, attr) {
return array.map((item) => {
return item[attr]
})
}
// 求和
export function getSum(array) {
return array.reduce((totol, item) => totol + item, 0)
}

View File

@ -0,0 +1,161 @@
<template>
<el-row :gutter="16">
<el-col
:xs="12"
:sm="12"
:md="12"
:lg="6"
:xl="6"
v-for="(item, index) in statisticsType"
:key="index"
class="mb-16"
>
<el-card shadow="never">
<div class="flex align-center ml-8 mr-8">
<el-avatar :size="40" shape="square" :style="{ background: item.background }">
<appIcon :iconName="item.icon" :style="{ fontSize: '24px', color: item.color }" />
</el-avatar>
<div class="ml-12">
<p class="color-secondary lighter mb-4">{{ item.name }}</p>
<div v-if="item.id !== 'starCharts'" class="flex align-baseline">
<h2>{{ numberFormat(item.sum?.[0]) }}</h2>
<span v-if="item.sum.length > 1" class="ml-12" style="color: #f54a45"
>+{{ numberFormat(item.sum?.[1]) }}</span
>
</div>
<div v-else class="flex align-center mr-8">
<AppIcon iconName="app-like-color"></AppIcon>
<h2 class="ml-4">{{ item.sum?.[0] }}</h2>
<AppIcon class="ml-12" iconName="app-oppose-color"></AppIcon>
<h2 class="ml-4">{{ item.sum?.[1] }}</h2>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col
:xs="24"
:sm="24"
:md="24"
:lg="12"
:xl="12"
v-for="(item, index) in statisticsType"
:key="index"
class="mb-16"
>
<el-card shadow="never">
<div class="p-8">
<AppCharts height="316px" :id="item.id" type="line" :option="item.option" />
</div>
</el-card>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import AppCharts from '@/components/app-charts/index.vue'
import { getAttrsArray, getSum, numberFormat } from '@/utils/utils'
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const statisticsType = computed(() => [
{
id: 'customerCharts',
name: '用户总数',
icon: 'app-user',
background: '#EBF1FF',
color: '#3370FF',
sum: [
getSum(getAttrsArray(props.data, 'customer_num') || 0),
getSum(getAttrsArray(props.data, 'customer_added_count') || 0)
],
option: {
title: '用户总数',
xDatas: getAttrsArray(props.data, 'day'),
yDatas: [
{
name: '用户总数',
type: 'line',
area: true,
data: getAttrsArray(props.data, 'customer_num')
},
{
name: '用户新增数',
type: 'line',
area: true,
data: getAttrsArray(props.data, 'customer_added_count')
}
]
}
},
{
id: 'chatRecordCharts',
name: '提问次数',
icon: 'app-question',
background: '#FFF3E5',
color: '#FF8800',
sum: [getSum(getAttrsArray(props.data, 'chat_record_count') || 0)],
option: {
title: '提问次数',
xDatas: getAttrsArray(props.data, 'day'),
yDatas: [
{
type: 'line',
data: getAttrsArray(props.data, 'chat_record_count')
}
]
}
},
{
id: 'tokensCharts',
name: 'Tokens 总数',
icon: 'app-tokens',
background: '#E5FBF8',
color: '#00D6B9',
sum: [getSum(getAttrsArray(props.data, 'tokens_num') || 0)],
option: {
title: 'Tokens 总数',
xDatas: getAttrsArray(props.data, 'day'),
yDatas: [
{
type: 'line',
data: getAttrsArray(props.data, 'tokens_num')
}
]
}
},
{
id: 'starCharts',
name: '用户满意度',
icon: 'app-user-stars',
background: '#FEEDEC',
color: '#F54A45',
sum: [
getSum(getAttrsArray(props.data, 'star_num') || 0),
getSum(getAttrsArray(props.data, 'trample_num') || 0)
],
option: {
title: '用户满意度',
xDatas: getAttrsArray(props.data, 'day'),
yDatas: [
{
name: '赞同',
type: 'line',
data: getAttrsArray(props.data, 'star_num')
},
{
name: '反对',
type: 'line',
data: getAttrsArray(props.data, 'trample_num')
}
]
}
}
])
</script>
<style lang="scss" scoped></style>

View File

@ -1,78 +1,104 @@
<template>
<LayoutContainer header="概览">
<div class="main-calc-height p-24" style="min-width: 600px">
<h4 class="title-decoration-1 mb-16">应用信息</h4>
<el-card shadow="never" class="overview-card" v-loading="loading">
<div class="title flex align-center">
<AppAvatar
v-if="detail?.name"
:name="detail?.name"
pinyinColor
class="mr-12"
shape="square"
:size="32"
<el-scrollbar>
<div class="main-calc-height p-24">
<h4 class="title-decoration-1 mb-16">应用信息</h4>
<el-card shadow="never" class="overview-card" v-loading="loading">
<div class="title flex align-center">
<AppAvatar
v-if="detail?.name"
:name="detail?.name"
pinyinColor
class="mr-12"
shape="square"
:size="32"
/>
<h4>{{ detail?.name }}</h4>
</div>
<el-row :gutter="12">
<el-col :span="12" class="mt-16">
<div class="flex">
<el-text type="info">公开访问链接</el-text>
<el-switch
v-model="accessToken.is_active"
class="ml-8"
size="small"
inline-prompt
active-text="开"
inactive-text="关"
@change="changeState($event)"
/>
</div>
<div class="mt-4 mb-16 url-height">
<span class="vertical-middle lighter break-all">
{{ shareUrl }}
</span>
<el-button type="primary" text @click="copyClick(shareUrl)">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
<el-button @click="refreshAccessToken" type="primary" text style="margin-left: 1px">
<el-icon><RefreshRight /></el-icon>
</el-button>
</div>
<div>
<el-button :disabled="!accessToken?.is_active" type="primary">
<a v-if="accessToken?.is_active" :href="shareUrl" target="_blank"> 演示 </a>
<span v-else>演示</span>
</el-button>
<el-button :disabled="!accessToken?.is_active" @click="openDialog">
嵌入第三方
</el-button>
<el-button @click="openLimitDialog"> 访问限制 </el-button>
</div>
</el-col>
<el-col :span="12" class="mt-16">
<div class="flex">
<el-text type="info">API访问凭据</el-text>
</div>
<div class="mt-4 mb-16 url-height">
<span class="vertical-middle lighter break-all">
{{ apiUrl }}
</span>
<el-button type="primary" text @click="copyClick(apiUrl)">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
</div>
<div>
<el-button @click="openAPIKeyDialog"> API Key </el-button>
</div>
</el-col>
</el-row>
</el-card>
<h4 class="title-decoration-1 mt-16 mb-16">监控统计</h4>
<div class="mb-16">
<el-select v-model="history_day" class="mr-12 w-120" @change="changeDayHandle">
<el-option
v-for="item in dayOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-date-picker
v-if="history_day === 'other'"
v-model="daterangeValue"
type="daterange"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="changeDayRangeHandle"
/>
<h4>{{ detail?.name }}</h4>
</div>
<el-row :gutter="12">
<el-col :span="12" class="mt-16">
<div class="flex">
<el-text type="info">公开访问链接</el-text>
<el-switch
v-model="accessToken.is_active"
class="ml-8"
size="small"
inline-prompt
active-text="开"
inactive-text="关"
@change="changeState($event)"
/>
</div>
<div class="mt-4 mb-16 url-height">
<span class="vertical-middle lighter break-all">
{{ shareUrl }}
</span>
<el-button type="primary" text @click="copyClick(shareUrl)">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
<el-button @click="refreshAccessToken" type="primary" text style="margin-left: 1px">
<el-icon><RefreshRight /></el-icon>
</el-button>
</div>
<div>
<el-button :disabled="!accessToken?.is_active" type="primary">
<a v-if="accessToken?.is_active" :href="shareUrl" target="_blank"> 演示 </a>
<span v-else>演示</span>
</el-button>
<el-button :disabled="!accessToken?.is_active" @click="openDialog">
嵌入第三方
</el-button>
<el-button @click="openLimitDialog"> 访问限制 </el-button>
</div>
</el-col>
<el-col :span="12" class="mt-16">
<div class="flex">
<el-text type="info">API访问凭据</el-text>
</div>
<div class="mt-4 mb-16 url-height">
<span class="vertical-middle lighter break-all">
{{ apiUrl }}
</span>
<el-button type="primary" text @click="copyClick(apiUrl)">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
</div>
<div>
<el-button @click="openAPIKeyDialog"> API Key </el-button>
</div>
</el-col>
</el-row>
</el-card>
</div>
<div v-loading="statisticsLoading">
<StatisticsCharts :data="statisticsData" />
</div>
</div>
</el-scrollbar>
<EmbedDialog ref="EmbedDialogRef" />
<APIKeyDialog ref="APIKeyDialogRef" />
<LimitDialog ref="LimitDialogRef" @refresh="refresh" />
@ -80,16 +106,20 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRoute } from 'vue-router'
import EmbedDialog from './component/EmbedDialog.vue'
import APIKeyDialog from './component/APIKeyDialog.vue'
import LimitDialog from './component/LimitDialog.vue'
import StatisticsCharts from './component/StatisticsCharts.vue'
import applicationApi from '@/api/application'
import overviewApi from '@/api/application-overview'
import { nowDate, beforeDay } from '@/utils/time'
import { MsgSuccess, MsgConfirm } from '@/utils/message'
import { copyClick } from '@/utils/clipboard'
import useStore from '@/stores'
const { application } = useStore()
const router = useRouter()
const route = useRoute()
const {
params: { id }
@ -108,6 +138,63 @@ const loading = ref(false)
const shareUrl = computed(() => application.location + accessToken.value.access_token)
const dayOptions = [
{
value: 7,
label: '过去7天'
},
{
value: 30,
label: '过去30天'
},
{
value: 90,
label: '过去90天'
},
{
value: 183,
label: '过去半年'
},
{
value: 'other',
label: '自定义'
}
]
const history_day = ref<number | string>(7)
//
const daterangeValue = ref('')
//
const daterange = ref({
start_time: '',
end_time: ''
})
const statisticsLoading = ref(false)
const statisticsData = ref([])
function changeDayHandle(val: number | string) {
if (val !== 'other') {
daterange.value.start_time = beforeDay(val)
daterange.value.end_time = nowDate
getAppStatistics()
}
}
function changeDayRangeHandle(val: string) {
daterange.value.start_time = val[0]
daterange.value.end_time = val[1]
getAppStatistics()
}
function getAppStatistics() {
overviewApi.getStatistics(id, daterange.value, statisticsLoading).then((res: any) => {
statisticsData.value = res.data
})
}
function refreshAccessToken() {
const obj = {
access_token_reset: true
@ -158,6 +245,7 @@ function refresh() {
onMounted(() => {
getDetail()
getAccessToken()
changeDayHandle(history_day.value)
})
</script>
<style lang="scss" scoped>
@ -168,8 +256,5 @@ onMounted(() => {
right: 16px;
top: 21px;
}
.url-height {
// min-height: 50px;
}
}
</style>