888 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<DefaultLayout>
<div class="analytics-page">
<div class="container">
<div class="header-actions">
<a-select v-model:value="dateRange" class="date-range-select" @change="loadData">
<a-select-option value="week">近一周</a-select-option>
<a-select-option value="month">近一月</a-select-option>
<a-select-option value="quarter">近三月</a-select-option>
<a-select-option value="year">近一年</a-select-option>
<a-select-option value="all">全部</a-select-option>
</a-select>
<a-button type="primary" @click="exportData">
<template #icon><Icon name="download" size="sm" /></template>
导出报表
</a-button>
</div>
<a-spin :spinning="loading">
<!-- 总览卡片 -->
<div v-if="statistics" class="overview-section">
<h2 class="section-title">数据总览</h2>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="6">
<div class="stat-card">
<div class="stat-icon">
<Icon name="document" size="xl" color="#31f08b" />
</div>
<div class="stat-content">
<div class="stat-label">总需求数</div>
<div class="stat-value">{{ overview.totalDemands }}</div>
<div class="stat-sub">今日: {{ overview.todayDemands }}</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<div class="stat-card">
<div class="stat-icon">
<Icon name="checkCircle" size="xl" color="#31f08b" />
</div>
<div class="stat-content">
<div class="stat-label">已完成</div>
<div class="stat-value">{{ overview.completedDemands }}</div>
<div class="stat-sub">完成率: {{ completionRate }}%</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<div class="stat-card">
<div class="stat-icon">
<Icon name="trophy" size="xl" color="#31f08b" />
</div>
<div class="stat-content">
<div class="stat-label">总积分消耗</div>
<div class="stat-value">{{ overview.totalScoreUsed }}</div>
<div class="stat-sub">今日: {{ overview.todayScoreUsed }}</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<div class="stat-card">
<div class="stat-icon">
<Icon name="star" size="xl" color="#31f08b" />
</div>
<div class="stat-content">
<div class="stat-label">平均满意度</div>
<div class="stat-value">{{ overview.avgSatisfaction?.toFixed(1) || '0.0' }}</div>
<div class="stat-sub">平均完成时长: {{ overview.avgCompletionTime?.toFixed(1) || '0' }}h</div>
</div>
</div>
</a-col>
</a-row>
</div>
<!-- 趋势图表 -->
<div v-if="statistics" class="chart-section">
<h2 class="section-title">需求趋势</h2>
<div class="chart-card">
<div ref="trendChartRef" style="width: 100%; height: 400px;"></div>
</div>
</div>
<!-- 类型分布 -->
<div v-if="statistics" class="chart-section">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<h2 class="section-title">需求类型分布</h2>
<div class="chart-card">
<div ref="typeChartRef" style="width: 100%; height: 350px;"></div>
</div>
</a-col>
<a-col :xs="24" :md="12">
<h2 class="section-title">部门积分使用情况</h2>
<div class="chart-card">
<div ref="deptChartRef" style="width: 100%; height: 350px;"></div>
</div>
</a-col>
</a-row>
</div>
<!-- 用户排行 -->
<div v-if="statistics && statistics.userStats" class="table-section">
<h2 class="section-title">用户需求排行</h2>
<div class="table-card">
<a-table
:columns="userColumns"
:data-source="statistics.userStats || []"
:pagination="{ pageSize: 10 }"
row-key="userId"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rank'">
<span class="rank-badge" :class="getRankClass(record.rank)">
{{ record.rank }}
</span>
</template>
<template v-else-if="column.key === 'completionRate'">
<a-progress
:percent="Math.round(record.completionRate)"
:stroke-color="getProgressColor(record.completionRate)"
size="small"
/>
</template>
<template v-else-if="column.key === 'satisfactionRate'">
<a-rate :value="record.satisfactionRate / 20" disabled allow-half />
<span style="margin-left: 8px;">{{ record.satisfactionRate.toFixed(1) }}%</span>
</template>
</template>
</a-table>
</div>
</div>
<!-- 部门统计 -->
<div v-if="statistics && statistics.deptStats" class="table-section">
<h2 class="section-title">部门需求统计</h2>
<div class="table-card">
<a-table
:columns="deptColumns"
:data-source="statistics.deptStats || []"
:pagination="{ pageSize: 10 }"
row-key="deptId"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'alertLevel'">
<a-tag :color="getAlertColor(record.alertLevel)">
{{ getAlertText(record.alertLevel) }}
</a-tag>
</template>
<template v-else-if="column.key === 'utilizationRate'">
<a-progress
:percent="Math.round(record.utilizationRate)"
:stroke-color="getUtilizationColor(record.utilizationRate)"
size="small"
/>
</template>
<template v-else-if="column.key === 'scoreStatus'">
<div class="score-status">
<div>剩余: <strong>{{ record.remainingScore }}</strong></div>
<div class="score-limits">
<span>日: {{ record.dailyUsed }}/{{ record.dailyLimit }}</span>
<span>周: {{ record.weeklyUsed }}/{{ record.weeklyLimit }}</span>
<span>月: {{ record.monthlyUsed }}/{{ record.monthlyLimit }}</span>
</div>
</div>
</template>
</template>
</a-table>
</div>
</div>
</a-spin>
</div>
</div>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import { getDemandStatistics, type DemandStatistics } from '@/api/analytics'
import { message } from 'ant-design-vue'
import * as echarts from 'echarts'
import Icon from '@/components/Icon.vue'
const loading = ref(false)
const dateRange = ref('month')
// 初始化 statistics 为默认值,避免 undefined 错误
// 使用 reactive 确保响应式对象结构始终完整
const statistics = reactive<DemandStatistics>({
overview: {
totalDemands: 0,
todayDemands: 0,
weekDemands: 0,
monthDemands: 0,
completedDemands: 0,
pendingDemands: 0,
canceledDemands: 0,
totalScoreUsed: 0,
todayScoreUsed: 0,
weekScoreUsed: 0,
monthScoreUsed: 0,
avgCompletionTime: 0,
avgSatisfaction: 0
},
trendData: [],
typeDistribution: [],
userStats: [],
deptStats: []
})
// ECharts实例引用
const trendChartRef = ref<HTMLElement>()
const typeChartRef = ref<HTMLElement>()
const deptChartRef = ref<HTMLElement>()
// 安全访问 overview 的计算属性
const overview = computed(() => {
return statistics?.overview || {
totalDemands: 0,
todayDemands: 0,
weekDemands: 0,
monthDemands: 0,
completedDemands: 0,
pendingDemands: 0,
canceledDemands: 0,
totalScoreUsed: 0,
todayScoreUsed: 0,
weekScoreUsed: 0,
monthScoreUsed: 0,
avgCompletionTime: 0,
avgSatisfaction: 0
}
})
// 计算完成率
const completionRate = computed(() => {
const ov = overview.value
const completedDemands = ov.completedDemands || 0
const totalDemands = ov.totalDemands || 0
if (totalDemands === 0) return '0.0'
return ((completedDemands / totalDemands) * 100).toFixed(1)
})
// 用户表格列定义
const userColumns = [
{ title: '排名', key: 'rank', width: 80, customRender: (_: any, __: any, index: number) => index + 1 },
{ title: '用户名', dataIndex: 'userName', key: 'userName' },
{ title: '部门', dataIndex: 'deptName', key: 'deptName' },
{ title: '总需求', dataIndex: 'totalDemands', key: 'totalDemands', sorter: (a: any, b: any) => a.totalDemands - b.totalDemands },
{ title: '已完成', dataIndex: 'completedDemands', key: 'completedDemands' },
{ title: '进行中', dataIndex: 'pendingDemands', key: 'pendingDemands' },
{ title: '总积分', dataIndex: 'totalScore', key: 'totalScore', sorter: (a: any, b: any) => a.totalScore - b.totalScore },
{ title: '完成率', key: 'completionRate' },
{ title: '满意度', key: 'satisfactionRate' }
]
// 部门表格列定义
const deptColumns = [
{ title: '部门名称', dataIndex: 'deptName', key: 'deptName' },
{ title: '总需求', dataIndex: 'totalDemands', key: 'totalDemands', sorter: (a: any, b: any) => a.totalDemands - b.totalDemands },
{ title: '已完成', dataIndex: 'completedDemands', key: 'completedDemands' },
{ title: '进行中', dataIndex: 'pendingDemands', key: 'pendingDemands' },
{ title: '总积分', dataIndex: 'totalScore', key: 'totalScore' },
{ title: '积分状态', key: 'scoreStatus', width: 200 },
{ title: '使用率', key: 'utilizationRate', width: 150 },
{ title: '预警', key: 'alertLevel', width: 100 }
]
// 加载数据
const loadData = async () => {
loading.value = true
try {
console.log('Loading analytics data...')
const data = await getDemandStatistics(dateRange.value)
console.log('Received data:', data)
// 确保数据结构完整,如果缺少字段则使用默认值
// 使用 Object.assign 更新 reactive 对象,保持响应式
const defaultOverview = {
totalDemands: 0,
todayDemands: 0,
weekDemands: 0,
monthDemands: 0,
completedDemands: 0,
pendingDemands: 0,
canceledDemands: 0,
totalScoreUsed: 0,
todayScoreUsed: 0,
weekScoreUsed: 0,
monthScoreUsed: 0,
avgCompletionTime: 0,
avgSatisfaction: 0
}
// 确保 data 和 data.overview 存在
if (!data) {
console.warn('Data is null or undefined')
Object.assign(statistics, {
overview: defaultOverview,
trendData: [],
typeDistribution: [],
userStats: [],
deptStats: []
})
} else {
// 合并 overview,确保所有字段都存在
const mergedOverview = {
...defaultOverview,
...(data.overview || {})
}
Object.assign(statistics, {
overview: mergedOverview,
trendData: Array.isArray(data.trendData) ? data.trendData : [],
typeDistribution: Array.isArray(data.typeDistribution) ? data.typeDistribution : [],
userStats: Array.isArray(data.userStats) ? data.userStats : [],
deptStats: Array.isArray(data.deptStats) ? data.deptStats : []
})
}
// 确保 overview 对象始终存在
if (!statistics.overview) {
console.warn('Overview is missing, creating default')
statistics.overview = defaultOverview
}
console.log('Statistics after update:', statistics)
await nextTick()
renderCharts()
} catch (error) {
message.error('加载数据失败')
console.error('加载数据分析数据失败:', error)
// 重置为默认值以避免渲染错误
Object.assign(statistics, {
overview: {
totalDemands: 0,
todayDemands: 0,
weekDemands: 0,
monthDemands: 0,
completedDemands: 0,
pendingDemands: 0,
canceledDemands: 0,
totalScoreUsed: 0,
todayScoreUsed: 0,
weekScoreUsed: 0,
monthScoreUsed: 0,
avgCompletionTime: 0,
avgSatisfaction: 0
},
trendData: [],
typeDistribution: [],
userStats: [],
deptStats: []
})
} finally {
loading.value = false
}
}
// 渲染图表
const renderCharts = () => {
if (!statistics) return
// 趋势图
if (trendChartRef.value && statistics.trendData && Array.isArray(statistics.trendData)) {
const trendChart = echarts.init(trendChartRef.value)
trendChart.setOption({
backgroundColor: 'transparent',
textStyle: {
color: '#ECECEC'
},
tooltip: {
trigger: 'axis',
backgroundColor: '#2A2A2A',
borderColor: '#3A3A3A',
textStyle: { color: '#ECECEC' }
},
legend: {
data: ['需求数量', '完成数量', '积分消耗'],
textStyle: { color: '#ECECEC' }
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: statistics.trendData.map(d => d.date),
axisLine: { lineStyle: { color: '#3A3A3A' } },
axisLabel: { color: '#AFAFAF' }
},
yAxis: [
{
type: 'value',
name: '数量',
axisLine: { lineStyle: { color: '#3A3A3A' } },
axisLabel: { color: '#AFAFAF' },
splitLine: { lineStyle: { color: '#262626' } }
},
{
type: 'value',
name: '积分',
position: 'right',
axisLine: { lineStyle: { color: '#3A3A3A' } },
axisLabel: { color: '#AFAFAF' },
splitLine: { lineStyle: { color: '#262626' } }
}
],
series: [
{
name: '需求数量',
type: 'line',
data: statistics.trendData.map(d => d.demandCount || 0),
smooth: true
},
{
name: '完成数量',
type: 'line',
data: statistics.trendData.map(d => d.completedCount || 0),
smooth: true
},
{
name: '积分消耗',
type: 'bar',
yAxisIndex: 1,
data: statistics.trendData.map(d => d.scoreUsed || 0)
}
]
})
}
// 类型分布饼图
if (typeChartRef.value && statistics.typeDistribution && Array.isArray(statistics.typeDistribution)) {
const typeChart = echarts.init(typeChartRef.value)
typeChart.setOption({
backgroundColor: 'transparent',
textStyle: {
color: '#ECECEC'
},
tooltip: {
trigger: 'item',
backgroundColor: '#2A2A2A',
borderColor: '#3A3A3A',
textStyle: { color: '#ECECEC' }
},
legend: {
orient: 'vertical',
left: 'left',
textStyle: { color: '#ECECEC' }
},
series: [
{
type: 'pie',
radius: '60%',
data: statistics.typeDistribution.map(d => ({
name: d.typeName || '未知',
value: d.count || 0
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
})
}
// 部门积分柱状图
if (deptChartRef.value && statistics.deptStats && Array.isArray(statistics.deptStats)) {
const deptChart = echarts.init(deptChartRef.value)
deptChart.setOption({
backgroundColor: 'transparent',
textStyle: {
color: '#ECECEC'
},
tooltip: {
trigger: 'axis',
backgroundColor: '#2A2A2A',
borderColor: '#3A3A3A',
textStyle: { color: '#ECECEC' }
},
legend: {
data: ['总积分', '剩余积分'],
textStyle: { color: '#ECECEC' }
},
xAxis: {
type: 'category',
data: statistics.deptStats.map(d => d.deptName || '未知'),
axisLabel: { rotate: 30, color: '#AFAFAF' },
axisLine: { lineStyle: { color: '#3A3A3A' } }
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#3A3A3A' } },
axisLabel: { color: '#AFAFAF' },
splitLine: { lineStyle: { color: '#262626' } }
},
series: [
{
name: '总积分',
type: 'bar',
data: statistics.deptStats.map(d => d.totalScore || 0)
},
{
name: '剩余积分',
type: 'bar',
data: statistics.deptStats.map(d => d.remainingScore || 0)
}
]
})
}
}
// 工具函数
const getRankClass = (rank: number) => {
if (rank === 1) return 'rank-1'
if (rank === 2) return 'rank-2'
if (rank === 3) return 'rank-3'
return ''
}
const getProgressColor = (rate: number) => {
if (rate >= 80) return '#10A37F' // $color-success
if (rate >= 60) return '#3B82F6' // $color-info
if (rate >= 40) return '#FFA500' // $color-warning
return '#EF4444' // $color-error
}
const getAlertColor = (level: string) => {
switch (level) {
case 'safe': return 'success'
case 'warning': return 'warning'
case 'danger': return 'error'
default: return 'default'
}
}
const getAlertText = (level: string) => {
switch (level) {
case 'safe': return '正常'
case 'warning': return '预警'
case 'danger': return '紧急'
default: return '-'
}
}
const getUtilizationColor = (rate: number) => {
if (rate >= 90) return '#EF4444' // $color-error
if (rate >= 70) return '#FFA500' // $color-warning
return '#10A37F' // $color-success
}
const exportData = () => {
message.info('导出功能开发中...')
}
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.analytics-page {
min-height: 100vh;
padding: $space-xl 0;
background: $bg-primary;
}
.container {
max-width: $container-max-width;
margin: 0 auto;
padding: 0 $space-xl;
@media (max-width: $breakpoint-tablet) {
padding: 0 $space-lg;
}
@media (max-width: $breakpoint-mobile) {
padding: 0 $space-md;
}
}
.header-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: $space-md;
margin-bottom: $space-2xl;
.date-range-select {
:deep(.ant-select-selector) {
height: 40px !important;
line-height: 40px !important;
min-width: 120px;
}
:deep(.ant-select-selection-item),
:deep(.ant-select-selection-placeholder) {
line-height: 38px !important;
}
}
}
.section-title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $text-primary;
margin-bottom: $space-lg;
}
.overview-section {
margin-bottom: $space-2xl;
}
.stat-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
padding: $space-lg;
display: flex;
gap: $space-lg;
transition: all $transition-base $ease-out;
&:hover {
border-color: $border-color;
background: $bg-card-hover;
box-shadow: $shadow-base;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
font-size: 28px;
flex-shrink: 0;
}
.stat-content {
flex: 1;
.stat-label {
font-size: $font-size-sm;
color: $text-secondary;
margin-bottom: $space-xs;
}
.stat-value {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $text-primary;
margin-bottom: $space-xs;
}
.stat-sub {
font-size: $font-size-sm;
color: $text-tertiary;
}
}
}
.chart-section {
margin-bottom: $space-2xl;
}
.chart-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
padding: $space-xl;
transition: all $transition-base $ease-out;
&:hover {
border-color: $border-color;
background: $bg-card-hover;
}
}
.table-section {
margin-bottom: $space-2xl;
}
.table-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
overflow: hidden;
transition: all $transition-base $ease-out;
&:hover {
border-color: $border-color;
}
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-weight: $font-weight-bold;
background: $bg-tertiary;
color: $text-secondary;
&.rank-1 {
background: rgba(#31f08b, 0.15);
color: #31f08b;
border: 1px solid rgba(#31f08b, 0.3);
}
&.rank-2 {
background: rgba(#31f08b, 0.1);
color: #31f08b;
border: 1px solid rgba(#31f08b, 0.2);
}
&.rank-3 {
background: rgba(#31f08b, 0.08);
color: #31f08b;
border: 1px solid rgba(#31f08b, 0.15);
}
}
.score-status {
.score-limits {
display: flex;
gap: $space-sm;
font-size: $font-size-xs;
color: $text-tertiary;
margin-top: $space-xs;
span {
background: $bg-tertiary;
padding: 2px 6px;
border-radius: $radius-sm;
}
}
}
// Ant Design 组件深色主题覆盖
:deep(.ant-select) {
.ant-select-selector {
background: $bg-tertiary !important;
border-color: $border-color !important;
color: $text-primary !important;
}
.ant-select-selection-item {
color: $text-primary !important;
}
.ant-select-arrow {
color: $text-secondary !important;
}
}
:deep(.ant-select-dropdown) {
background: $bg-secondary !important;
border-color: $border-color !important;
.ant-select-item {
color: $text-primary !important;
&:hover {
background: $bg-tertiary !important;
}
&.ant-select-item-option-selected {
background: rgba($brand-primary, 0.1) !important;
color: $brand-primary !important;
}
}
}
:deep(.ant-table) {
background: transparent !important;
color: $text-primary !important;
.ant-table-thead > tr > th {
background: $bg-tertiary !important;
border-color: $border-color !important;
color: $text-primary !important;
font-weight: $font-weight-semibold;
}
.ant-table-tbody > tr {
background: transparent !important;
&:hover > td {
background: $bg-tertiary !important;
}
> td {
border-color: $border-color !important;
color: $text-primary !important;
}
}
.ant-table-pagination {
.ant-pagination-item {
background: $bg-tertiary !important;
border-color: $border-primary !important;
a {
color: $text-primary !important;
}
&.ant-pagination-item-active {
background: $brand-primary !important;
border-color: $brand-primary !important;
a {
color: #000000 !important; // 当前页文字为黑色
}
}
}
.ant-pagination-prev,
.ant-pagination-next {
.ant-pagination-item-link {
background: $bg-tertiary !important;
border-color: $border-primary !important;
color: $text-primary !important;
}
}
.ant-pagination-options {
.ant-select {
.ant-select-selector {
background: $bg-tertiary !important;
border-color: $border-primary !important;
color: $text-primary !important;
}
}
}
}
}
:deep(.ant-progress) {
.ant-progress-bg {
background-color: $brand-primary !important;
}
.ant-progress-text {
color: $text-primary !important;
}
}
:deep(.ant-rate) {
.ant-rate-star {
color: $text-tertiary !important;
&.ant-rate-star-full,
&.ant-rate-star-half .ant-rate-star-first {
color: $color-warning !important;
}
}
}
:deep(.ant-tag) {
background: $bg-tertiary !important;
border-color: $border-color !important;
color: $text-primary !important;
&.ant-tag-success {
background: rgba($color-success, 0.1) !important;
border-color: $color-success !important;
color: $color-success !important;
}
&.ant-tag-warning {
background: rgba($color-warning, 0.1) !important;
border-color: $color-warning !important;
color: $color-warning !important;
}
&.ant-tag-error {
background: rgba($color-error, 0.1) !important;
border-color: $color-error !important;
color: $color-error !important;
}
}
</style>