修复
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -96,7 +96,7 @@ public class AiUsageStatsServiceImpl implements AiUsageStatsService {
|
|||||||
public PageResult<AiUsageUserStatsRespVO> getUserStatsPage(AiUsageStatsPageReqVO reqVO) {
|
public PageResult<AiUsageUserStatsRespVO> getUserStatsPage(AiUsageStatsPageReqVO reqVO) {
|
||||||
// 查询用户统计数据
|
// 查询用户统计数据
|
||||||
List<Map<String, Object>> userStatsList = pointRecordMapper.selectUserStats(
|
List<Map<String, Object>> userStatsList = pointRecordMapper.selectUserStats(
|
||||||
reqVO.getStartTime(), reqVO.getEndTime(), reqVO.getBizType(), reqVO.getUserId());
|
reqVO.getStartTime(), reqVO.getEndTime(), reqVO.getBizType(), reqVO.getUserId(), reqVO.getMobile());
|
||||||
|
|
||||||
// 计算总数
|
// 计算总数
|
||||||
long total = userStatsList.size();
|
long total = userStatsList.size();
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ public class AiUsageStatsPageReqVO extends SortablePageParam {
|
|||||||
@Schema(description = "用户ID")
|
@Schema(description = "用户ID")
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
|
||||||
|
@Schema(description = "手机号")
|
||||||
|
private String mobile;
|
||||||
|
|
||||||
@Schema(description = "服务标识")
|
@Schema(description = "服务标识")
|
||||||
private String serviceCode;
|
private String serviceCode;
|
||||||
|
|
||||||
|
|||||||
@@ -78,13 +78,15 @@ public interface PointRecordMapper extends BaseMapperX<PointRecordDO> {
|
|||||||
"<if test='endTime != null'> AND create_time <= #{endTime}</if>" +
|
"<if test='endTime != null'> AND create_time <= #{endTime}</if>" +
|
||||||
"<if test='bizType != null and bizType != \"\"'> AND biz_type = #{bizType}</if>" +
|
"<if test='bizType != null and bizType != \"\"'> AND biz_type = #{bizType}</if>" +
|
||||||
"<if test='userId != null'> AND user_id = #{userId}</if>" +
|
"<if test='userId != null'> AND user_id = #{userId}</if>" +
|
||||||
|
"<if test='mobile != null and mobile != \"\"'> AND mobile LIKE CONCAT('%', #{mobile}, '%')</if>" +
|
||||||
"GROUP BY user_id, mobile " +
|
"GROUP BY user_id, mobile " +
|
||||||
"ORDER BY consumePoints DESC" +
|
"ORDER BY consumePoints DESC" +
|
||||||
"</script>")
|
"</script>")
|
||||||
List<Map<String, Object>> selectUserStats(@Param("startTime") LocalDateTime startTime,
|
List<Map<String, Object>> selectUserStats(@Param("startTime") LocalDateTime startTime,
|
||||||
@Param("endTime") LocalDateTime endTime,
|
@Param("endTime") LocalDateTime endTime,
|
||||||
@Param("bizType") String bizType,
|
@Param("bizType") String bizType,
|
||||||
@Param("userId") Long userId);
|
@Param("userId") Long userId,
|
||||||
|
@Param("mobile") String mobile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按应用统计
|
* 按应用统计
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const getAiUsageUserStatsPage = (params: {
|
|||||||
endTime?: string
|
endTime?: string
|
||||||
bizType?: string
|
bizType?: string
|
||||||
userId?: number
|
userId?: number
|
||||||
|
mobile?: string
|
||||||
}) => {
|
}) => {
|
||||||
return request.get({ url: '/muye/ai-usage-stats/user-stats', params })
|
return request.get({ url: '/muye/ai-usage-stats/user-stats', params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-stats-panel">
|
||||||
|
<!-- 筛选条件 -->
|
||||||
|
<el-form :model="queryParams" :inline="true" class="mb-16px">
|
||||||
|
<el-form-item label="应用">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.serviceCode"
|
||||||
|
placeholder="全部应用"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
class="!w-200px"
|
||||||
|
@change="handleQuery"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="app in appOptions"
|
||||||
|
:key="app.serviceCode"
|
||||||
|
:label="app.serviceName || app.serviceCode"
|
||||||
|
:value="app.serviceCode"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleQuery">
|
||||||
|
<Icon icon="ep:search" class="mr-4px" /> 查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">
|
||||||
|
<Icon icon="ep:refresh" class="mr-4px" /> 重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<el-row :gutter="16" class="mb-16px">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-blue">
|
||||||
|
<Icon icon="ep:menu" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">应用数量</div>
|
||||||
|
<div class="stat-value">{{ summary.appCount?.toLocaleString() || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-green">
|
||||||
|
<Icon icon="ep:phone" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">总调用次数</div>
|
||||||
|
<div class="stat-value">{{ summary.totalCallCount?.toLocaleString() || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-orange">
|
||||||
|
<Icon icon="ep:coin" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">总消耗积分</div>
|
||||||
|
<div class="stat-value">{{ summary.totalConsumePoints?.toLocaleString() || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-purple">
|
||||||
|
<Icon icon="ep:data-analysis" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">平均积分/应用</div>
|
||||||
|
<div class="stat-value">{{ avgPointsPerApp }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 图表区域 -->
|
||||||
|
<el-row :gutter="16" class="mb-16px">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>应用积分消耗排行 TOP10</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="rankChartRef" class="chart-container"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>平台占比分布</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="pieChartRef" class="chart-container"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>应用详细统计</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table v-loading="loading" :data="filteredDataList" :stripe="true">
|
||||||
|
<el-table-column label="服务标识" align="center" prop="serviceCode" width="150" />
|
||||||
|
<el-table-column label="服务名称" align="center" prop="serviceName" />
|
||||||
|
<el-table-column label="平台" align="center" prop="platform" width="100" />
|
||||||
|
<el-table-column label="调用次数" align="center" prop="callCount" sortable>
|
||||||
|
<template #default="{ row }">{{ row.callCount?.toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="消耗积分" align="center" prop="consumePoints" sortable>
|
||||||
|
<template #default="{ row }">{{ row.consumePoints?.toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="总Token" align="center" prop="totalTokens" sortable>
|
||||||
|
<template #default="{ row }">{{ row.totalTokens?.toLocaleString() || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="平均积分/次" align="center" prop="avgPointsPerCall" sortable>
|
||||||
|
<template #default="{ row }">{{ row.avgPointsPerCall?.toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="平均Token/次" align="center" prop="avgTokensPerCall" sortable>
|
||||||
|
<template #default="{ row }">{{ row.avgTokensPerCall?.toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { getAiUsageAppStats, type AiUsageAppStats } from '@/api/muye/aiusagestats'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
timeRange: string[]
|
||||||
|
bizType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// 查询参数
|
||||||
|
const queryParams = reactive({
|
||||||
|
serviceCode: undefined as string | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataList = ref<AiUsageAppStats[]>([])
|
||||||
|
const appOptions = ref<{ serviceCode: string; serviceName: string }[]>([])
|
||||||
|
|
||||||
|
// 筛选后的数据
|
||||||
|
const filteredDataList = computed(() => {
|
||||||
|
if (!queryParams.serviceCode) return dataList.value
|
||||||
|
return dataList.value.filter(d => d.serviceCode === queryParams.serviceCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 汇总数据
|
||||||
|
const summary = computed(() => {
|
||||||
|
const data = filteredDataList.value
|
||||||
|
const appCount = data.length
|
||||||
|
const totalCallCount = data.reduce((sum, item) => sum + (item.callCount || 0), 0)
|
||||||
|
const totalConsumePoints = data.reduce((sum, item) => sum + (item.consumePoints || 0), 0)
|
||||||
|
return { appCount, totalCallCount, totalConsumePoints }
|
||||||
|
})
|
||||||
|
|
||||||
|
const avgPointsPerApp = computed(() => {
|
||||||
|
if (summary.value.appCount === 0) return '0'
|
||||||
|
return (summary.value.totalConsumePoints / summary.value.appCount).toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 图表
|
||||||
|
const rankChartRef = ref<HTMLElement>()
|
||||||
|
const pieChartRef = ref<HTMLElement>()
|
||||||
|
let rankChart: echarts.ECharts | null = null
|
||||||
|
let pieChart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
const initCharts = () => {
|
||||||
|
if (rankChartRef.value && !rankChart) {
|
||||||
|
rankChart = echarts.init(rankChartRef.value)
|
||||||
|
}
|
||||||
|
if (pieChartRef.value && !pieChart) {
|
||||||
|
pieChart = echarts.init(pieChartRef.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawCharts = () => {
|
||||||
|
initCharts()
|
||||||
|
const data = filteredDataList.value
|
||||||
|
|
||||||
|
// 积分消耗排行 TOP10
|
||||||
|
const topByPoints = [...data]
|
||||||
|
.sort((a, b) => (b.consumePoints || 0) - (a.consumePoints || 0))
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
if (rankChart && topByPoints.length > 0) {
|
||||||
|
rankChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: 120, right: 20, top: 20, bottom: 20 },
|
||||||
|
xAxis: { type: 'value', name: '积分' },
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: topByPoints.map(d => d.serviceName || d.serviceCode).reverse(),
|
||||||
|
axisLabel: { width: 100, overflow: 'truncate' }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
data: topByPoints.map(d => d.consumePoints).reverse(),
|
||||||
|
itemStyle: { color: '#e6a23c' }
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台占比饼图
|
||||||
|
const platformStats = new Map<string, number>()
|
||||||
|
data.forEach(item => {
|
||||||
|
const platform = item.platform || '未知'
|
||||||
|
platformStats.set(platform, (platformStats.get(platform) || 0) + (item.consumePoints || 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieData = Array.from(platformStats.entries())
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
|
||||||
|
if (pieChart && pieData.length > 0) {
|
||||||
|
pieChart.setOption({
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
legend: { orient: 'vertical', left: 'left', top: 'center' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
center: ['60%', '50%'],
|
||||||
|
data: pieData,
|
||||||
|
emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }},
|
||||||
|
label: { show: false }
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据
|
||||||
|
const getDataList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [startTime, endTime] = props.timeRange || []
|
||||||
|
const res = await getAiUsageAppStats({
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
bizType: props.bizType
|
||||||
|
})
|
||||||
|
dataList.value = res || []
|
||||||
|
|
||||||
|
// 构建应用选项
|
||||||
|
appOptions.value = (res || []).map(item => ({
|
||||||
|
serviceCode: item.serviceCode,
|
||||||
|
serviceName: item.serviceName || item.serviceCode
|
||||||
|
}))
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
drawCharts()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询
|
||||||
|
const handleQuery = () => {
|
||||||
|
nextTick(() => drawCharts())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.serviceCode = undefined
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听外部参数变化
|
||||||
|
watch(() => [props.timeRange, props.bizType], () => {
|
||||||
|
getDataList()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 窗口大小变化
|
||||||
|
const handleResize = () => {
|
||||||
|
rankChart?.resize()
|
||||||
|
pieChart?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getDataList()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
rankChart?.dispose()
|
||||||
|
pieChart?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.app-stats-panel {
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
margin-right: 16px;
|
||||||
|
|
||||||
|
&.bg-blue { background: linear-gradient(135deg, #409eff, #3375b9); }
|
||||||
|
&.bg-orange { background: linear-gradient(135deg, #e6a23c, #cf9236); }
|
||||||
|
&.bg-green { background: linear-gradient(135deg, #67c23a, #529b2e); }
|
||||||
|
&.bg-purple { background: linear-gradient(135deg, #909399, #73767a); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-stats-panel">
|
||||||
|
<!-- 筛选条件 -->
|
||||||
|
<el-form :model="queryParams" :inline="true" class="mb-16px">
|
||||||
|
<el-form-item label="手机号">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.mobile"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
clearable
|
||||||
|
class="!w-200px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleQuery">
|
||||||
|
<Icon icon="ep:search" class="mr-4px" /> 查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">
|
||||||
|
<Icon icon="ep:refresh" class="mr-4px" /> 重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<el-row :gutter="16" class="mb-16px">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-blue">
|
||||||
|
<Icon icon="ep:user" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">活跃用户数</div>
|
||||||
|
<div class="stat-value">{{ summary.activeUserCount?.toLocaleString() || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-green">
|
||||||
|
<Icon icon="ep:phone" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">总调用次数</div>
|
||||||
|
<div class="stat-value">{{ summary.totalCallCount?.toLocaleString() || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-orange">
|
||||||
|
<Icon icon="ep:coin" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">总消耗积分</div>
|
||||||
|
<div class="stat-value">{{ summary.totalConsumePoints?.toLocaleString() || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-purple">
|
||||||
|
<Icon icon="ep:data-analysis" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">平均积分/用户</div>
|
||||||
|
<div class="stat-value">{{ avgPointsPerUser }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 图表区域 -->
|
||||||
|
<el-row :gutter="16" class="mb-16px">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>用户积分消耗排行 TOP10</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="rankChartRef" class="chart-container"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>用户调用次数排行 TOP10</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="callChartRef" class="chart-container"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>用户详细统计</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table v-loading="loading" :data="dataList" :stripe="true">
|
||||||
|
<el-table-column label="用户ID" align="center" prop="userId" width="100" />
|
||||||
|
<el-table-column label="手机号" align="center" prop="mobile" width="130" />
|
||||||
|
<el-table-column label="调用次数" align="center" prop="callCount" sortable>
|
||||||
|
<template #default="{ row }">{{ row.callCount?.toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="消耗积分" align="center" prop="consumePoints" sortable>
|
||||||
|
<template #default="{ row }">{{ row.consumePoints?.toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="输入Token" align="center" prop="inputTokens" sortable>
|
||||||
|
<template #default="{ row }">{{ row.inputTokens?.toLocaleString() || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="输出Token" align="center" prop="outputTokens" sortable>
|
||||||
|
<template #default="{ row }">{{ row.outputTokens?.toLocaleString() || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="总Token" align="center" prop="totalTokens" sortable>
|
||||||
|
<template #default="{ row }">{{ row.totalTokens?.toLocaleString() || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="平均积分/次" align="center" prop="avgPointsPerCall" sortable>
|
||||||
|
<template #default="{ row }">{{ row.avgPointsPerCall?.toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<Pagination
|
||||||
|
v-model:page="pageParams.pageNo"
|
||||||
|
v-model:limit="pageParams.pageSize"
|
||||||
|
:total="pageParams.total"
|
||||||
|
@pagination="getDataList"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { getAiUsageUserStatsPage, type AiUsageUserStats } from '@/api/muye/aiusagestats'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
timeRange: string[]
|
||||||
|
bizType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// 查询参数
|
||||||
|
const queryParams = reactive({
|
||||||
|
mobile: undefined as string | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// 分页参数
|
||||||
|
const pageParams = reactive({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataList = ref<AiUsageUserStats[]>([])
|
||||||
|
const allUserData = ref<AiUsageUserStats[]>([])
|
||||||
|
|
||||||
|
// 汇总数据
|
||||||
|
const summary = computed(() => {
|
||||||
|
const activeUserCount = allUserData.value.length
|
||||||
|
const totalCallCount = allUserData.value.reduce((sum, item) => sum + (item.callCount || 0), 0)
|
||||||
|
const totalConsumePoints = allUserData.value.reduce((sum, item) => sum + (item.consumePoints || 0), 0)
|
||||||
|
return { activeUserCount, totalCallCount, totalConsumePoints }
|
||||||
|
})
|
||||||
|
|
||||||
|
const avgPointsPerUser = computed(() => {
|
||||||
|
if (summary.value.activeUserCount === 0) return '0'
|
||||||
|
return (summary.value.totalConsumePoints / summary.value.activeUserCount).toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 图表
|
||||||
|
const rankChartRef = ref<HTMLElement>()
|
||||||
|
const callChartRef = ref<HTMLElement>()
|
||||||
|
let rankChart: echarts.ECharts | null = null
|
||||||
|
let callChart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
const initCharts = () => {
|
||||||
|
if (rankChartRef.value && !rankChart) {
|
||||||
|
rankChart = echarts.init(rankChartRef.value)
|
||||||
|
}
|
||||||
|
if (callChartRef.value && !callChart) {
|
||||||
|
callChart = echarts.init(callChartRef.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawCharts = () => {
|
||||||
|
initCharts()
|
||||||
|
|
||||||
|
// 积分消耗排行 TOP10
|
||||||
|
const topByPoints = [...allUserData.value]
|
||||||
|
.sort((a, b) => (b.consumePoints || 0) - (a.consumePoints || 0))
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
if (rankChart && topByPoints.length > 0) {
|
||||||
|
rankChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: 100, right: 20, top: 20, bottom: 20 },
|
||||||
|
xAxis: { type: 'value', name: '积分' },
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: topByPoints.map(d => d.mobile || `用户${d.userId}`).reverse(),
|
||||||
|
axisLabel: { width: 80, overflow: 'truncate' }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
data: topByPoints.map(d => d.consumePoints).reverse(),
|
||||||
|
itemStyle: { color: '#e6a23c' }
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用次数排行 TOP10
|
||||||
|
const topByCalls = [...allUserData.value]
|
||||||
|
.sort((a, b) => (b.callCount || 0) - (a.callCount || 0))
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
if (callChart && topByCalls.length > 0) {
|
||||||
|
callChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: 100, right: 20, top: 20, bottom: 20 },
|
||||||
|
xAxis: { type: 'value', name: '次数' },
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: topByCalls.map(d => d.mobile || `用户${d.userId}`).reverse(),
|
||||||
|
axisLabel: { width: 80, overflow: 'truncate' }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
data: topByCalls.map(d => d.callCount).reverse(),
|
||||||
|
itemStyle: { color: '#409eff' }
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据
|
||||||
|
const getDataList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [startTime, endTime] = props.timeRange || []
|
||||||
|
const res = await getAiUsageUserStatsPage({
|
||||||
|
pageNo: pageParams.pageNo,
|
||||||
|
pageSize: pageParams.pageSize,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
bizType: props.bizType,
|
||||||
|
mobile: queryParams.mobile
|
||||||
|
})
|
||||||
|
dataList.value = res?.list || []
|
||||||
|
pageParams.total = res?.total || 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取全部数据用于图表
|
||||||
|
const getAllDataForChart = async () => {
|
||||||
|
const [startTime, endTime] = props.timeRange || []
|
||||||
|
const res = await getAiUsageUserStatsPage({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
bizType: props.bizType,
|
||||||
|
mobile: queryParams.mobile
|
||||||
|
})
|
||||||
|
allUserData.value = res?.list || []
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
drawCharts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询
|
||||||
|
const handleQuery = () => {
|
||||||
|
pageParams.pageNo = 1
|
||||||
|
getDataList()
|
||||||
|
getAllDataForChart()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.mobile = undefined
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听外部参数变化
|
||||||
|
watch(() => [props.timeRange, props.bizType], () => {
|
||||||
|
handleQuery()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 窗口大小变化
|
||||||
|
const handleResize = () => {
|
||||||
|
rankChart?.resize()
|
||||||
|
callChart?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
handleQuery()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
rankChart?.dispose()
|
||||||
|
callChart?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.user-stats-panel {
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
margin-right: 16px;
|
||||||
|
|
||||||
|
&.bg-blue { background: linear-gradient(135deg, #409eff, #3375b9); }
|
||||||
|
&.bg-orange { background: linear-gradient(135deg, #e6a23c, #cf9236); }
|
||||||
|
&.bg-green { background: linear-gradient(135deg, #67c23a, #529b2e); }
|
||||||
|
&.bg-purple { background: linear-gradient(135deg, #909399, #73767a); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
<!-- Tab 切换 -->
|
<!-- Tab 切换 -->
|
||||||
<ContentWrap>
|
<ContentWrap>
|
||||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
<el-tabs v-model="activeTab">
|
||||||
<!-- 概览 Tab -->
|
<!-- 概览 Tab -->
|
||||||
<el-tab-pane label="概览" name="overview">
|
<el-tab-pane label="概览" name="overview">
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
@@ -136,58 +136,12 @@
|
|||||||
|
|
||||||
<!-- 用户统计 Tab -->
|
<!-- 用户统计 Tab -->
|
||||||
<el-tab-pane label="用户统计" name="user">
|
<el-tab-pane label="用户统计" name="user">
|
||||||
<el-table v-loading="userLoading" :data="userStatsList" :stripe="true">
|
<UserStatsPanel v-if="activeTab === 'user'" :time-range="queryParams.timeRange" :biz-type="queryParams.bizType" />
|
||||||
<el-table-column label="用户ID" align="center" prop="userId" />
|
|
||||||
<el-table-column label="手机号" align="center" prop="mobile" />
|
|
||||||
<el-table-column label="调用次数" align="center" prop="callCount">
|
|
||||||
<template #default="{ row }">{{ row.callCount?.toLocaleString() }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="消耗积分" align="center" prop="consumePoints">
|
|
||||||
<template #default="{ row }">{{ row.consumePoints?.toLocaleString() }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="输入Token" align="center" prop="inputTokens">
|
|
||||||
<template #default="{ row }">{{ row.inputTokens?.toLocaleString() || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="输出Token" align="center" prop="outputTokens">
|
|
||||||
<template #default="{ row }">{{ row.outputTokens?.toLocaleString() || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="总Token" align="center" prop="totalTokens">
|
|
||||||
<template #default="{ row }">{{ row.totalTokens?.toLocaleString() || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="平均积分/次" align="center" prop="avgPointsPerCall">
|
|
||||||
<template #default="{ row }">{{ row.avgPointsPerCall?.toFixed(2) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<Pagination
|
|
||||||
v-model:page="userPageParams.pageNo"
|
|
||||||
v-model:limit="userPageParams.pageSize"
|
|
||||||
:total="userPageParams.total"
|
|
||||||
@pagination="getUserStatsList"
|
|
||||||
/>
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 应用统计 Tab -->
|
<!-- 应用统计 Tab -->
|
||||||
<el-tab-pane label="应用统计" name="app">
|
<el-tab-pane label="应用统计" name="app">
|
||||||
<el-table v-loading="appLoading" :data="appStatsList" :stripe="true">
|
<AppStatsPanel v-if="activeTab === 'app'" :time-range="queryParams.timeRange" :biz-type="queryParams.bizType" />
|
||||||
<el-table-column label="服务标识" align="center" prop="serviceCode" />
|
|
||||||
<el-table-column label="服务名称" align="center" prop="serviceName" />
|
|
||||||
<el-table-column label="平台" align="center" prop="platform" />
|
|
||||||
<el-table-column label="调用次数" align="center" prop="callCount">
|
|
||||||
<template #default="{ row }">{{ row.callCount?.toLocaleString() }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="消耗积分" align="center" prop="consumePoints">
|
|
||||||
<template #default="{ row }">{{ row.consumePoints?.toLocaleString() }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="总Token" align="center" prop="totalTokens">
|
|
||||||
<template #default="{ row }">{{ row.totalTokens?.toLocaleString() || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="平均积分/次" align="center" prop="avgPointsPerCall">
|
|
||||||
<template #default="{ row }">{{ row.avgPointsPerCall?.toFixed(2) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="平均Token/次" align="center" prop="avgTokensPerCall">
|
|
||||||
<template #default="{ row }">{{ row.avgTokensPerCall?.toFixed(2) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</ContentWrap>
|
</ContentWrap>
|
||||||
@@ -198,13 +152,11 @@ import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
|
|||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import {
|
import {
|
||||||
getAiUsageOverview,
|
getAiUsageOverview,
|
||||||
getAiUsageUserStatsPage,
|
|
||||||
getAiUsageAppStats,
|
|
||||||
getAiUsageTrend,
|
getAiUsageTrend,
|
||||||
type AiUsageOverview,
|
type AiUsageOverview
|
||||||
type AiUsageUserStats,
|
|
||||||
type AiUsageAppStats
|
|
||||||
} from '@/api/muye/aiusagestats'
|
} from '@/api/muye/aiusagestats'
|
||||||
|
import UserStatsPanel from './components/UserStatsPanel.vue'
|
||||||
|
import AppStatsPanel from './components/AppStatsPanel.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'AiUsageStats' })
|
defineOptions({ name: 'AiUsageStats' })
|
||||||
|
|
||||||
@@ -254,19 +206,6 @@ const activeTab = ref('overview')
|
|||||||
// 概览数据
|
// 概览数据
|
||||||
const overviewData = ref<AiUsageOverview>({} as AiUsageOverview)
|
const overviewData = ref<AiUsageOverview>({} as AiUsageOverview)
|
||||||
|
|
||||||
// 用户统计
|
|
||||||
const userLoading = ref(false)
|
|
||||||
const userStatsList = ref<AiUsageUserStats[]>([])
|
|
||||||
const userPageParams = reactive({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
total: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// 应用统计
|
|
||||||
const appLoading = ref(false)
|
|
||||||
const appStatsList = ref<AiUsageAppStats[]>([])
|
|
||||||
|
|
||||||
// 图表
|
// 图表
|
||||||
const trendChartRef = ref<HTMLElement>()
|
const trendChartRef = ref<HTMLElement>()
|
||||||
const pieChartRef = ref<HTMLElement>()
|
const pieChartRef = ref<HTMLElement>()
|
||||||
@@ -303,10 +242,8 @@ const getTrendData = async () => {
|
|||||||
type: 'day'
|
type: 'day'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 确保 DOM 已渲染
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// 初始化图表
|
|
||||||
if (!initTrendChart()) {
|
if (!initTrendChart()) {
|
||||||
console.warn('趋势图表容器未找到')
|
console.warn('趋势图表容器未找到')
|
||||||
return
|
return
|
||||||
@@ -342,7 +279,6 @@ const initPieChart = () => {
|
|||||||
|
|
||||||
// 绘制饼图
|
// 绘制饼图
|
||||||
const drawPieChart = () => {
|
const drawPieChart = () => {
|
||||||
// 初始化图表
|
|
||||||
if (!initPieChart()) {
|
if (!initPieChart()) {
|
||||||
console.warn('饼图容器未找到')
|
console.warn('饼图容器未找到')
|
||||||
return
|
return
|
||||||
@@ -367,61 +303,13 @@ const drawPieChart = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户统计列表
|
|
||||||
const getUserStatsList = async () => {
|
|
||||||
userLoading.value = true
|
|
||||||
try {
|
|
||||||
const [startTime, endTime] = queryParams.timeRange || []
|
|
||||||
const res = await getAiUsageUserStatsPage({
|
|
||||||
pageNo: userPageParams.pageNo,
|
|
||||||
pageSize: userPageParams.pageSize,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
bizType: queryParams.bizType
|
|
||||||
})
|
|
||||||
userStatsList.value = res?.list || []
|
|
||||||
userPageParams.total = res?.total || 0
|
|
||||||
} finally {
|
|
||||||
userLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取应用统计列表
|
|
||||||
const getAppStatsList = async () => {
|
|
||||||
appLoading.value = true
|
|
||||||
try {
|
|
||||||
const [startTime, endTime] = queryParams.timeRange || []
|
|
||||||
const res = await getAiUsageAppStats({
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
bizType: queryParams.bizType
|
|
||||||
})
|
|
||||||
appStatsList.value = res || []
|
|
||||||
} finally {
|
|
||||||
appLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询
|
// 查询
|
||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
|
if (activeTab.value !== 'overview') return
|
||||||
await getOverviewData()
|
await getOverviewData()
|
||||||
await getTrendData()
|
await getTrendData()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
drawPieChart()
|
drawPieChart()
|
||||||
if (activeTab.value === 'user') {
|
|
||||||
getUserStatsList()
|
|
||||||
} else if (activeTab.value === 'app') {
|
|
||||||
getAppStatsList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab 切换
|
|
||||||
const handleTabChange = (tab: string) => {
|
|
||||||
if (tab === 'user') {
|
|
||||||
getUserStatsList()
|
|
||||||
} else if (tab === 'app') {
|
|
||||||
getAppStatsList()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 窗口大小变化时重绘图表
|
// 窗口大小变化时重绘图表
|
||||||
|
|||||||
Reference in New Issue
Block a user