Browse Source

feat: 更新国际化语言文件,新增考勤记录相关文本和状态信息,优化用户在考勤记录页面的体验。新增考勤记录组件,支持月份筛选和详细记录展示,提升用户交互体验。

master
lizhuang 2 days ago
parent
commit
eff68ded11

+ 44
- 0
src/i18n/lang/en.js View File

@@ -183,6 +183,8 @@ export default {
checkedIn: "Check Out",
checkedOut: "Checked Out",
confirmCheck: "Confirm Check",
checkedInText: "Check In",
recordText: "Record",
},
punch: {
status: {
@@ -212,6 +214,48 @@ export default {
pcMessage: "Please use the mobile phone to check in",
message: "Message",
confirm: "Confirm",
initFailed: "Initialization failed",
loadDataFailed: "Failed to load attendance data",
},
record: {
title: "My Attendance",
monthFilter: {
current: "Current Month",
previous: "Previous Month",
},
statistics: {
totalDays: "Check-in Days",
lateDays: "Late Times",
earlyDays: "Early Leave Times",
normalDays: "Normal Days",
abnormalDays: "Abnormal Times",
},
status: {
notChecked: "Not Checked",
normal: "Normal",
late: "Late",
early: "Early Leave",
update: "Updated",
},
time: {
morning: "Check In",
evening: "Check Out",
},
remark: {
label: "Remark: ",
},
empty: "No attendance records",
loading: "Loading...",
today: "Today",
weekdays: {
sunday: "Sun",
monday: "Mon",
tuesday: "Tue",
wednesday: "Wed",
thursday: "Thu",
friday: "Fri",
saturday: "Sat",
},
},
},


+ 44
- 0
src/i18n/lang/ja.js View File

@@ -184,6 +184,8 @@ export default {
checkedIn: "退勤打刻",
checkedOut: "打刻済み",
confirmCheck: "打刻確認",
checkedInText: "打刻",
recordText: "統計",
},
punch: {
status: {
@@ -213,6 +215,48 @@ export default {
pcMessage: "携帯電話で考勤打刻を行ってください",
message: "メッセージ",
confirm: "確認",
initFailed: "初期化に失敗しました",
loadDataFailed: "出勤データの読み込みに失敗しました",
},
record: {
title: "私の出勤記録",
monthFilter: {
current: "今月",
previous: "先月",
},
statistics: {
totalDays: "打刻日数",
lateDays: "遅刻回数",
earlyDays: "早退回数",
normalDays: "正常日数",
abnormalDays: "異常回数",
},
status: {
notChecked: "未打刻",
normal: "正常",
late: "遅刻",
early: "早退",
update: "更新",
},
time: {
morning: "出勤",
evening: "退勤",
},
remark: {
label: "備考:",
},
empty: "出勤記録がありません",
loading: "読み込み中...",
today: "今日",
weekdays: {
sunday: "日曜",
monday: "月曜",
tuesday: "火曜",
wednesday: "水曜",
thursday: "木曜",
friday: "金曜",
saturday: "土曜",
},
},
},


+ 44
- 0
src/i18n/lang/zh.js View File

@@ -183,6 +183,8 @@ export default {
checkedIn: "下班打卡",
checkedOut: "已打卡",
confirmCheck: "确认打卡",
checkedInText: "打卡",
recordText: "统计",
},
punch: {
status: {
@@ -212,6 +214,48 @@ export default {
pcMessage: "请使用手机端,进行考勤打卡",
message: "提示",
confirm: "确定",
initFailed: "初始化失败",
loadDataFailed: "加载考勤数据失败",
},
record: {
title: "我的考勤",
monthFilter: {
current: "当前月",
previous: "上个月",
},
statistics: {
totalDays: "打卡天数",
lateDays: "迟到次数",
earlyDays: "早退次数",
normalDays: "正常天数",
abnormalDays: "异常次数",
},
status: {
notChecked: "未打卡",
normal: "正常",
late: "迟到",
early: "早退",
update: "更新",
},
time: {
morning: "上班",
evening: "下班",
},
remark: {
label: "备注:",
},
empty: "暂无考勤记录",
loading: "加载中...",
today: "今天",
weekdays: {
sunday: "周日",
monday: "周一",
tuesday: "周二",
wednesday: "周三",
thursday: "周四",
friday: "周五",
saturday: "周六",
},
},
},


+ 6
- 0
src/router/index.js View File

@@ -94,6 +94,12 @@ export const constantRoutes = [
component: () => import("@/views/oa/attendance/checkin"),
hidden: true,
meta: { title: "menu.checkin", icon: "date" },
},
{
path: "/m/checkin/record",
component: () => import("@/views/oa/attendance/checkin/record"),
hidden: true,
meta: { title: "menu.checkin", icon: "date" },
}
];


+ 261
- 2
src/views/index.vue View File

@@ -32,6 +32,26 @@
</div>
</header>
<section>
<div v-if="noticeList.length > 0" class="notice" v-show="false">
<div class="notice-title">
<span>公告</span>
</div>
<div class="notice-content">
<div
v-if="noticeList.length > 0"
class="notice-content-item"
@click="showNoticeDetail(noticeList[currentNoticeIndex])"
>
<div class="notice-title-text">
{{ noticeList[currentNoticeIndex].noticeTitle }}
</div>
<div class="notice-time">
{{ formatTime(noticeList[currentNoticeIndex].createTime) }}
</div>
</div>
<div v-else class="notice-content-item no-notice">暂无公告</div>
</div>
</div>
<div class="apps">
<router-link to="/m/checkin" class="app-card">
<svg-icon icon-class="icon-one" style="font-size: 24px" />
@@ -40,18 +60,51 @@
</router-link>
</div>
</section>

<!-- 通知详情弹窗 -->
<el-dialog
title="公告详情"
:visible.sync="noticeDetailVisible"
width="960px"
:fullscreen="!$_isMobile() ? false : true"
:before-close="closeNoticeDetail"
>
<div v-if="selectedNotice" class="notice-detail">
<div class="notice-detail-title">
{{ selectedNotice.noticeTitle }}
</div>
<div class="notice-detail-meta">
<span>发布时间:{{ formatTime(selectedNotice.createTime) }}</span>
</div>
<div
class="notice-detail-content"
v-html="selectedNotice.noticeContent"
/>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="closeNoticeDetail">关闭</el-button>
</span>
</el-dialog>
</div>
</template>

<script>
import { mapGetters } from 'vuex'
import messageGenerator from '@/utils/warmMessageGenerator'
import { listNotice } from '@/api/system/notice'
import moment from 'moment'
import ResizeMixin from '@/layout/mixin/ResizeHandler'
export default {
name: 'Index',
mixins: [ResizeMixin],
data() {
return {
warmMessage: ''
warmMessage: '',
noticeList: [],
currentNoticeIndex: 0,
noticeDetailVisible: false,
selectedNotice: null,
autoScrollTimer: null
}
},
computed: {
@@ -91,10 +144,77 @@ export default {
immediate: true
}
},
created() {},
created() {
this.getNoticeList()
},
beforeDestroy() {
this.clearAutoScroll()
},
methods: {
getNoticeList() {
listNotice({
pageNum: 1,
pageSize: 20,
status: '0'
}).then((response) => {
this.noticeList = response.rows.filter((item) => item.status === '0')
// 数据加载完成后启动自动滚动
this.$nextTick(() => {
this.startAutoScroll()
})
})
},
goTarget(href) {
window.open(href, '_blank')
},
/**
* 开始自动滚动通知
* 每3秒切换到下一条通知
*/
startAutoScroll() {
if (this.noticeList.length <= 1) return

this.autoScrollTimer = setInterval(() => {
this.currentNoticeIndex =
(this.currentNoticeIndex + 1) % this.noticeList.length
}, 3000)
},
/**
* 清除自动滚动定时器
*/
clearAutoScroll() {
if (this.autoScrollTimer) {
clearInterval(this.autoScrollTimer)
this.autoScrollTimer = null
}
},
/**
* 显示通知详情
* @param {Object} notice 通知对象
*/
showNoticeDetail(notice) {
this.selectedNotice = notice
this.noticeDetailVisible = true
// 暂停自动滚动
this.clearAutoScroll()
},
/**
* 关闭通知详情弹窗
*/
closeNoticeDetail() {
this.noticeDetailVisible = false
this.selectedNotice = null
// 重新开始自动滚动
this.startAutoScroll()
},
/**
* 格式化时间显示
* @param {String} time 时间字符串
* @returns {String} 格式化后的时间
*/
formatTime(time) {
if (!time) return ''
return moment(time).format('YYYY-MM-DD HH:mm')
}
}
}
@@ -192,4 +312,143 @@ export default {
}
}
}

/* 通知相关样式 */
.notice {
background: #fff;
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
border: 1px solid #e0e0e0;
width: 50%;
@media (max-width: 768px) {
width: 100%;
}

.notice-title {
background: linear-gradient(135deg, #409eff, #67c23a);
color: #fff;
padding: 8px 16px;
font-weight: 600;
font-size: 14px;
}

.notice-content {
height: 80px;
position: relative;
overflow: hidden;

.notice-content-item {
height: 80px;
padding: 16px;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
transition: all 0.3s ease;
animation: slideInUp 0.5s ease-out;

&:hover {
background-color: #f5f7fa;
transform: translateY(-2px);
}

.notice-title-text {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.notice-time {
font-size: 12px;
color: #909399;
}

&.no-notice {
color: #909399;
text-align: center;
cursor: default;

&:hover {
background-color: transparent;
transform: none;
}
}
}
}
}

/* 通知详情弹窗样式 */
.notice-detail {
.notice-detail-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e4e7ed;
}

.notice-detail-meta {
margin-bottom: 16px;
color: #909399;
font-size: 14px;
}

.notice-detail-content {
font-size: 14px;
line-height: 1.6;
color: #606266;
min-height: 100px;
white-space: pre-wrap;
word-wrap: break-word;
}
}

/* 滑入动画 */
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

/* 弹窗样式覆盖 */
::v-deep .el-dialog {
.el-dialog__header {
background: linear-gradient(135deg, #409eff, #67c23a);
color: #fff;
padding: 20px;

.el-dialog__title {
color: #fff;
font-weight: 600;
}

.el-dialog__close {
color: #fff;

&:hover {
color: #f0f0f0;
}
}
}

.el-dialog__body {
padding: 20px;
}

.el-dialog__footer {
padding: 10px 20px 20px;
text-align: right;
}
}
</style>

+ 66
- 17
src/views/oa/attendance/checkin/index.vue View File

@@ -42,7 +42,8 @@
{{ formatTime(attendanceStatus.clockInTime) }}
</div>
<div class="status-label">
<span>{{ $t('checkin.workStartTime') }} {{ attendanceGroup.workStartTime }}</span>
<span>{{ $t('checkin.workStartTime') }}
{{ attendanceGroup.workStartTime }}</span>
</div>
</div>
<div class="status-card">
@@ -52,7 +53,8 @@
{{ formatTime(attendanceStatus.clockOutTime) }}
</div>
<div class="status-label">
<span>{{ $t('checkin.workEndTime') }} {{ attendanceGroup.workEndTime }}</span>
<span>{{ $t('checkin.workEndTime') }}
{{ attendanceGroup.workEndTime }}</span>
</div>
</div>
</div>
@@ -112,10 +114,19 @@

<!-- 加载状态 -->
<div v-else class="container loading">
<div class="loading-text">
<!-- 正在加载考勤信息... -->
<i class="el-icon-loading" />
</div>
<i class="el-icon-loading" />
<span>{{ $t('checkin.record.loading') }}</span>
</div>

<div class="footer">
<router-link to="/m/checkin" class="footer-item active">
<i class="el-icon-location-information" style="font-size: 20px" />
<span>{{$t('checkin.button.checkedInText')}}</span>
</router-link>
<router-link to="/m/checkin/record" class="footer-item">
<i class="el-icon-pie-chart" />
<span>{{$t('checkin.button.recordText')}}</span>
</router-link>
</div>
</div>
</template>
@@ -247,17 +258,21 @@ export default {
}
// 判断如果是PC端
if (!this.$_isMobile()) {
this.$alert(this.$t('checkin.error.pcMessage'), this.$t('checkin.error.message'), {
confirmButtonText: this.$t('checkin.error.confirm'),
showCancelButton: false,
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false,
center: true,
callback: () => {
this.$router.push('/')
this.$alert(
this.$t('checkin.error.pcMessage'),
this.$t('checkin.error.message'),
{
confirmButtonText: this.$t('checkin.error.confirm'),
showCancelButton: false,
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false,
center: true,
callback: () => {
this.$router.push('/')
}
}
})
)
return
} else {
await this.initializeApp()
@@ -651,8 +666,20 @@ export default {

&.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #666;

i {
font-size: 24px;
margin-bottom: 10px;
}

span {
font-size: 14px;
}
}
}

@@ -681,6 +708,9 @@ export default {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.footer--position {
height: 60px;
}
.footer {
position: fixed;
bottom: 0;
@@ -689,9 +719,28 @@ export default {
padding: 10px;
text-align: center;
background: white;
z-index: 10;
display: flex;
align-items: center;
justify-content: flex-end;
border-top: 1px solid #e5e5e5;
.footer-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-direction: column;
&.active {
color: #2196f3;
}
i {
font-size: 18px;
margin-bottom: 5px;
}
span {
font-size: 12px;
}
}
}

.user-info {

+ 877
- 0
src/views/oa/attendance/checkin/record.vue View File

@@ -0,0 +1,877 @@
<template>
<div class="page">
<div class="container">
<!-- 用户信息头部 -->
<div class="header">
<div class="user-info">
<div class="user-left">
<div class="avatar">
<el-avatar :src="userinfo.avatar">{{
userinfo.nickName
}}</el-avatar>
</div>
<div class="user-name">{{ userinfo.nickName }}</div>
</div>
<div class="user-right">
<div class="user-department">
{{ userinfo.dept.deptName }}
<em v-if="userinfo.language">({{ userinfo.language }})</em>
</div>
</div>
</div>
</div>

<!-- 月份筛选 -->
<div class="filter-section">
<div class="month-tabs">
<div
v-for="month in monthOptions"
:key="month.value"
class="month-tab"
:class="{ active: selectedMonth === month.value }"
@click="handleMonthChange(month.value)"
>
{{ $t(month.labelKey) }}
</div>
</div>
</div>

<!-- 统计信息 -->
<div class="statistics-section">
<div class="statistics-cards">
<div class="statistics-card">
<div class="statistics-number">{{ statisticsData.totalDays }}</div>
<div class="statistics-label">
{{ $t('checkin.record.statistics.totalDays') }}
</div>
</div>
<div class="statistics-card">
<div class="statistics-number late">
{{ statisticsData.lateDays }}
</div>
<div class="statistics-label">
{{ $t('checkin.record.statistics.lateDays') }}
</div>
</div>
<div class="statistics-card">
<div class="statistics-number early">
{{ statisticsData.earlyDays }}
</div>
<div class="statistics-label">
{{ $t('checkin.record.statistics.earlyDays') }}
</div>
</div>
<div class="statistics-card">
<div class="statistics-number normal">
{{ statisticsData.normalDays }}
</div>
<div class="statistics-label">
{{ $t('checkin.record.statistics.normalDays') }}
</div>
</div>
</div>
</div>

<!-- 考勤记录列表 -->
<div class="record-section">
<div v-if="loading" class="loading-wrapper">
<i class="el-icon-loading" />
<span>{{ $t('checkin.record.loading') }}</span>
</div>

<div v-else-if="attendanceList.length === 0" class="empty-wrapper">
<div class="empty-text">{{ $t('checkin.record.empty') }}</div>
</div>

<div v-else class="record-list">
<div
v-for="record in attendanceList"
:key="record.id"
class="record-item"
:class="{
'is-today': isToday(record.checkInTime),
'is-weekend': isWeekend(record.checkInTime)
}"
>
<div class="record-date">
<div class="date-day">
{{ formatDateDay(record.checkInTime) }}
</div>
<div class="date-month">
{{ formatDateMonth(record.checkInTime) }}
</div>
<div
class="date-weekday"
:class="{
'is-today': isToday(record.checkInTime),
'is-weekend': isWeekend(record.checkInTime)
}"
>
{{ formatWeekday(record.checkInTime) }}
</div>
<!-- 今天标识 -->
<div v-if="isToday(record.checkInTime)" class="today-badge">
{{ $t('checkin.record.today') }}
</div>
</div>

<div class="record-content">
<div class="record-times">
<!-- 上班打卡 -->
<div class="time-item">
<div class="time-label">
{{ $t('checkin.record.time.morning') }}
</div>
<div class="time-value">
<span
v-if="record.clockInStatus == '0'"
class="status-text status-none"
>
{{ $t('checkin.record.status.notChecked') }}
</span>
<span
v-else-if="record.clockInStatus == '1'"
class="status-text status-normal"
>
{{ $t('checkin.record.status.normal') }}
{{ formatTime(record.clockIn) }}
</span>
<span
v-else-if="record.clockInStatus == '3'"
class="status-text status-late"
>
{{ $t('checkin.record.status.late') }}
{{ formatTime(record.clockIn) }}
</span>
</div>
</div>

<!-- 下班打卡 -->
<div class="time-item">
<div class="time-label">
{{ $t('checkin.record.time.evening') }}
</div>
<div class="time-value">
<span
v-if="record.clockOutStatus == '0'"
class="status-text status-none"
>
{{ $t('checkin.record.status.notChecked') }}
</span>
<span
v-else-if="record.clockOutStatus == '2'"
class="status-text status-normal"
>
{{ $t('checkin.record.status.normal') }}
{{ formatTime(record.clockOut) }}
</span>
<span
v-else-if="record.clockOutStatus == '4'"
class="status-text status-early"
>
{{ $t('checkin.record.status.early') }}
{{ formatTime(record.clockOut) }}
</span>
<span
v-else-if="record.clockOutStatus == '5'"
class="status-text status-update"
>
{{ $t('checkin.record.status.update') }}
{{ formatTime(record.clockOut) }}
</span>
</div>
</div>
</div>

<!-- 备注 -->
<div v-if="record.description" class="record-remark">
<span class="remark-label">{{
$t('checkin.record.remark.label')
}}</span>
<span class="remark-text">{{ record.description }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer--position"></div>
<div class="footer">
<router-link to="/m/checkin" class="footer-item">
<i class="el-icon-location-information" style="font-size: 20px;" />
<span>{{$t('checkin.button.checkedInText')}}</span>
</router-link>
<router-link
to="/m/checkin/record"
class="footer-item active"
>
<i class="el-icon-pie-chart" />
<span>{{$t('checkin.button.recordText')}}</span>
</router-link>
</div>
</div>
</template>

<script>
import { mapGetters } from 'vuex'
import { queryList } from '@/api/oa/attendance/history'
import { formatTime, formatDate } from '@/utils/filters'
import moment from 'moment'

export default {
name: 'CheckinRecord',
data() {
return {
// 加载状态
loading: true,
// 选中的月份
selectedMonth: 'current',
// 考勤记录列表
attendanceList: [],
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 100,
sysUserName: undefined,
strDay: undefined,
flag: 'all'
}
}
},

computed: {
...mapGetters(['userinfo']),

/**
* 月份选项
* @returns {Array} 月份选项数组
*/
monthOptions() {
return [
{ labelKey: 'checkin.record.monthFilter.current', value: 'current' },
{ labelKey: 'checkin.record.monthFilter.previous', value: 'previous' }
]
},

/**
* 统计数据计算
* @returns {Object} 统计数据对象
*/
statisticsData() {
return this.calculateStatistics(this.attendanceList)
},

/**
* 获取查询日期范围
* @returns {Object} 开始和结束日期
*/
dateRange() {
if (this.selectedMonth === 'current') {
const now = moment()
return {
start: now.clone().startOf('month').format('YYYY-MM-DD'),
end: now.clone().endOf('month').format('YYYY-MM-DD')
}
} else {
const lastMonth = moment().subtract(1, 'month')
return {
start: lastMonth.clone().startOf('month').format('YYYY-MM-DD'),
end: lastMonth.clone().endOf('month').format('YYYY-MM-DD')
}
}
}
},

async mounted() {
try {
await this.loadAttendanceData()
} catch (error) {
this.handleError(this.$t('checkin.error.initFailed'), error)
}
},

methods: {
/**
* 处理月份切换
* @param {string} month 月份值
*/
async handleMonthChange(month) {
if (this.selectedMonth === month) return

this.selectedMonth = month
await this.loadAttendanceData()
},

/**
* 加载考勤数据
*/
async loadAttendanceData() {
try {
this.loading = true
this.queryParams.sysUserName = this.userinfo.userName

// 根据选中月份设置查询日期
const { start, end } = this.dateRange
// this.queryParams.strDay = start
// this.queryParams.endDay = end

const response = await queryList(this.queryParams)

// 过滤指定月份的数据
this.attendanceList = response.rows.filter((record) => {
if (!record.checkInTime) return false
const recordDate = moment(record.checkInTime)
return recordDate.isBetween(start, end, 'day', '[]')
})
} catch (error) {
this.handleError(this.$t('checkin.error.loadDataFailed'), error)
this.attendanceList = []
} finally {
this.loading = false
}
},

/**
* 格式化日期 - 日
* @param {string} dateStr 日期字符串
* @returns {string} 格式化后的日
*/
formatDateDay(dateStr) {
if (!dateStr) return '--'
return moment(dateStr).format('DD')
},

/**
* 格式化日期 - 月
* @param {string} dateStr 日期字符串
* @returns {string} 格式化后的月
*/
formatDateMonth(dateStr) {
if (!dateStr) return '--'
const currentLang = this.$i18n.locale
if (currentLang === 'en') {
return moment(dateStr).format('MMM')
} else if (currentLang === 'ja') {
return moment(dateStr).format('MM月')
} else {
return moment(dateStr).format('MM月')
}
},

/**
* 格式化星期
* @param {string} dateStr 日期字符串
* @returns {string} 星期
*/
formatWeekday(dateStr) {
if (!dateStr) return '--'
const dayOfWeek = moment(dateStr).day()
const weekdayKeys = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday'
]
return this.$t(`checkin.record.weekdays.${weekdayKeys[dayOfWeek]}`)
},

/**
* 判断是否为今天
* @param {string} dateStr 日期字符串
* @returns {boolean} 是否为今天
*/
isToday(dateStr) {
if (!dateStr) return false
return moment(dateStr).isSame(moment(), 'day')
},

/**
* 判断是否为周末
* @param {string} dateStr 日期字符串
* @returns {boolean} 是否为周末
*/
isWeekend(dateStr) {
if (!dateStr) return false
const dayOfWeek = moment(dateStr).day()
return dayOfWeek === 0 || dayOfWeek === 6 // 0是周日,6是周六
},

/**
* 格式化时间
* @param {string} timeStr 时间字符串
* @returns {string} 格式化后的时间
*/
formatTime(timeStr) {
return formatTime(timeStr)
},

/**
* 计算统计数据
* @param {Array} attendanceList 考勤记录列表
* @returns {Object} 统计结果
*/
calculateStatistics(attendanceList) {
const statistics = {
totalDays: 0,
lateDays: 0,
earlyDays: 0,
normalDays: 0
}

if (!attendanceList || attendanceList.length === 0) {
return statistics
}

attendanceList.forEach((record) => {
// 统计打卡天数(有上班打卡记录的天数)
if (record.clockInStatus && record.clockInStatus !== '0') {
statistics.totalDays++
}

// 统计迟到次数(上班状态为3)
if (record.clockInStatus === '3') {
statistics.lateDays++
}

// 统计早退次数(下班状态为4)
if (record.clockOutStatus === '4') {
statistics.earlyDays++
}

// 统计正常天数(上班正常且下班正常)
if (record.clockInStatus === '1' && record.clockOutStatus === '2') {
statistics.normalDays++
}
})

return statistics
},

/**
* 统一错误处理
* @param {string} message 错误消息
* @param {Error} error 错误对象
*/
handleError(message, error) {
console.error(message, error)
this.$toast.error(error.message || message)
}
}
}
</script>

<style lang="scss" scoped>
.page {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
color: #333;
@media (min-width: 480px) {
width: 480px;
margin: 0 auto;
border-radius: 10px;
}
}
.footer--position{
height: 60px;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 10px;
text-align: center;
background: white;
z-index: 10;
display: flex;
align-items: center;
border-top: 1px solid #e5e5e5;
.footer-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-direction: column;
&.active {
color: #2196f3;
}
i {
font-size: 18px;
margin-bottom: 5px;
}
span {
font-size: 12px;
}
}
}

.container {
margin: 0 auto;
background: #f5f5f5;
min-height: 100vh;
position: relative;
user-select: none;
}

// 头部用户信息
.header {
padding: 20px;
background: white;
border-radius: 0 0 20px 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.user-info {
display: flex;
align-items: center;
justify-content: space-between;
}

.user-left {
display: flex;
align-items: center;
}

.avatar {
width: 50px;
height: 50px;
background: #2196f3;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
font-weight: bold;
margin-right: 15px;

.el-avatar {
background: #2196f3;
}
}

.user-name {
font-size: 20px;
font-weight: 600;
color: #333;
}

.user-department {
font-size: 14px;
color: #666;
text-align: right;
}

// 月份筛选
.filter-section {
padding: 15px 20px;
background: white;
margin-top: 10px;
}

.month-tabs {
display: flex;
background: #f8f9fa;
border-radius: 8px;
padding: 4px;
}

.month-tab {
flex: 1;
text-align: center;
padding: 10px 0;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
color: #666;
cursor: pointer;
transition: all 0.3s ease;

&.active {
background: #2196f3;
color: white;
}
}

// 统计信息区域
.statistics-section {
padding: 10px 20px;
background: white;
margin-top: 10px;
}

.statistics-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}

.statistics-card {
background: #f8f9fa;
border-radius: 8px;
padding: 12px 8px;
text-align: center;
transition: all 0.3s ease;

&:active {
transform: scale(0.95);
}
}

.statistics-number {
font-size: 20px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
line-height: 1;

&.late {
color: #f56c6c;
}

&.early {
color: #e6a23c;
}

&.normal {
color: #67c23a;
}
}

.statistics-label {
font-size: 12px;
color: #666;
font-weight: 500;
}

// 记录列表区域
.record-section {
padding: 10px 0;
}

.loading-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #666;

i {
font-size: 24px;
margin-bottom: 10px;
}

span {
font-size: 14px;
}
}

.empty-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 20px;
}

.empty-text {
font-size: 16px;
color: #999;
}

.record-list {
padding: 0 15px;
}

.record-item {
display: flex;
background: white;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
transition: all 0.3s ease;
position: relative;

&:active {
transform: scale(0.98);
}

// 今天的记录特殊样式
&.is-today {
background: linear-gradient(135deg, #fff9e6, #ffffff);
}

// 周末的记录特殊样式
&.is-weekend {
background: linear-gradient(135deg, #f0f8ff, #ffffff);
}

// 今天且是周末的组合样式
&.is-today.is-weekend {
background: linear-gradient(135deg, #fff0f5, #ffffff);
}
}

.record-date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 60px;
margin-right: 16px;
flex-shrink: 0;
}

.date-day {
font-size: 24px;
font-weight: 600;
color: #333;
line-height: 1;
}

.date-month {
font-size: 12px;
color: #666;
margin: 2px 0;
}

.date-weekday {
font-size: 10px;
color: #999;
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.3s ease;

&.is-today {
background: #ffd700;
color: #333;
font-weight: 600;
}

&.is-weekend {
background: #87ceeb;
color: white;
font-weight: 500;
}

&.is-today.is-weekend {
background: #ff69b4;
color: white;
font-weight: 600;
}
}

.today-badge {
font-size: 8px;
color: #ff6b35;
background: linear-gradient(135deg, #fff2e6, #ffe6d9);
padding: 2px 4px;
border-radius: 6px;
margin-top: 4px;
text-align: center;
border: 1px solid #ffb088;
animation: pulse 2s infinite;
}

@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 107, 53, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(255, 107, 53, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 107, 53, 0);
}
}

.record-content {
flex: 1;
min-width: 0;
}

.record-group {
font-size: 14px;
color: #666;
margin-bottom: 8px;
font-weight: 500;
}

.record-times {
display: flex;
flex-direction: column;
gap: 6px;
}

.time-item {
display: flex;
align-items: center;
justify-content: space-between;
}

.time-label {
font-size: 13px;
color: #333;
font-weight: 500;
width: 80px;
flex-shrink: 0;
}

.time-value {
flex: 1;
text-align: right;
}

.status-text {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;

&.status-none {
color: #909399;
background: #f4f4f5;
}

&.status-normal {
color: #409eff;
background: #ecf5ff;
}

&.status-late {
color: #f56c6c;
background: #fef0f0;
}

&.status-early {
color: #e6a23c;
background: #fdf6ec;
}

&.status-update {
color: #67c23a;
background: #f0f9ff;
}
}

.record-remark {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
font-size: 12px;
}

.remark-label {
color: #666;
}

.remark-text {
color: #333;
margin-left: 4px;
}
</style>

Loading…
Cancel
Save