增加ai办公,考勤管理

This commit is contained in:
hero920103 committed 2026-11-14 18:15:55 +08:00
1 parent eb9b7beff5
commit 31ae07175d
46 files changed
+9250 -296

No files matched your search

+969
View File
@@ -0,0 +1,969 @@
<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>