Files
tuanwei-uniapp/pages/gxmu/stats.vue
2026-07-20 11:56:02 +08:00

736 lines
18 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>
<view class="page">
<scroll-view scroll-y class="page-scroll" refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="handleRefresh">
<common-hero title="数据统计与报表中心" :use-image-bg="true"></common-hero>
<view class="hero-card">
<view class="hero-title">挑战杯数据统计</view>
<view class="hero-actions">
<picker class="picker-wrap" mode="selector" :range="yearOptionLabels" :value="selectedYearIndex" @change="handleYearChange">
<view class="action-pill">
<text>{{ selectedYear ? `${selectedYear}` : '全部年份' }}</text>
<text class="action-pill-arrow"></text>
</view>
</picker>
<view class="action-pill action-pill--ghost" :class="{ 'action-pill--loading': loading }" @tap="loadData()">
{{ loading ? '刷新中' : '刷新数据' }}
</view>
</view>
</view>
<view v-if="stats.awardCountDescription" class="alert-card">
<view class="alert-icon">i</view>
<view class="alert-text">{{ stats.awardCountDescription }}</view>
</view>
<view v-if="loading && !hasLoaded" class="state-card">
<view class="state-title">正在加载统计数据...</view>
<view class="state-desc">请稍候正在同步挑战杯报表中心数据</view>
</view>
<view v-else-if="loadError" class="state-card state-card--error">
<view class="state-title">加载失败</view>
<view class="state-desc">{{ loadError }}</view>
<view class="state-action" @tap="loadData()">重新加载</view>
</view>
<block v-else>
<view class="metrics-grid">
<view class="metric-card">
<view class="metric-label">申报作品数量</view>
<view class="metric-value">{{ summary.declarationCount }}</view>
<view class="metric-foot">当前筛选范围内的项目总数</view>
</view>
<view class="metric-card metric-card--accent">
<view class="metric-label">参与申报人数</view>
<view class="metric-value">{{ summary.participantCount }}</view>
<view class="metric-foot">按团队成员去重统计</view>
</view>
<view class="metric-card metric-card--warm">
<view class="metric-label">指导老师数量</view>
<view class="metric-value">{{ summary.advisorCount }}</view>
<view class="metric-foot">按指导老师去重统计</view>
</view>
<view class="metric-card metric-card--success">
<view class="metric-label">获奖数量</view>
<view class="metric-value">{{ summary.awardCount }}</view>
<view class="metric-foot">当前按审核通过作品数统计</view>
</view>
</view>
<view class="mini-metrics">
<view class="mini-metric-card">
<text>申报学校数量</text>
<text class="mini-metric-value">{{ summary.schoolCount }}</text>
</view>
<view class="mini-metric-card">
<text>项目类型数</text>
<text class="mini-metric-value">{{ stats.typeDistribution.length }}</text>
</view>
<view class="mini-metric-card">
<text>项目分组数</text>
<text class="mini-metric-value">{{ stats.groupDistribution.length }}</text>
</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">年度申报趋势</view>
<view class="section-subtitle">展示各年度挑战杯作品申报数量变化</view>
</view>
</view>
<view v-if="stats.yearDistribution.length" class="trend-list">
<view v-for="item in stats.yearDistribution" :key="item.label" class="trend-item">
<view class="trend-top">
<view class="trend-label">{{ item.label }}</view>
<view class="trend-value">{{ item.value }}</view>
</view>
<view class="bar-track">
<view class="bar-fill bar-fill--blue" :style="{ width: getBarWidth(item.value, maxYearValue) }"></view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无年度数据</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">项目类型分布</view>
<view class="section-subtitle">按项目类型统计申报数量</view>
</view>
</view>
<view v-if="stats.typeDistribution.length" class="distribution-list">
<view v-for="item in stats.typeDistribution" :key="item.label" class="distribution-item">
<view class="distribution-main">
<view class="distribution-label">{{ item.label }}</view>
<view class="distribution-value">{{ item.value }}</view>
</view>
<view class="bar-track">
<view class="bar-fill bar-fill--cyan" :style="{ width: getBarWidth(item.value, maxTypeValue) }"></view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无类型分布</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">项目分组分布</view>
<view class="section-subtitle">按项目分组统计申报数量</view>
</view>
</view>
<view v-if="stats.groupDistribution.length" class="distribution-list">
<view v-for="item in stats.groupDistribution" :key="item.label" class="distribution-item">
<view class="distribution-main">
<view class="distribution-label">{{ item.label }}</view>
<view class="distribution-value">{{ item.value }}</view>
</view>
<view class="bar-track">
<view class="bar-fill bar-fill--orange" :style="{ width: getBarWidth(item.value, maxGroupValue) }"></view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无分组分布</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">学校申报排行</view>
<view class="section-subtitle">展示学校维度的申报数量排名</view>
</view>
</view>
<view v-if="stats.schoolDistribution.length" class="ranking-list">
<view v-for="(item, index) in stats.schoolDistribution" :key="item.label" class="ranking-item">
<view class="ranking-order">{{ index + 1 }}</view>
<view class="ranking-main">
<view class="distribution-main">
<view class="distribution-label">{{ item.label }}</view>
<view class="distribution-value">{{ item.value }}</view>
</view>
<view class="bar-track">
<view class="bar-fill bar-fill--green" :style="{ width: getBarWidth(item.value, maxSchoolValue) }"></view>
</view>
</view>
</view>
</view>
<view v-else class="empty-card">暂无学校排行</view>
</view>
<view class="section-card">
<view class="section-head">
<view>
<view class="section-title">分布明细</view>
<view class="section-subtitle">{{ selectedYear ? `${selectedYear}` : '全部年份' }}统计口径下的分类数量</view>
</view>
</view>
<view class="detail-grid">
<view class="detail-panel">
<view class="detail-title">项目类型</view>
<view v-if="stats.typeDistribution.length" class="detail-list">
<view v-for="item in stats.typeDistribution" :key="`type-${item.label}`" class="detail-row">
<text class="detail-label">{{ item.label }}</text>
<text class="detail-value">{{ item.value }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无数据</view>
</view>
<view class="detail-panel">
<view class="detail-title">项目分组</view>
<view v-if="stats.groupDistribution.length" class="detail-list">
<view v-for="item in stats.groupDistribution" :key="`group-${item.label}`" class="detail-row">
<text class="detail-label">{{ item.label }}</text>
<text class="detail-value">{{ item.value }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无数据</view>
</view>
</view>
</view>
</block>
</scroll-view>
</view>
</template>
<script>
import CommonHero from '../../components/common-hero/common-hero.vue'
import { getChallengeCupStatistics } from './stats-service'
const createInitialStats = () => ({
availableYears: [],
summary: {
declarationCount: 0,
participantCount: 0,
advisorCount: 0,
awardCount: 0,
schoolCount: 0
},
awardCountDescription: '',
yearDistribution: [],
typeDistribution: [],
groupDistribution: [],
schoolDistribution: []
})
export default {
components: {
CommonHero
},
data() {
return {
loading: false,
refreshing: false,
hasLoaded: false,
loadError: '',
selectedYear: '',
stats: createInitialStats()
}
},
computed: {
summary() {
return this.stats.summary || createInitialStats().summary
},
yearOptionLabels() {
const years = Array.isArray(this.stats.availableYears) ? this.stats.availableYears : []
return ['全部年份', ...years.map((item) => `${item}`)]
},
selectedYearIndex() {
if (!this.selectedYear) {
return 0
}
const years = Array.isArray(this.stats.availableYears) ? this.stats.availableYears : []
const index = years.findIndex((item) => Number(item) === Number(this.selectedYear))
return index >= 0 ? index + 1 : 0
},
maxYearValue() {
return this.getMaxValue(this.stats.yearDistribution)
},
maxTypeValue() {
return this.getMaxValue(this.stats.typeDistribution)
},
maxGroupValue() {
return this.getMaxValue(this.stats.groupDistribution)
},
maxSchoolValue() {
return this.getMaxValue(this.stats.schoolDistribution)
}
},
onLoad() {
this.loadData()
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
getMaxValue(list) {
const values = (Array.isArray(list) ? list : []).map((item) => Number(item.value || 0))
return values.length ? Math.max(...values, 1) : 1
},
getBarWidth(value, maxValue) {
const current = Number(value || 0)
const max = Number(maxValue || 1)
const percent = max > 0 ? (current / max) * 100 : 0
return `${Math.max(percent, current > 0 ? 8 : 0)}%`
},
handleYearChange(event) {
const index = Number(event.detail.value || 0)
if (index === 0) {
this.selectedYear = ''
} else {
const year = this.stats.availableYears[index - 1]
this.selectedYear = year || ''
}
this.loadData()
},
handleRefresh() {
this.refreshing = true
this.loadData(true)
},
async loadData(fromRefresh = false) {
if (this.loading) {
if (fromRefresh) {
this.refreshing = false
}
return
}
this.loading = true
this.loadError = ''
try {
const params = this.selectedYear ? { year: Number(this.selectedYear) } : undefined
const result = await getChallengeCupStatistics(params)
this.stats = {
...createInitialStats(),
...(result || {}),
summary: {
...createInitialStats().summary,
...((result && result.summary) || {})
},
availableYears: Array.isArray(result && result.availableYears) ? result.availableYears : [],
yearDistribution: Array.isArray(result && result.yearDistribution) ? result.yearDistribution : [],
typeDistribution: Array.isArray(result && result.typeDistribution) ? result.typeDistribution : [],
groupDistribution: Array.isArray(result && result.groupDistribution) ? result.groupDistribution : [],
schoolDistribution: Array.isArray(result && result.schoolDistribution) ? result.schoolDistribution : []
}
if (
this.selectedYear &&
!this.stats.availableYears.some((item) => Number(item) === Number(this.selectedYear))
) {
this.selectedYear = ''
}
this.hasLoaded = true
} catch (error) {
this.loadError = (error && error.message) || '统计数据加载失败'
this.showToast(this.loadError)
} finally {
this.loading = false
if (fromRefresh) {
this.refreshing = false
}
}
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.14), transparent 28%),
linear-gradient(180deg, #f7fbff 0%, #fffdf8 100%);
}
.page-scroll {
height: 100vh;
//padding: 28rpx 24rpx 40rpx;
box-sizing: border-box;
}
.hero-card {
padding: 34rpx 36rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 4rpx 12rpx 0 rgba(174, 174, 174, 0.4);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
border: 1rpx solid rgba(255, 255, 255, 0.5);
}
.hero-badge {
display: inline-flex;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(20,150,242,.1);
font-size: 22rpx;
color: #1496f2;
letter-spacing: 1rpx;
}
.hero-title {
margin-top: 8rpx;
padding-left: 10rpx;
font-size: 30rpx;
line-height: 1.35;
font-weight: 700;
color: #18a0f7;
border-left: 5px solid #0f94ef;
}
.hero-desc {
margin-top: 20rpx;
font-size: 24rpx;
line-height: 1.8;
color: #7f7f7f;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 24rpx;
}
.picker-wrap {
display: block;
}
.action-pill {
display: inline-flex;
align-items: center;
gap: 10rpx;
padding: 16rpx 24rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
font-size: 24rpx;
font-weight: 600;
}
.action-pill--ghost {
background: rgba(10, 20, 45, 0.16);
}
.action-pill--loading {
opacity: 0.7;
}
.action-pill-arrow {
font-size: 18rpx;
opacity: 0.85;
}
.alert-card {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin-top: 22rpx;
padding: 24rpx;
border-radius: 28rpx;
background: #eef6ff;
border: 1rpx solid rgba(37, 99, 235, 0.12);
}
.alert-icon {
flex-shrink: 0;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: #2563eb;
color: #fff;
font-size: 22rpx;
font-weight: 700;
line-height: 36rpx;
text-align: center;
}
.alert-text {
font-size: 24rpx;
line-height: 1.7;
color: #31537c;
}
.state-card {
margin-top: 24rpx;
padding: 32rpx 28rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.94);
border: 1rpx solid rgba(37, 99, 235, 0.08);
box-shadow: 0 10rpx 28rpx rgba(34, 94, 142, 0.08);
}
.state-card--error {
background: rgba(247, 250, 255, 0.98);
border-color: rgba(37, 99, 235, 0.16);
}
.state-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.state-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: #6c7480;
}
.state-action {
margin-top: 18rpx;
font-size: 24rpx;
font-weight: 600;
color: #2563eb;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 24rpx;
}
.metric-card {
padding: 24rpx;
border-radius: 28rpx;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
border: 1rpx solid #dbeafe;
box-shadow: 0 10rpx 28rpx rgba(15, 23, 42, 0.05);
}
.metric-card--accent {
background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%);
border-color: #bbf7d0;
}
.metric-card--warm {
background: linear-gradient(135deg, #fff7ed 0%, #ffffff 100%);
border-color: #fed7aa;
}
.metric-card--success {
background: linear-gradient(135deg, #ecfeff 0%, #ffffff 100%);
border-color: #a5f3fc;
}
.metric-label {
font-size: 24rpx;
color: #64748b;
}
.metric-value {
margin-top: 12rpx;
font-size: 46rpx;
font-weight: 700;
line-height: 1;
color: #0f172a;
}
.metric-foot {
margin-top: 14rpx;
font-size: 22rpx;
line-height: 1.6;
color: #94a3b8;
}
.mini-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16rpx;
margin-top: 18rpx;
}
.mini-metric-card {
padding: 22rpx 18rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.05);
font-size: 22rpx;
line-height: 1.6;
color: #64748b;
}
.mini-metric-value {
display: block;
margin-top: 8rpx;
font-size: 34rpx;
font-weight: 700;
color: #0f172a;
}
.section-card {
margin-top: 24rpx;
padding: 28rpx 24rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.06);
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #162033;
}
.section-subtitle {
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.6;
color: #8b94a3;
}
.empty-card {
margin-top: 20rpx;
padding: 28rpx 24rpx;
border-radius: 24rpx;
background: #f8fbff;
font-size: 24rpx;
color: #90a0b7;
}
.trend-list,
.distribution-list,
.ranking-list,
.detail-list {
margin-top: 20rpx;
}
.trend-item,
.distribution-item {
& + & {
margin-top: 18rpx;
}
}
.trend-top,
.distribution-main,
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.trend-label,
.distribution-label,
.detail-label {
flex: 1;
min-width: 0;
font-size: 24rpx;
line-height: 1.6;
color: #1f2937;
word-break: break-all;
}
.trend-value,
.distribution-value,
.detail-value {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 700;
color: #0f172a;
}
.bar-track {
height: 16rpx;
margin-top: 12rpx;
border-radius: 999rpx;
background: #edf2f7;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999rpx;
}
.bar-fill--blue {
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
}
.bar-fill--cyan {
background: linear-gradient(90deg, #38bdf8 0%, #0ea5e9 100%);
}
.bar-fill--orange {
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
}
.bar-fill--green {
background: linear-gradient(90deg, #34d399 0%, #10b981 100%);
}
.ranking-item {
display: flex;
align-items: flex-start;
gap: 16rpx;
& + & {
margin-top: 18rpx;
}
}
.ranking-order {
flex-shrink: 0;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: #ecf4ff;
color: #2563eb;
font-size: 22rpx;
font-weight: 700;
line-height: 40rpx;
text-align: center;
}
.ranking-main {
flex: 1;
min-width: 0;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 20rpx;
}
.detail-panel {
padding: 22rpx;
border-radius: 24rpx;
background: #f9fbfe;
}
.detail-title {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
}
.detail-row {
padding: 16rpx 0;
border-bottom: 1rpx solid rgba(148, 163, 184, 0.18);
}
.detail-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.detail-empty {
margin-top: 18rpx;
font-size: 22rpx;
color: #94a3b8;
}
</style>