This commit is contained in:
2026-07-20 11:56:02 +08:00
commit 2dcae4165b
176 changed files with 34633 additions and 0 deletions

735
pages/gxmu/stats.vue Normal file
View File

@@ -0,0 +1,735 @@
<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>