Files
yunzerwebsiteallinone/uniapp/pages/oa/attendance/index.vue
T

970 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view class="body">
<!-- ==================== 今日打卡 ==================== -->
<view class="punch-card">
<view class="punch-head">
<view class="head-left">
<text class="head-date">{{ dateText }}</text>
<text class="head-sub">{{ employeeText }}</text>
</view>
<view class="head-clock">
<text class="clock-text">{{ clockText }}</text>
<text class="clock-label">{{ weekdayText }}</text>
</view>
</view>
<view class="shift-row">
<view class="shift-badge" :style="{ background: shiftColor }">{{ shortName }}</view>
<view class="shift-body">
<text class="shift-name">{{ shiftName }}</text>
<text class="shift-time">{{ shiftTimeText }}</text>
</view>
<text class="match-chip" :class="matched ? 'is-ok' : 'is-warn'">
{{ matched ? '已匹配排班' : '未匹配排班' }}
</text>
</view>
<!-- 未匹配排班等提醒:只提示,不阻断打卡 -->
<view v-if="warning" class="warn-bar">
<FaIcon name="triangle-exclamation" color="#b45309" :size="14" />
<text class="warn-text">{{ warning }}</text>
</view>
<view class="punch-times">
<view class="time-item">
<text class="time-label">签到</text>
<text class="time-value">{{ record.clock_in_hm || '--:--' }}</text>
<text v-if="record.clock_in_hm" class="time-status" :class="statusClass(record.clock_in_status)">
{{ record.clock_in_text }}
</text>
</view>
<view class="time-divider" />
<view class="time-item">
<text class="time-label">签退</text>
<text class="time-value">{{ record.clock_out_hm || '--:--' }}</text>
<text v-if="record.clock_out_hm" class="time-status" :class="statusClass(record.clock_out_status)">
{{ record.clock_out_text }}
</text>
</view>
</view>
<view class="punch-actions">
<view class="punch-btn is-in" :class="{ 'is-disabled': !canClockIn, 'is-busy': punching }" @tap="punch('in')">
<text class="btn-title">{{ record.clock_in_hm ? '已签到' : '签到' }}</text>
<text class="btn-sub">{{ record.clock_in_hm || '上班打卡' }}</text>
</view>
<view class="punch-btn is-out" :class="{ 'is-disabled': !canClockOut, 'is-busy': punching }" @tap="punch('out')">
<text class="btn-title">{{ record.clock_out_hm ? '已签退' : '签退' }}</text>
<text class="btn-sub">{{ record.clock_out_hm || '下班打卡' }}</text>
</view>
</view>
</view>
<!-- ==================== 近 7 天 ==================== -->
<view v-if="recentDays.length" class="week-card">
<view class="block-head">
<text class="block-title">近 7 天</text>
<text class="block-sub">排班与打卡概览</text>
</view>
<view class="week-row">
<view
v-for="day in recentDays"
:key="day.date"
class="week-cell"
:class="[dayClass(day.day_status), { 'is-today': day.is_today }]"
>
<text class="wc-week">{{ weekdayLabel(day.weekday) }}</text>
<text class="wc-date">{{ shortDate(day.date) }}</text>
<text class="wc-shift">{{ day.shift_name || '休息' }}</text>
<text class="wc-status">{{ day.day_status_text }}</text>
</view>
</view>
</view>
<!-- ==================== 考勤卡(本月 / 本季 / 本年) ==================== -->
<view class="stat-card">
<view class="block-head">
<text class="block-title">考勤卡</text>
<view class="period-switch">
<text
v-for="item in STATS_PERIODS"
:key="item.key"
class="ps-item"
:class="{ 'is-active': statsPeriod === item.key }"
@tap="loadStats(item.key)"
>
{{ item.label }}
</text>
</view>
</view>
<text class="stats-range">{{ statsLabel }}</text>
<view class="stat-grid" :class="{ 'is-loading': statsLoading }">
<view v-for="item in statItems" :key="item.key" class="stat-item">
<text class="stat-num" :class="item.cls">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
</view>
</view>
<!-- ==================== 考勤记录 ==================== -->
<view class="list-card">
<view class="block-head">
<text class="block-title">考勤记录</text>
<text class="block-sub">共 {{ recordTotal }} 条</text>
</view>
<view v-if="loading && !records.length" class="state-box">
<text class="state-text">加载中...</text>
</view>
<u-empty
v-else-if="!records.length"
mode="list"
text="本月还没有考勤记录,先打个卡吧"
:icon-size="80"
/>
<view v-else class="record-list">
<view v-for="item in records" :key="item.id" class="record-row">
<view class="record-date">
<text class="rd-day">{{ shortDate(item.attend_date) }}</text>
<text class="rd-week">{{ weekdayLabel(item.weekday) }}</text>
</view>
<view class="record-main">
<view class="rm-line">
<text class="rm-shift">{{ item.shift_name || '未匹配排班' }}</text>
<text class="rm-shift-time">{{ item.shift_text }}</text>
</view>
<view class="rm-line">
<text class="rm-punch">签到 {{ item.clock_in_hm || '--:--' }}</text>
<text class="rm-punch">签退 {{ item.clock_out_hm || '--:--' }}</text>
</view>
<text v-if="recordNote(item)" class="rm-note">{{ recordNote(item) }}</text>
</view>
<text class="record-status" :class="dayClass(item.day_status)">{{ item.day_status_text }}</text>
</view>
<view class="load-more">
<text v-if="hasMore" class="load-link" @tap="loadMore">加载更多</text>
<text v-else class="load-done">没有更多了</text>
</view>
</view>
</view>
<view class="bottom-space" />
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onHide, onReachBottom, onShow, onUnload } from '@dcloudio/uni-app'
import { isLoggedIn } from '@/utils/auth.js'
import {
clockAttendance,
fetchAttendanceStats,
fetchAttendanceToday,
fetchMyAttendanceRecords
} from '@/api/oaAttendance.js'
const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
const WEEKDAY_FULL = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const STATS_PERIODS = [
{ key: 'month', label: '本月' },
{ key: 'quarter', label: '本季' },
{ key: 'year', label: '本年' }
]
const today = ref({})
const clockText = ref('')
const records = ref([])
const recordPage = ref(1)
const recordTotal = ref(0)
const loading = ref(false)
const punching = ref(false)
const statsData = ref({})
const statsPeriod = ref('month')
const statsLabel = ref('')
const statsLoading = ref(false)
let timer = null
const employee = computed(() => today.value.employee || {})
const record = computed(() => today.value.record || {})
const matched = computed(() => today.value.matched === true)
const warning = computed(() => today.value.warning || '')
const ready = computed(() => Boolean(today.value.date))
const canClockIn = computed(() => ready.value && today.value.can_clock_in !== false)
const canClockOut = computed(() => ready.value && today.value.can_clock_out !== false)
const hasMore = computed(() => records.value.length < recordTotal.value)
const dateText = computed(() => today.value.date || '')
const weekdayText = computed(() => {
if (!dateText.value) return ''
const date = new Date(`${dateText.value}T00:00:00`)
if (Number.isNaN(date.getTime())) return ''
return WEEKDAY_FULL[date.getDay()]
})
const employeeText = computed(() => {
const parts = [employee.value.name, employee.value.org_name].filter(Boolean)
if (!employee.value.bound) {
parts.push('未绑定员工档案')
}
return parts.join(' · ') || '考勤打卡'
})
const shiftName = computed(() => {
const entry = today.value.entry
if (entry && entry.shift_name) return entry.shift_name
if (record.value.shift_name) return record.value.shift_name
return '今日未排班'
})
const shiftTimeText = computed(() => {
const entry = today.value.entry
if (entry && entry.shift_text) return entry.shift_text
if (record.value.shift_text) return record.value.shift_text
return '无排班时间,打卡将标记为「无排班」'
})
const shortName = computed(() => {
const entry = today.value.entry
if (entry && entry.short_name) return entry.short_name
return '班'
})
const shiftColor = computed(() => {
const entry = today.value.entry
return (entry && entry.color) || record.value.color || '#409EFF'
})
const statItems = computed(() => {
const stats = statsData.value || {}
return [
{ key: 'scheduled', label: '应出勤', value: stats.scheduled || 0, cls: '' },
{ key: 'clocked', label: '已打卡', value: stats.clocked || 0, cls: 'is-ok' },
{ key: 'late', label: '迟到', value: stats.late || 0, cls: 'is-warn' },
{ key: 'early', label: '早退', value: stats.early || 0, cls: 'is-warn' },
{ key: 'noschedule', label: '无排班', value: stats.noschedule || 0, cls: 'is-info' },
{ key: 'missing', label: '缺卡', value: stats.missing || 0, cls: 'is-danger' }
]
})
// 近 7 天:后端按「最近在前」返回,这里翻成从左到右的时间轴
const recentDays = computed(() => {
const list = Array.isArray(today.value.recent) ? today.value.recent : []
return list.slice().reverse()
})
function pad2(value) {
return String(value).padStart(2, '0')
}
function tick() {
const now = new Date()
clockText.value = `${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`
}
function startTimer() {
tick()
if (timer) clearInterval(timer)
timer = setInterval(tick, 1000)
}
function stopTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function weekdayLabel(value) {
return WEEKDAY_LABELS[Number(value)] || ''
}
function shortDate(date) {
return String(date || '').slice(5)
}
function statusClass(status) {
switch (Number(status)) {
case 1:
return 'is-ok'
case 2:
return 'is-warn'
case 3:
return 'is-muted'
case 4:
return 'is-danger'
}
return 'is-muted'
}
function dayClass(status) {
switch (Number(status)) {
case 1:
return 'is-ok'
case 2:
case 3:
case 7:
return 'is-warn'
case 4:
case 6:
return 'is-danger'
case 5:
return 'is-info'
}
return 'is-muted'
}
function recordNote(item) {
return item.clock_out_note || item.clock_in_note || ''
}
function confirmDialog(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '继续',
cancelText: '取消',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false)
})
})
}
async function loadToday() {
try {
const data = await fetchAttendanceToday(7)
if (data) {
today.value = data
statsData.value = data.stats || {}
statsPeriod.value = 'month'
statsLabel.value = monthLabel()
}
} catch (error) {
uni.showToast({ title: (error && error.message) || '考勤状态加载失败', icon: 'none' })
}
}
/** 月度卡标题(today 接口只返回区间数字,标题在前端拼) */
function monthLabel() {
const now = new Date()
return `${now.getFullYear()} 年 ${now.getMonth() + 1} 月`
}
/** 切换考勤卡维度:本月 / 本季 / 本年(后端返回统一口径的统计) */
async function loadStats(period) {
if (statsLoading.value || (statsPeriod.value === period && statsLabel.value)) {
return
}
statsLoading.value = true
try {
const data = await fetchAttendanceStats(period, today.value.date || '')
if (data) {
statsPeriod.value = data.period || period
statsLabel.value = data.label || ''
statsData.value = data.stats || {}
}
} catch (error) {
uni.showToast({ title: (error && error.message) || '考勤统计加载失败', icon: 'none' })
} finally {
statsLoading.value = false
}
}
async function loadRecords(reset = false) {
if (reset) {
recordPage.value = 1
loading.value = true
}
try {
const data = await fetchMyAttendanceRecords({ page: recordPage.value, pageSize: 20 })
const rows = (data && data.list) || []
records.value = reset ? rows : records.value.concat(rows)
recordTotal.value = (data && data.total) || 0
} catch (error) {
if (reset) uni.showToast({ title: (error && error.message) || '考勤记录加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (!hasMore.value || loading.value) return
recordPage.value += 1
loadRecords(false)
}
async function punch(type) {
if (punching.value) return
if (!ready.value) {
uni.showToast({ title: '考勤状态加载中,请稍候', icon: 'none' })
return
}
if (type === 'in' && !canClockIn.value) {
uni.showToast({ title: '今日已签到,无需重复打卡', icon: 'none' })
return
}
if (type === 'out' && !canClockOut.value) {
uni.showToast({ title: '今日已签退,无需重复打卡', icon: 'none' })
return
}
// 排班匹配不上:先提醒,确认后仍可打卡
if (warning.value) {
const ok = await confirmDialog('排班提醒', `${warning.value}\n\n是否继续${type === 'in' ? '签到' : '签退'}?`)
if (!ok) return
}
if (type === 'out' && !record.value.clock_in_hm) {
const ok = await confirmDialog('未签到提醒', '今日尚未签到,是否直接签退?签退记录会标注「未签到」。')
if (!ok) return
}
punching.value = true
try {
const data = await clockAttendance(type)
uni.showToast({ title: (data && data.message) || '打卡成功', icon: 'success' })
if (data && data.warning) {
setTimeout(() => {
uni.showModal({ title: '考勤提醒', content: data.warning, showCancel: false, confirmText: '知道了' })
}, 600)
}
await Promise.all([loadToday(), loadRecords(true)])
} catch (error) {
uni.showToast({ title: (error && error.message) || '打卡失败', icon: 'none' })
} finally {
punching.value = false
}
}
onShow(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
startTimer()
loadToday()
loadRecords(true)
})
onHide(() => {
stopTimer()
})
onUnload(() => {
stopTimer()
})
onReachBottom(() => {
if (hasMore.value && !loading.value) {
loadMore()
}
})
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
}
.body {
padding: 28rpx $page-padding-x 0;
}
/* ==================== 今日打卡 ==================== */
.punch-card {
@include card;
padding: 28rpx 24rpx 24rpx;
}
.punch-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.head-date {
display: block;
font-size: 34rpx;
font-weight: 600;
color: $color-text;
}
.head-sub {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: $color-text-muted;
word-break: break-all;
}
.head-clock {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
.clock-text {
font-size: 44rpx;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $color-primary;
line-height: 1.1;
}
.clock-label {
margin-top: 4rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.shift-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 24rpx;
padding: 18rpx 20rpx;
background: #f8fafc;
border-radius: $radius-md;
}
.shift-badge {
width: 60rpx;
height: 60rpx;
border-radius: 18rpx;
color: #ffffff;
font-size: 24rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.shift-body {
flex: 1;
min-width: 0;
}
.shift-name {
display: block;
font-size: 28rpx;
font-weight: 500;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shift-time {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
color: $color-text-muted;
}
.match-chip {
@include chip($color-primary-bg, $color-primary);
flex-shrink: 0;
&.is-ok {
background: #ecfdf5;
color: $color-success;
}
&.is-warn {
background: #fffbeb;
color: #b45309;
}
}
.warn-bar {
display: flex;
align-items: flex-start;
gap: 10rpx;
margin-top: 18rpx;
padding: 16rpx 18rpx;
background: #fffbeb;
border: 1rpx solid #fde68a;
border-radius: $radius-md;
}
.warn-text {
flex: 1;
font-size: 24rpx;
line-height: 1.5;
color: #b45309;
}
.punch-times {
display: flex;
align-items: center;
margin-top: 24rpx;
}
.time-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.time-divider {
width: 1rpx;
height: 56rpx;
background: $color-divider;
}
.time-label {
font-size: 22rpx;
color: $color-text-muted;
}
.time-value {
font-size: 36rpx;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: $color-text;
}
.time-status {
font-size: 22rpx;
&.is-ok { color: $color-success; }
&.is-warn { color: $color-warning; }
&.is-danger { color: $color-danger; }
&.is-muted { color: $color-text-muted; }
}
.punch-actions {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.punch-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
padding: 22rpx 0;
border-radius: $radius-md;
color: #ffffff;
transition: transform 0.15s ease;
&:active {
transform: scale(0.98);
}
&.is-in {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
&.is-out {
background: linear-gradient(135deg, #34d399, #10b981);
}
&.is-disabled {
background: #e2e8f0;
color: $color-text-muted;
}
&.is-busy {
opacity: 0.8;
}
}
.btn-title {
font-size: 32rpx;
font-weight: 600;
}
.btn-sub {
font-size: 22rpx;
opacity: 0.9;
}
/* ==================== 近 7 天 ==================== */
.week-card,
.stat-card,
.list-card {
@include card;
margin-top: 24rpx;
padding: 24rpx 22rpx;
}
.week-row {
display: flex;
gap: 8rpx;
}
.week-cell {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
padding: 12rpx 4rpx;
border-radius: $radius-sm;
background: #f8fafc;
&.is-today {
background: $color-primary-bg;
}
&.is-danger {
background: #fef2f2;
}
&.is-warn {
background: #fffbeb;
}
}
.wc-week {
font-size: 20rpx;
color: $color-text-muted;
}
.wc-date {
font-size: 22rpx;
font-weight: 500;
color: $color-text;
font-variant-numeric: tabular-nums;
}
.wc-shift {
max-width: 100%;
font-size: 20rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wc-status {
font-size: 20rpx;
font-weight: 500;
.is-ok & { color: $color-success; }
.is-warn & { color: $color-warning; }
.is-danger & { color: $color-danger; }
.is-info & { color: $color-primary; }
.is-muted & { color: $color-text-muted; }
}
.block-head {
@include block-head;
}
.block-title {
@include section-title;
}
.block-sub {
font-size: 22rpx;
color: $color-text-muted;
}
.period-switch {
display: flex;
align-items: center;
gap: 4rpx;
padding: 4rpx;
background: #f1f5f9;
border-radius: $radius-full;
}
.ps-item {
padding: 6rpx 18rpx;
border-radius: $radius-full;
font-size: 22rpx;
color: $color-text-muted;
&.is-active {
background: $color-card;
color: $color-primary;
font-weight: 500;
box-shadow: 0 2rpx 6rpx rgba(15, 23, 42, 0.08);
}
}
.stats-range {
display: block;
margin: -8rpx 0 18rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.stat-grid {
&.is-loading {
opacity: 0.5;
}
}
.stat-grid {
display: flex;
flex-wrap: wrap;
margin-right: -16rpx;
}
.stat-item {
width: 33.33%;
box-sizing: border-box;
padding-right: 16rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
&:nth-last-child(-n + 3) {
margin-bottom: 0;
}
}
.stat-num {
font-size: 36rpx;
font-weight: 600;
color: $color-text;
&.is-ok { color: $color-success; }
&.is-warn { color: $color-warning; }
&.is-danger { color: $color-danger; }
&.is-info { color: $color-primary; }
}
.stat-label {
font-size: 22rpx;
color: $color-text-muted;
}
/* ==================== 考勤记录 ==================== */
.state-box {
padding: 60rpx 0;
display: flex;
justify-content: center;
}
.state-text {
font-size: 26rpx;
color: $color-text-muted;
}
.record-list {
display: flex;
flex-direction: column;
}
.record-row {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 22rpx 0;
& + & {
border-top: 1rpx solid $color-divider;
}
}
.record-date {
width: 92rpx;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4rpx;
}
.rd-day {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
font-variant-numeric: tabular-nums;
}
.rd-week {
font-size: 22rpx;
color: $color-text-muted;
}
.record-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.rm-line {
display: flex;
align-items: center;
gap: 14rpx;
min-width: 0;
}
.rm-shift {
font-size: 26rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rm-shift-time {
font-size: 22rpx;
color: $color-text-muted;
flex-shrink: 0;
}
.rm-punch {
font-size: 24rpx;
color: $color-text-secondary;
font-variant-numeric: tabular-nums;
}
.rm-note {
font-size: 22rpx;
line-height: 1.5;
color: #b45309;
word-break: break-all;
}
.record-status {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 500;
&.is-ok { color: $color-success; }
&.is-warn { color: $color-warning; }
&.is-danger { color: $color-danger; }
&.is-info { color: $color-primary; }
&.is-muted { color: $color-text-muted; }
}
.load-more {
padding: 24rpx 0 4rpx;
display: flex;
justify-content: center;
}
.load-link {
font-size: 26rpx;
color: $color-primary;
}
.load-done {
font-size: 24rpx;
color: $color-text-muted;
}
.bottom-space {
height: 40rpx;
}
</style>