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

master
zhoulexin 2 weeks ago
parent de90d5284a
commit fb6a791e64

@ -1,6 +1,6 @@
import request from '@/utils/request' import request from '@/utils/request'
/** 分页查询考勤任务 */ /** 分页查询考勤记录 */
export const getTaskPage = (params) => { export const getRecordPage = (params) => {
return request.get('/attendance/task/page', { 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) { export function getCourses(params) {

@ -14,29 +14,17 @@
</div> </div>
<div class="navbar-right"> <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-tooltip
<el-icon :size="20"><FullScreen /></el-icon> effect="dark"
</el-button> 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"> <el-dropdown trigger="click" @command="handleCommand">
@ -59,22 +47,6 @@
</template> </template>
</el-dropdown> </el-dropdown>
</div> </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> </header>
</template> </template>
@ -84,22 +56,12 @@ 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' import { UserFilled } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const appStore = useAppStore() 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 = () => { const toggleFullscreen = () => {
if (document.fullscreenElement) { if (document.fullscreenElement) {
document.exitFullscreen() document.exitFullscreen()
@ -143,11 +105,6 @@ const handleCommand = async (cmd) => {
gap: 8px; gap: 8px;
} }
.header-search {
width: 200px;
margin-right: 8px;
}
.search-input { .search-input {
:deep(.el-input__wrapper) { :deep(.el-input__wrapper) {
background: #f5f7fa; background: #f5f7fa;
@ -197,61 +154,4 @@ const handleCommand = async (cmd) => {
transition: transform 0.2s ease; 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> </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', () => { export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const loading = ref(false) const loading = ref(false)
const messageCount = ref(3)
const toggleSidebar = () => { const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value sidebarCollapsed.value = !sidebarCollapsed.value
@ -14,16 +13,10 @@ export const useAppStore = defineStore('app', () => {
loading.value = val loading.value = val
} }
const clearMessage = () => {
messageCount.value = 0
}
return { return {
sidebarCollapsed, sidebarCollapsed,
loading, loading,
messageCount,
toggleSidebar, toggleSidebar,
setLoading, setLoading
clearMessage
} }
}) })

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

@ -8,14 +8,6 @@
placeholder="选择日期" placeholder="选择日期"
size="default" 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 type="primary" :icon="Search" @click="handleSearch"></el-button>
<el-button @click="handleReset"></el-button> <el-button @click="handleReset"></el-button>
</div> </div>
@ -86,25 +78,29 @@
import { ref, reactive, computed, onMounted, watch } 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 { Search } from '@element-plus/icons-vue'
import { getTaskPage } from '@/api/attendance' import { getRecordPage } from '@/api/attendance'
import { useNameMaps } from '@/composables/useNameMaps'
defineEmits(['showDetail']) defineEmits(['showDetail'])
const { loadNameMaps, courseName, teacherName, className, roomName } = useNameMaps()
const loading = ref(false) const loading = ref(false)
const filters = reactive({ attDate: null, keyword: '' }) const filters = reactive({ attDate: null })
const pagination = reactive({ current: 1, pageSize: 10 }) const pagination = reactive({ current: 1, pageSize: 10 })
const total = ref(0) const total = ref(0)
/** 将接口记录转为表格行数据 */ /** 将接口记录转为表格行数据 */
const mapRecord = (item) => ({ const mapRecord = (item) => ({
courseName: item.courseName, courseName: courseName(item.courseId),
teacher: item.teacherName, teacher: teacherName(item.teacherId),
classroom: item.classroomName, classroom: roomName(item.classroomId),
className: className(item.classId),
time: `${item.startTime || ''}${item.endTime || ''}`, time: `${item.startTime || ''}${item.endTime || ''}`,
total: item.totalCount, total: item.totalCount,
actual: item.actualCount, actual: item.actualCount,
absentCount: item.absentCount, absentCount: item.absentCount,
absentRate: item.totalCount ? Math.round((item.absentCount / item.totalCount) * 1000) / 10 : 0 absentRate: item.absentRate ?? 0
}) })
const tableData = ref([]) const tableData = ref([])
@ -114,14 +110,13 @@ const fetchData = async () => {
try { try {
const params = { const params = {
current: pagination.current, current: pagination.current,
size: pagination.pageSize, size: pagination.pageSize
keyword: filters.keyword
} }
if (filters.attDate) { if (filters.attDate) {
const d = new Date(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')}` 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 const { records, total: totalCount } = res.data
if (Array.isArray(records)) { if (Array.isArray(records)) {
tableData.value = records.map(mapRecord) tableData.value = records.map(mapRecord)
@ -141,7 +136,10 @@ watch(() => pagination.pageSize, () => {
fetchData() fetchData()
}) })
onMounted(() => fetchData()) onMounted(async () => {
await loadNameMaps()
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))
@ -158,7 +156,7 @@ const handleSearch = () => {
fetchData() fetchData()
} }
const handleReset = () => { const handleReset = () => {
Object.assign(filters, { attDate: null, keyword: '' }) Object.assign(filters, { attDate: null })
pagination.current = 1 pagination.current = 1
fetchData() fetchData()
} }

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

Loading…
Cancel
Save