970 lines
21 KiB
Vue
970 lines
21 KiB
Vue
<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>
|