clickhouse
This commit is contained in:
@@ -194,6 +194,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
|
||||
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader']),
|
||||
{ path: '/event-analytics', label: '运行数据分析' },
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -263,6 +263,14 @@ const router = createRouter({
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'event-analytics',
|
||||
name: 'event-analytics',
|
||||
component: () => import('../views/EventAnalyticsView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'other-exams',
|
||||
name: 'other-exams',
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { DataAnalysis, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
|
||||
interface Status {
|
||||
enabled: boolean
|
||||
syncIntervalSeconds: number
|
||||
sourceLookbackDays: number
|
||||
batchSize: number
|
||||
database: string
|
||||
}
|
||||
|
||||
const status = ref<Status>()
|
||||
const attendance = ref<any[]>([])
|
||||
const grades = ref<any[]>([])
|
||||
const audit = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const range = ref<[Date, Date]>([
|
||||
new Date(Date.now() - 29 * 24 * 60 * 60 * 1000),
|
||||
new Date(),
|
||||
])
|
||||
|
||||
const attendanceTotals = computed(() => attendance.value.reduce((total, row) => total + Number(row.total ?? 0), 0))
|
||||
const absentTotals = computed(() => attendance.value.reduce((total, row) => total + Number(row.absent ?? 0), 0))
|
||||
const auditTotals = computed(() => audit.value.reduce((total, row) => total + Number(row.total ?? 0), 0))
|
||||
|
||||
function dateOnly(value: Date) {
|
||||
const offset = value.getTimezoneOffset() * 60_000
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [statusResponse, overviewResponse] = await Promise.all([
|
||||
http.get<Status>('/clickhouse-analytics/status'),
|
||||
http.get('/clickhouse-analytics/overview', {
|
||||
params: { from: dateOnly(range.value[0]), to: dateOnly(range.value[1]) },
|
||||
}),
|
||||
])
|
||||
status.value = statusResponse.data
|
||||
attendance.value = overviewResponse.data.attendance ?? []
|
||||
grades.value = overviewResponse.data.grades ?? []
|
||||
audit.value = overviewResponse.data.audit ?? []
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error) || '加载运行数据分析失败。')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-loading="loading" class="event-analytics page-stack">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="eyebrow">CLICKHOUSE READ MODEL</span>
|
||||
<h2>运行数据分析</h2>
|
||||
<p>考勤、教学班成绩与访问审计的只读聚合;不会影响教务业务写入。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-date-picker v-model="range" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" />
|
||||
<el-button type="primary" :icon="Refresh" @click="load">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert v-if="status && !status.enabled" type="warning" :closable="false" show-icon title="ClickHouse 分析未启用">
|
||||
请在服务配置中启用 ClickHouseAnalytics 后再查看分析数据。
|
||||
</el-alert>
|
||||
|
||||
<template v-else>
|
||||
<section class="metrics">
|
||||
<article><el-icon><DataAnalysis /></el-icon><span>考勤记录</span><b>{{ attendanceTotals.toLocaleString() }}</b></article>
|
||||
<article><el-icon><DataAnalysis /></el-icon><span>缺勤记录</span><b>{{ absentTotals.toLocaleString() }}</b></article>
|
||||
<article v-if="audit.length"><el-icon><DataAnalysis /></el-icon><span>访问审计</span><b>{{ auditTotals.toLocaleString() }}</b></article>
|
||||
</section>
|
||||
|
||||
<section class="analysis-grid">
|
||||
<el-card shadow="never">
|
||||
<template #header>每日考勤</template>
|
||||
<el-table :data="attendance" size="small" empty-text="所选范围暂无考勤投影数据">
|
||||
<el-table-column prop="attendanceDate" label="日期" min-width="110" />
|
||||
<el-table-column prop="total" label="总人次" align="right" />
|
||||
<el-table-column prop="present" label="到课" align="right" />
|
||||
<el-table-column prop="absent" label="缺勤" align="right" />
|
||||
<el-table-column prop="late" label="迟到" align="right" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<template #header>学期成绩趋势</template>
|
||||
<el-table :data="grades" size="small" empty-text="暂无已计算的教学班成绩统计">
|
||||
<el-table-column prop="academicTermName" label="学期" min-width="130" />
|
||||
<el-table-column prop="studentCount" label="学生数" align="right" />
|
||||
<el-table-column prop="averageScore" label="加权平均分" align="right" />
|
||||
<el-table-column prop="passRate" label="通过率" align="right">
|
||||
<template #default="{ row }">{{ (Number(row.passRate) * 100).toFixed(1) }}%</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<el-card v-if="audit.length" shadow="never">
|
||||
<template #header>访问审计(仅全校数据范围)</template>
|
||||
<el-table :data="audit" size="small">
|
||||
<el-table-column prop="date" label="日期" min-width="110" />
|
||||
<el-table-column prop="total" label="操作量" align="right" />
|
||||
<el-table-column prop="failed" label="异常响应" align="right" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-analytics { --ink: #18324f; --line: #dce3ec; }
|
||||
.page-intro { display: flex; justify-content: space-between; align-items: end; gap: 18px; }
|
||||
.eyebrow { color: #3d75aa; font-size: 12px; letter-spacing: .12em; font-weight: 700; }
|
||||
h2 { margin: 5px 0; color: var(--ink); } p { margin: 0; color: #6b7b8d; }
|
||||
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.metrics, .analysis-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.analysis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.metrics article { padding: 20px; border: 1px solid var(--line); background: #fff; border-radius: 8px; display: grid; grid-template-columns: 26px 1fr; gap: 4px 9px; }
|
||||
.metrics .el-icon { color: #3574a8; grid-row: span 2; font-size: 21px; } .metrics span { color: #657689; font-size: 13px; } .metrics b { color: var(--ink); font-size: 24px; }
|
||||
@media (max-width: 760px) { .page-intro { align-items: start; flex-direction: column; } .metrics, .analysis-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user