fix:首页接口对接

master
zhoulexin 4 weeks ago
parent 0c8fcefaee
commit fc7f642463

@ -1,4 +1,4 @@
# 开发环境 # 开发环境
VITE_APP_TITLE=教室智能人脸考勤系统(开发) VITE_APP_TITLE=教室智能人脸考勤系统(开发)
VITE_API_BASE_URL=http://localhost:8080/api VITE_API_BASE_URL=http://10.23.22.43:8088/api
VITE_WS_URL=ws://localhost:8080/ws VITE_WS_URL=ws://localhost:8080/ws

11
package-lock.json generated

@ -13,6 +13,7 @@
"echarts": "^5.5.0", "echarts": "^5.5.0",
"element-plus": "^2.6.2", "element-plus": "^2.6.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-router": "^4.3.0" "vue-router": "^4.3.0"
}, },
@ -1970,6 +1971,7 @@
"resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^6.6.3", "@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10" "vue-demi": "^0.14.10"
@ -1987,6 +1989,15 @@
} }
} }
}, },
"node_modules/pinia-plugin-persistedstate": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.3.tgz",
"integrity": "sha512-Cm819WBj/s5K5DGw55EwbXDtx+EZzM0YR5AZbq9XE3u0xvXwvX2JnWoFpWIcdzISBHqy9H1UiSIUmXyXqWsQRQ==",
"license": "MIT",
"peerDependencies": {
"pinia": "^2.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",

@ -9,17 +9,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"element-plus": "^2.6.2",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.8",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"axios": "^1.6.8" "element-plus": "^2.6.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.0", "sass": "^1.72.0",
"sass": "^1.72.0" "vite": "^5.2.0"
} }
} }

@ -0,0 +1,6 @@
import request from '@/utils/request'
/** 分页查询考勤任务 */
export const getTaskPage = (params) => {
return request.get('/attendance/task/page', { params })
}

@ -0,0 +1,11 @@
import request from '@/utils/request'
/** 登录 */
export const login = (params) => {
return request.post('/auth/login', params)
}
/** 退出登录 */
export const logout = () => {
return request.post('/auth/logout')
}

@ -0,0 +1,16 @@
import request from '@/utils/request'
/** 获取首页核心统计数据 */
export const getStats = () => {
return request.get('/dashboard/stats')
}
/** 获取近7天出勤趋势 */
export const getTrend = () => {
return request.get('/dashboard/trend')
}
/** 获取各班级出勤率排名 */
export const getRanking = () => {
return request.get('/dashboard/ranking')
}

@ -42,7 +42,7 @@
<el-dropdown trigger="click" @command="handleCommand"> <el-dropdown trigger="click" @command="handleCommand">
<div class="user-info"> <div class="user-info">
<el-avatar :size="32" :icon="UserFilled" class="user-avatar" /> <el-avatar :size="32" :icon="UserFilled" class="user-avatar" />
<span class="user-name">{{ userStore.userInfo.name }}</span> <span class="user-name">{{ userStore.name }}</span>
<el-icon class="user-arrow"><ArrowDown /></el-icon> <el-icon class="user-arrow"><ArrowDown /></el-icon>
</div> </div>
<template #dropdown> <template #dropdown>
@ -84,6 +84,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Search, UserFilled } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -107,8 +108,9 @@ const toggleFullscreen = () => {
} }
} }
const handleCommand = (cmd) => { const handleCommand = async (cmd) => {
if (cmd === 'logout') { if (cmd === 'logout') {
await userStore.logout()
ElMessage.success('已退出登录') ElMessage.success('已退出登录')
router.push('/login') router.push('/login')
} else if (cmd === 'profile') { } else if (cmd === 'profile') {

@ -1,5 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
@ -17,7 +18,10 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component) app.component(key, component)
} }
app.use(createPinia()) const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router) app.use(router)
app.use(ElementPlus, { locale: zhCn }) app.use(ElementPlus, { locale: zhCn })
app.mount('#app') app.mount('#app')

@ -74,7 +74,18 @@ const router = createRouter({
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
document.title = to.meta.title ? `${to.meta.title} - 教室智能人脸考勤系统` : '教室智能人脸考勤系统' document.title = to.meta.title ? `${to.meta.title} - 教室智能人脸考勤系统` : '教室智能人脸考勤系统'
next()
// 登录鉴权:从持久化的 user store 中读取 token
let token = ''
try {
const store = localStorage.getItem('user')
if (store) token = JSON.parse(store).token || ''
} catch { /* ignore */ }
if (!token && to.path !== '/login') {
next('/login')
} else {
next()
}
}) })
export default router export default router

@ -1,45 +1,83 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { logout as logoutApi } from '@/api/auth'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref({ const userInfo = ref({
name: '张教务', userId: null,
username: '',
realName: '',
avatar: '', avatar: '',
role: 'admin', // admin | teacher | staff role: '',
school: '阳光实验学校' roleName: '',
schoolName: ''
}) })
const roleName = computed(() => { const name = computed(() => userInfo.value.realName || userInfo.value.username || '未知用户')
const map = {
admin: '管理员', const roleLabel = computed(() => {
teacher: '教师', return userInfo.value.roleName || '未知'
staff: '教务员'
}
return map[userInfo.value.role] || '未知'
}) })
const permissions = computed(() => { const permissions = computed(() => {
const role = userInfo.value.role
const permMap = { const permMap = {
admin: ['dashboard', 'attendance', 'classroom', 'behavior', 'history', 'bigscreen', 'settings'], admin: ['dashboard', 'attendance', 'classroom', 'behavior', 'history', 'bigscreen', 'settings'],
staff: ['dashboard', 'attendance', 'classroom', 'history'], staff: ['dashboard', 'attendance', 'classroom', 'history'],
teacher: ['dashboard', 'attendance', 'behavior', 'history'] teacher: ['dashboard', 'attendance', 'behavior', 'history']
} }
return permMap[userInfo.value.role] || [] return permMap[role] || []
}) })
const hasPermission = (page) => { const hasPermission = (page) => {
return permissions.value.includes(page) return permissions.value.includes(page)
} }
const logout = () => { /** 登录成功后设置 token 和用户信息 */
userInfo.value = null const setLoginData = (data) => {
token.value = data.token
userInfo.value = {
userId: data.userId,
username: data.username,
realName: data.realName,
avatar: data.avatar,
role: data.role,
roleName: data.roleName,
schoolName: data.schoolName
}
}
/** 退出登录 */
const logout = async () => {
try {
await logoutApi()
} catch {
// 即使接口失败,也清除本地状态
}
token.value = ''
userInfo.value = {
userId: null,
username: '',
realName: '',
avatar: '',
role: '',
roleName: '',
schoolName: ''
}
} }
return { return {
token,
userInfo, userInfo,
roleName, name,
roleName: roleLabel,
permissions, permissions,
hasPermission, hasPermission,
setLoginData,
logout logout
} }
}, {
persist: true
}) })

@ -10,10 +10,15 @@ const request = axios.create({
// 请求拦截器 // 请求拦截器
request.interceptors.request.use( request.interceptors.request.use(
(config) => { (config) => {
// 可在此添加 token // 从 pinia-plugin-persistedstate 持久化的 user store 中读取 token
const token = localStorage.getItem('token') const store = localStorage.getItem('user')
if (token) { if (store) {
config.headers.Authorization = `Bearer ${token}` try {
const { token } = JSON.parse(store)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
} catch { /* ignore */ }
} }
return config return config
}, },
@ -23,9 +28,9 @@ request.interceptors.request.use(
// 响应拦截器 // 响应拦截器
request.interceptors.response.use( request.interceptors.response.use(
(response) => { (response) => {
const { code, message, data } = response.data const { code, message } = response.data
if (code === 200) { if (code === 200) {
return data return response.data
} }
ElMessage.error(message || '请求失败') ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message)) return Promise.reject(new Error(message))

@ -3,30 +3,18 @@
<!-- 筛选区 --> <!-- 筛选区 -->
<div class="filter-bar"> <div class="filter-bar">
<el-date-picker <el-date-picker
v-model="filters.dateRange" v-model="filters.attDate"
type="daterange" type="date"
range-separator="至" placeholder="选择日期"
start-placeholder="开始日期"
end-placeholder="结束日期"
size="default" size="default"
/> />
<el-select v-model="filters.department" placeholder="选择学院" clearable size="default" style="width: 150px">
<el-option label="计算机学院" value="cs" />
<el-option label="数学学院" value="math" />
<el-option label="外国语学院" value="english" />
</el-select>
<el-select v-model="filters.class" placeholder="选择班级" clearable size="default" style="width: 150px">
<el-option label="2021级1班" value="c1" />
<el-option label="2022级2班" value="c2" />
<el-option label="2023级1班" value="c3" />
</el-select>
<el-input <el-input
v-model="filters.keyword" v-model="filters.keyword"
placeholder="搜索课程/班级..." placeholder="搜索课程/授课老师/教室"
:prefix-icon="Search" :prefix-icon="Search"
clearable clearable
size="default" size="default"
style="width: 200px" style="width: 240px"
/> />
<el-button type="primary" :icon="Search" @click="handleSearch"></el-button> <el-button type="primary" :icon="Search" @click="handleSearch"></el-button>
<el-button @click="handleReset"></el-button> <el-button @click="handleReset"></el-button>
@ -55,9 +43,14 @@
<!-- 数据表格 --> <!-- 数据表格 -->
<el-table :data="tableData" stripe v-loading="loading"> <el-table :data="tableData" stripe v-loading="loading">
<el-table-column prop="courseName" label="课程名称" min-width="150" /> <el-table-column prop="courseName" label="课程名称" min-width="150" />
<el-table-column prop="teacher" label="授课教师" width="100" /> <el-table-column prop="teacher" label="授课教师" width="100" align="center" />
<el-table-column prop="classroom" label="教室" width="120" /> <el-table-column prop="classroom" label="教室" width="120" align="center" />
<el-table-column prop="time" label="上课时间" width="170" sortable /> <el-table-column prop="time" label="上课时间" width="170" align="center" sortable>
<template #default="{ row }">
<span v-html="row.time"></span>
</template>
</el-table-column>
<el-table-column prop="total" label="应到" width="70" align="center" /> <el-table-column prop="total" label="应到" width="70" align="center" />
<el-table-column prop="actual" label="实到" width="70" align="center" /> <el-table-column prop="actual" label="实到" width="70" align="center" />
<el-table-column prop="absentCount" label="缺勤" width="70" align="center"> <el-table-column prop="absentCount" label="缺勤" width="70" align="center">
@ -81,7 +74,7 @@
<el-pagination <el-pagination
v-model:current-page="pagination.current" v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize" v-model:page-size="pagination.pageSize"
:total="tableData.length" :total="total"
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next"
background background
@ -90,25 +83,65 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { getTaskPage } from '@/api/attendance'
defineEmits(['showDetail']) defineEmits(['showDetail'])
const loading = ref(false) const loading = ref(false)
const filters = reactive({ dateRange: null, department: '', class: '', keyword: '' }) const filters = reactive({ attDate: null, keyword: '' })
const pagination = reactive({ current: 1, pageSize: 10 }) const pagination = reactive({ current: 1, pageSize: 10 })
const total = ref(0)
/** 将接口记录转为表格行数据 */
const mapRecord = (item) => ({
courseName: item.courseName,
teacher: item.teacherName,
classroom: item.classroomName,
time: `${item.startTime || ''}<br/>至<br/>${item.endTime || ''}`,
total: item.totalCount,
actual: item.actualCount,
absentCount: item.absentCount,
absentRate: item.totalCount ? Math.round((item.absentCount / item.totalCount) * 1000) / 10 : 0
})
const tableData = ref([ const tableData = ref([])
{ courseName: '高等数学A', teacher: '王教授', classroom: '301教室', time: '2024-06-01 08:00-09:40', total: 45, actual: 44, absentCount: 1, absentRate: 2.2 },
{ courseName: '大学英语B', teacher: '李老师', classroom: '205教室', time: '2024-06-01 10:00-11:40', total: 38, actual: 38, absentCount: 0, absentRate: 0 }, const fetchData = async () => {
{ courseName: '计算机导论', teacher: '张教授', classroom: '102实验室', time: '2024-06-01 14:00-15:40', total: 52, actual: 49, absentCount: 3, absentRate: 5.8 }, loading.value = true
{ courseName: '线性代数', teacher: '赵教授', classroom: '408教室', time: '2024-05-31 08:00-09:40', total: 40, actual: 38, absentCount: 2, absentRate: 5.0 }, try {
{ courseName: '马克思原理', teacher: '刘老师', classroom: '大阶梯教室', time: '2024-05-31 10:00-11:40', total: 120, actual: 108, absentCount: 12, absentRate: 10.0 }, const params = {
{ courseName: '数据结构', teacher: '陈教授', classroom: '201教室', time: '2024-05-31 14:00-15:40', total: 48, actual: 47, absentCount: 1, absentRate: 2.1 }, current: pagination.current,
{ courseName: '操作系统', teacher: '杨教授', classroom: '310教室', time: '2024-05-30 08:00-09:40', total: 42, actual: 40, absentCount: 2, absentRate: 4.8 }, size: pagination.pageSize,
{ courseName: '数据库原理', teacher: '周老师', classroom: '105实验室', time: '2024-05-30 10:00-11:40', total: 36, actual: 36, absentCount: 0, absentRate: 0 } keyword: filters.keyword
]) }
if (filters.attDate) {
const d = new Date(filters.attDate)
params.attDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const res = await getTaskPage(params)
const { records, total: totalCount } = res.data
if (Array.isArray(records)) {
tableData.value = records.map(mapRecord)
}
total.value = totalCount ?? 0
} catch {
//
} finally {
loading.value = false
}
}
//
watch(() => pagination.current, () => fetchData())
watch(() => pagination.pageSize, () => {
pagination.current = 1
fetchData()
})
onMounted(() => fetchData())
const totalShould = computed(() => tableData.value.reduce((s, r) => s + r.total, 0)) const totalShould = computed(() => tableData.value.reduce((s, r) => s + r.total, 0))
const totalActual = computed(() => tableData.value.reduce((s, r) => s + r.actual, 0)) const totalActual = computed(() => tableData.value.reduce((s, r) => s + r.actual, 0))
@ -120,10 +153,14 @@ const avgAbsentRate = computed(() => {
const getAbsentType = (rate) => (rate >= 10 ? 'danger' : rate > 5 ? 'warning' : 'success') const getAbsentType = (rate) => (rate >= 10 ? 'danger' : rate > 5 ? 'warning' : 'success')
const handleSearch = () => ElMessage.success('搜索完成') const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => { const handleReset = () => {
Object.assign(filters, { dateRange: null, department: '', class: '', keyword: '' }) Object.assign(filters, { attDate: null, keyword: '' })
ElMessage.info('已重置') pagination.current = 1
fetchData()
} }
const handleExport = (row) => ElMessage.success(`正在导出 ${row.courseName} 考勤明细...`) const handleExport = (row) => ElMessage.success(`正在导出 ${row.courseName} 考勤明细...`)
</script> </script>

@ -11,20 +11,24 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { getTrend } from '@/api/dashboard'
const trendChartRef = ref(null) const trendChartRef = ref(null)
let trendChart = null let trendChart = null
const initTrendChart = () => { const initTrendChart = (dates = [], values = []) => {
if (!trendChartRef.value) return if (!trendChartRef.value) return
trendChart = echarts.init(trendChartRef.value) //
if (!trendChart) {
trendChart = echarts.init(trendChartRef.value)
}
trendChart.setOption({ trendChart.setOption({
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
grid: { top: 20, right: 20, bottom: 20, left: 40 }, grid: { top: 20, right: 20, bottom: 20, left: 40 },
xAxis: { type: 'category', data: ['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01'], axisTick: { show: false } }, xAxis: { type: 'category', data: dates, axisTick: { show: false } },
yAxis: { type: 'value', min: 80, max: 100, axisLabel: { formatter: '{value}%' } }, yAxis: { type: 'value', min: 80, max: 100, axisLabel: { formatter: '{value}%' } },
series: [{ series: [{
data: [93, 95, 94.5, 96, 95.8, 97, 96.8], data: values,
type: 'line', smooth: true, symbol: 'circle', symbolSize: 6, type: 'line', smooth: true, symbol: 'circle', symbolSize: 6,
lineStyle: { color: '#52c41a', width: 3 }, itemStyle: { color: '#52c41a' }, lineStyle: { color: '#52c41a', width: 3 }, itemStyle: { color: '#52c41a' },
areaStyle: { areaStyle: {
@ -37,10 +41,31 @@ const initTrendChart = () => {
}) })
} }
const fetchTrendData = async () => {
try {
const res = await getTrend()
const data = res.data
if (data && Array.isArray(data.dates) && Array.isArray(data.values)) {
initTrendChart(data.dates, data.values)
} else {
// 使
const defaultDates = ['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01']
const defaultValues = [93, 95, 94.5, 96, 95.8, 97, 96.8]
initTrendChart(defaultDates, defaultValues)
}
} catch {
// 使
initTrendChart(
['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01'],
[93, 95, 94.5, 96, 95.8, 97, 96.8]
)
}
}
const handleResize = () => trendChart?.resize() const handleResize = () => trendChart?.resize()
onMounted(() => { onMounted(() => {
initTrendChart() fetchTrendData()
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
}) })

@ -3,10 +3,10 @@
<div class="chart-header"> <div class="chart-header">
<h3>各班级出勤率对比</h3> <h3>各班级出勤率对比</h3>
</div> </div>
<div class="class-rank"> <div v-if="classRank && classRank.length > 0" class="class-rank">
<div v-for="(item, index) in classRank" :key="index" class="rank-item"> <div v-for="(item, index) in classRank" :key="index" class="rank-item">
<span class="rank-num" :class="{ top: index < 3 }">{{ index + 1 }}</span> <span class="rank-num" :class="{ top: index < 3 }">{{ index + 1 }}</span>
<span class="rank-name">{{ item.name }}</span> <span class="rank-name">{{ item.className }}</span>
<div class="rank-bar-wrap"> <div class="rank-bar-wrap">
<div <div
class="rank-bar" class="rank-bar"
@ -16,19 +16,35 @@
<span class="rank-value">{{ item.rate }}%</span> <span class="rank-value">{{ item.rate }}%</span>
</div> </div>
</div> </div>
<div v-else class="no-data">
<el-empty description="暂无数据"></el-empty>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { getRanking } from '@/api/dashboard'
const classRank = ref([])
const fetchRanking = async () => {
try {
const res = await getRanking()
const data = res.data
if (Array.isArray(data) && data.length > 0) {
classRank.value = data
} else {
classRank.value = []
}
} catch {
classRank.value = []
}
}
const classRank = ref([ onMounted(() => {
{ name: '计算机科学2021-1班', rate: 98.5 }, fetchRanking()
{ name: '软件工程2022-2班', rate: 97.2 }, })
{ name: '人工智能2023-1班', rate: 96.8 },
{ name: '大数据2021-2班', rate: 95.4 },
{ name: '网络安全2022-1班', rate: 94.0 }
])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

@ -56,8 +56,12 @@
</div> </div>
<el-table :data="recentRecords" stripe> <el-table :data="recentRecords" stripe>
<el-table-column prop="courseName" label="课程名称" min-width="150" /> <el-table-column prop="courseName" label="课程名称" min-width="150" />
<el-table-column prop="classroom" label="教室" width="120" /> <el-table-column prop="classroom" label="教室" width="120" align="center" />
<el-table-column prop="time" label="上课时间" width="170" sortable /> <el-table-column prop="time" label="上课时间" width="300" sortable align="center">
<template #default="{ row }">
<span v-html="row.time"></span>
</template>
</el-table-column>
<el-table-column prop="total" label="应到" width="70" align="center" /> <el-table-column prop="total" label="应到" width="70" align="center" />
<el-table-column prop="actual" label="实到" width="70" align="center" /> <el-table-column prop="actual" label="实到" width="70" align="center" />
<el-table-column prop="absentRate" label="缺勤率" width="90" align="center"> <el-table-column prop="absentRate" label="缺勤率" width="90" align="center">
@ -88,28 +92,68 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getStats } from '@/api/dashboard'
import { getTaskPage } from '@/api/attendance'
import DataCard from '@/components/DataCard.vue' import DataCard from '@/components/DataCard.vue'
import AttendanceTrendChart from './components/AttendanceTrendChart.vue' import AttendanceTrendChart from './components/AttendanceTrendChart.vue'
import ClassRanking from './components/ClassRanking.vue' import ClassRanking from './components/ClassRanking.vue'
import AttendanceManage from './components/AttendanceManage.vue' import AttendanceManage from './components/AttendanceManage.vue'
import AttendanceDetail from './components/AttendanceDetail.vue' import AttendanceDetail from './components/AttendanceDetail.vue'
import { Plus, Download, VideoCamera } from '@element-plus/icons-vue'
/** 将接口记录转为表格行数据 */
const mapRecord = (item) => ({
courseName: item.courseName,
teacher: item.teacherName,
classroom: item.classroomName,
time: `${item.startTime || ''} - ${item.endTime || ''}`,
total: item.totalCount,
actual: item.actualCount,
absentCount: item.absentCount,
absentRate: item.totalCount ? Math.round((item.absentCount / item.totalCount) * 1000) / 10 : 0
})
// ===== ===== // ===== =====
const stats = ref({ attendanceRate: 96.8, classroomUsage: 78.5, warningCount: 3 }) const stats = ref({ attendanceRate: 0, classroomUsage: 0, warningCount: 0 })
const fetchStats = async () => {
try {
const res = await getStats()
const data = res.data
stats.value = {
attendanceRate: data.attendanceRate ?? 0,
classroomUsage: data.classroomUsage ?? 0,
warningCount: data.warningCount ?? 0
}
} catch {
//
}
}
onMounted(() => {
fetchStats()
fetchRecentRecords()
})
// ===== tabs ===== // ===== tabs =====
const activeTab = ref('overview') const activeTab = ref('overview')
// ===== ===== // ===== =====
const recentRecords = ref([ const recentRecords = ref([])
{ courseName: '高等数学A', teacher: '王教授', classroom: '301教室', time: '2024-06-01 08:00-09:40', total: 45, actual: 44, absentCount: 1, absentRate: 2.2 },
{ courseName: '大学英语B', teacher: '李老师', classroom: '205教室', time: '2024-06-01 10:00-11:40', total: 38, actual: 38, absentCount: 0, absentRate: 0 }, const fetchRecentRecords = async () => {
{ courseName: '计算机导论', teacher: '张教授', classroom: '102实验室', time: '2024-06-01 14:00-15:40', total: 52, actual: 49, absentCount: 3, absentRate: 5.8 }, try {
{ courseName: '线性代数', teacher: '赵教授', classroom: '408教室', time: '2024-05-31 08:00-09:40', total: 40, actual: 38, absentCount: 2, absentRate: 5.0 }, const res = await getTaskPage({ current: 1, size: 10 })
{ courseName: '马克思原理', teacher: '刘老师', classroom: '大阶梯教室', time: '2024-05-31 10:00-11:40', total: 120, actual: 108, absentCount: 12, absentRate: 10.0 } const { records } = res.data
]) if (Array.isArray(records)) {
recentRecords.value = records.map(mapRecord)
}
} catch {
//
}
}
// ===== ===== // ===== =====
const detailVisible = ref(false) const detailVisible = ref(false)

@ -43,7 +43,7 @@
<div class="form-options"> <div class="form-options">
<el-checkbox v-model="rememberMe"></el-checkbox> <el-checkbox v-model="rememberMe"></el-checkbox>
<el-link type="primary" :underline="false">忘记密码</el-link> <el-link type="primary" underline="never">忘记密码</el-link>
</div> </div>
<el-form-item> <el-form-item>
@ -67,11 +67,15 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { login } from '@/api/auth'
import { useUserStore } from '@/stores/user'
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const formRef = ref(null) const formRef = ref(null)
const loading = ref(false) const loading = ref(false)
const rememberMe = ref(false) const rememberMe = ref(false)
@ -86,19 +90,48 @@ const rules = {
password: [{ required: true, message: '请输入密码', trigger: 'blur' }] password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
} }
//
onMounted(() => {
const saved = localStorage.getItem('rememberedLogin')
if (saved) {
try {
const { username, password } = JSON.parse(saved)
form.username = username
form.password = password
rememberMe.value = true
} catch { /* ignore */ }
}
})
const handleLogin = () => { const handleLogin = () => {
formRef.value?.validate((valid) => { formRef.value?.validate(async (valid) => {
if (!valid) return if (!valid) return
loading.value = true loading.value = true
setTimeout(() => { try {
loading.value = false const res = await login({
ElMessage.success('欢迎回来!') username: form.username,
password: form.password
})
userStore.setLoginData(res.data)
//
if (rememberMe.value) {
localStorage.setItem('rememberedLogin', JSON.stringify({
username: form.username,
password: form.password
}))
} else {
localStorage.removeItem('rememberedLogin')
}
ElMessage.success(`欢迎回来,${res.data.realName || res.data.username}`)
router.push('/dashboard') router.push('/dashboard')
}, 800) } catch {
} finally {
loading.value = false
}
}) })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

Loading…
Cancel
Save