fix:优化token过期逻辑、考勤管理调整

master
zhoulexin 2 weeks ago
parent de90d5284a
commit fb6a791e64

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

@ -159,6 +159,10 @@ export function deleteTeacher(ids) {
}
// ==================== 课程信息 ====================
// 获取课程列表(下拉用,返回全部)
export function getCourseList() {
return request({ url: '/course/list', method: 'get' })
}
// 获取课程列表(分页)
export function getCourses(params) {

@ -14,29 +14,17 @@
</div>
<div class="navbar-right">
<!-- 快捷搜索 -->
<div class="header-search">
<el-input
v-model="searchKeyword"
placeholder="搜索课程/班级..."
:prefix-icon="Search"
size="small"
clearable
class="search-input"
/>
</div>
<!-- 消息通知 -->
<el-badge :value="appStore.messageCount" :hidden="appStore.messageCount === 0" class="header-badge">
<el-button link @click="showNotifications = true">
<el-icon :size="20"><Bell /></el-icon>
</el-button>
</el-badge>
<!-- 全屏按钮 -->
<el-button link @click="toggleFullscreen">
<el-icon :size="20"><FullScreen /></el-icon>
</el-button>
<el-tooltip
effect="dark"
content="全屏"
placement="bottom"
>
<el-button link @click="toggleFullscreen">
<el-icon :size="20"><FullScreen /></el-icon>
</el-button>
</el-tooltip>
<!-- 用户下拉 -->
<el-dropdown trigger="click" @command="handleCommand">
@ -59,22 +47,6 @@
</template>
</el-dropdown>
</div>
<!-- 通知弹窗 -->
<el-dialog v-model="showNotifications" title="消息通知" width="420px">
<div class="notification-list">
<div v-for="(item, index) in notifications" :key="index" class="notification-item">
<div class="notif-icon" :class="item.type">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div class="notif-content">
<div class="notif-title">{{ item.title }}</div>
<div class="notif-time">{{ item.time }}</div>
</div>
</div>
<el-empty v-if="notifications.length === 0" description="暂无通知" />
</div>
</el-dialog>
</header>
</template>
@ -84,22 +56,12 @@ import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { ElMessage } from 'element-plus'
import { Search, UserFilled } from '@element-plus/icons-vue'
import { UserFilled } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const appStore = useAppStore()
const searchKeyword = ref('')
const showNotifications = ref(false)
const notifications = ref([
{ title: '今日考勤报告已生成', time: '10分钟前', type: 'success', icon: 'CircleCheckFilled' },
{ title: '301教室摄像头离线', time: '30分钟前', type: 'danger', icon: 'WarningFilled' },
{ title: '系统更新通知:新增行为分析功能', time: '2小时前', type: 'info', icon: 'InfoFilled' }
])
const toggleFullscreen = () => {
if (document.fullscreenElement) {
document.exitFullscreen()
@ -143,11 +105,6 @@ const handleCommand = async (cmd) => {
gap: 8px;
}
.header-search {
width: 200px;
margin-right: 8px;
}
.search-input {
:deep(.el-input__wrapper) {
background: #f5f7fa;
@ -197,61 +154,4 @@ const handleCommand = async (cmd) => {
transition: transform 0.2s ease;
}
}
.notification-list {
max-height: 320px;
overflow-y: auto;
}
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-radius: 8px;
transition: background 0.2s ease;
&:hover {
background: #f5f7fa;
}
.notif-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.success {
background: #f0fdf0;
color: #52c41a;
}
&.danger {
background: #fff1f0;
color: #f5222d;
}
&.info {
background: #e8f4ff;
color: #1890ff;
}
}
.notif-content {
flex: 1;
.notif-title {
font-size: 14px;
color: #262626;
line-height: 1.5;
}
.notif-time {
font-size: 12px;
color: #bfbfbf;
margin-top: 2px;
}
}
}
</style>

@ -0,0 +1,44 @@
import { ref } from 'vue'
import { getCourseList, getTeacherList, getClassList, getRoomsList } from '@/api/info'
const courseMap = ref({})
const teacherMap = ref({})
const classMap = ref({})
const roomMap = ref({})
let loadPromise = null
const loadNameMaps = () => {
if (!loadPromise) {
loadPromise = Promise.all([
getCourseList(),
getTeacherList(),
getClassList(),
getRoomsList()
]).then(([courseRes, teacherRes, classRes, roomRes]) => {
const toMap = (list, nameKey = 'name') => {
const map = {}
;(list || []).forEach((item) => { map[item.id] = item[nameKey] })
return map
}
courseMap.value = toMap(courseRes.data, 'courseName')
teacherMap.value = toMap(teacherRes.data, 'name')
classMap.value = toMap(classRes.data, 'className')
roomMap.value = toMap(roomRes.data, 'roomName')
}).catch((e) => {
// 加载失败保留空映射,重置 promise 以便下次可重试
console.error('[useNameMaps] 加载名称映射失败:', e)
loadPromise = null
})
}
return loadPromise
}
/** 根据 ID 获取名称,兜底返回 ID 本身 */
const courseName = (id) => courseMap.value[id] ?? id ?? ''
const teacherName = (id) => teacherMap.value[id] ?? id ?? ''
const className = (id) => classMap.value[id] ?? id ?? ''
const roomName = (id) => roomMap.value[id] ?? id ?? ''
export function useNameMaps() {
return { loadNameMaps, courseName, teacherName, className, roomName }
}

@ -4,7 +4,6 @@ import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
const loading = ref(false)
const messageCount = ref(3)
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
@ -14,16 +13,10 @@ export const useAppStore = defineStore('app', () => {
loading.value = val
}
const clearMessage = () => {
messageCount.value = 0
}
return {
sidebarCollapsed,
loading,
messageCount,
toggleSidebar,
setLoading,
clearMessage
setLoading
}
})

@ -2,6 +2,8 @@ import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
let isTokenExpired = false
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
@ -35,9 +37,13 @@ request.interceptors.response.use(
const { status } = error.response
switch (status) {
case 401:
ElMessage.error('登录已过期,请重新登录')
useUserStore().$patch({ token: '' })
window.location.hash = '#/login'
if (!isTokenExpired) {
isTokenExpired = true
ElMessage.error('登录已过期,请重新登录')
useUserStore().$patch({ token: '' })
window.location.hash = '#/login'
setTimeout(() => { isTokenExpired = false }, 2000)
}
break
case 403:
ElMessage.error('没有访问权限')

@ -8,14 +8,6 @@
placeholder="选择日期"
size="default"
/>
<el-input
v-model="filters.keyword"
placeholder="搜索课程/授课老师/教室"
:prefix-icon="Search"
clearable
size="default"
style="width: 240px"
/>
<el-button type="primary" :icon="Search" @click="handleSearch"></el-button>
<el-button @click="handleReset"></el-button>
</div>
@ -86,25 +78,29 @@
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { getTaskPage } from '@/api/attendance'
import { getRecordPage } from '@/api/attendance'
import { useNameMaps } from '@/composables/useNameMaps'
defineEmits(['showDetail'])
const { loadNameMaps, courseName, teacherName, className, roomName } = useNameMaps()
const loading = ref(false)
const filters = reactive({ attDate: null, keyword: '' })
const filters = reactive({ attDate: null })
const pagination = reactive({ current: 1, pageSize: 10 })
const total = ref(0)
/** 将接口记录转为表格行数据 */
const mapRecord = (item) => ({
courseName: item.courseName,
teacher: item.teacherName,
classroom: item.classroomName,
courseName: courseName(item.courseId),
teacher: teacherName(item.teacherId),
classroom: roomName(item.classroomId),
className: className(item.classId),
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
absentRate: item.absentRate ?? 0
})
const tableData = ref([])
@ -114,14 +110,13 @@ const fetchData = async () => {
try {
const params = {
current: pagination.current,
size: pagination.pageSize,
keyword: filters.keyword
size: pagination.pageSize
}
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 res = await getRecordPage(params)
const { records, total: totalCount } = res.data
if (Array.isArray(records)) {
tableData.value = records.map(mapRecord)
@ -141,7 +136,10 @@ watch(() => pagination.pageSize, () => {
fetchData()
})
onMounted(() => fetchData())
onMounted(async () => {
await loadNameMaps()
fetchData()
})
const totalShould = computed(() => tableData.value.reduce((s, r) => s + r.total, 0))
const totalActual = computed(() => tableData.value.reduce((s, r) => s + r.actual, 0))
@ -158,7 +156,7 @@ const handleSearch = () => {
fetchData()
}
const handleReset = () => {
Object.assign(filters, { attDate: null, keyword: '' })
Object.assign(filters, { attDate: null })
pagination.current = 1
fetchData()
}

@ -36,7 +36,6 @@
<!-- 快捷入口 -->
<div class="quick-actions">
<el-button type="primary" :icon="Plus" @click="activeTab = 'manage'">新增考勤任务</el-button>
<el-button :icon="Download" @click="handleExportAll"></el-button>
<el-button :icon="VideoCamera" @click="$router.push('/bigscreen')"></el-button>
</div>
@ -95,7 +94,8 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getStats } from '@/api/dashboard'
import { getTaskPage } from '@/api/attendance'
import { getRecordPage } from '@/api/attendance'
import { useNameMaps } from '@/composables/useNameMaps'
import DataCard from '@/components/DataCard.vue'
import AttendanceTrendChart from './components/AttendanceTrendChart.vue'
import ClassRanking from './components/ClassRanking.vue'
@ -105,16 +105,19 @@ import { Plus, Download, VideoCamera } from '@element-plus/icons-vue'
/** 将接口记录转为表格行数据 */
const mapRecord = (item) => ({
courseName: item.courseName,
teacher: item.teacherName,
classroom: item.classroomName,
courseName: courseName(item.courseId),
teacher: teacherName(item.teacherId),
classroom: roomName(item.classroomId),
className: className(item.classId),
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
absentRate: item.absentRate ?? 0
})
const { loadNameMaps, courseName, teacherName, className, roomName } = useNameMaps()
// ===== =====
const stats = ref({ attendanceRate: 0, classroomUsage: 0, warningCount: 0 })
@ -132,7 +135,8 @@ const fetchStats = async () => {
}
}
onMounted(() => {
onMounted(async () => {
await loadNameMaps()
fetchStats()
fetchRecentRecords()
})
@ -145,7 +149,7 @@ const recentRecords = ref([])
const fetchRecentRecords = async () => {
try {
const res = await getTaskPage({ current: 1, size: 10 })
const res = await getRecordPage({ current: 1, size: 10 })
const { records } = res.data
if (Array.isArray(records)) {
recentRecords.value = records.map(mapRecord)

Loading…
Cancel
Save