<template> <view :class="['app-container', theme]...
Criado em: 9 de janeiro de 2026
Respondido usando GPT-5.2 Thinking por Chat01
Criado em: 9 de janeiro de 2026
Respondido usando GPT-5.2 Thinking por Chat01
</template> <script setup> import ajax from '@/api/ajax.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import { ref, reactive, computed } from 'vue' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { onLoad } from '@dcloudio/uni-app' // theme const store = useMainStore() const { theme } = storeToRefs(store) // header heights const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) // basic user const userName = ref('用户名') const userAvatar = ref('') // 你可以从缓存/接口拿 // params const castingTeamId = ref('') //组id const consumerId = ref('') // config const loadingConfig = ref(false) const configError = ref('') const config = ref(null) // today records (回显) const today = reactive({ on: null, // { clockTime, type, address, longitude, latitude } off: null, }) // ui states const pressed = ref(false) const clocking = ref(false) const showSuccess = ref(false) const location = reactive({ latitude: null, longitude: null, address: '' }) // 补卡 const makeup = reactive({ visible: false, date: new Date().toISOString().slice(0, 10), time: String(new Date().getHours()).padStart(2, '0') + ':' + String(new Date().getMinutes()).padStart(2, '0'), remark: '', confirmConsumerId: '' }) // ---------- helpers ---------- function respData(res) { return res?.data || res || {} } function isSuccess(res) { const d = respData(res) const r = d?.result if (r === 'success' || r === true || r === 0 || r === '0') return true if (d?.code === 0 || d?.success === true) return true return false } function respMsg(res) { const d = respData(res) return d?.remark || d?.msg || d?.message || d?.error || '' } function navigateBack() { uni.navigateBack() } function openMore() { uni.navigateTo({ url: '/pages/sociAlize/Coffeegroup/application/CheckinSettings/Settings/Checksettings?castingTeamId=' + castingTeamId.value }) } function openRules() { // 可跳转规则页/弹窗展示 config if (!config.value) return uni.showModal({ title: '打卡规则', content: `上班:${startHHMM.value}\n下班:${overHHMM.value}\n范围:${config.value?.scope || '-'}m\n地址:${config.value?.address || ''}`, showCancel: false }) } // 时间显示 function formatTime(v) { if (!v) return '' const d = new Date(v) if (Number.isNaN(d.getTime())) return String(v).slice(11, 16) // 兼容后端直接给 "HH:mm" return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') } const startHHMM = computed(() => { const t = config.value?.startTime return t ? (' ' + String(t).slice(0, 5)) : ' --:--' }) const overHHMM = computed(() => { const t = config.value?.overTime return t ? (' ' + String(t).slice(0, 5)) : ' --:--' }) // 工作日判断 const isWorkday = computed(() => { if (!config.value) return true const day = new Date().getDay() // 0 Sun ... 6 Sat const map = { 1: 'monday', 2: 'tuesday', 3: 'wednesday', 4: 'thursday', 5: 'friday', 6: 'saturday', 0: 'sunday' } const key = map[day] const v = config.value?.[key] if (v === undefined || v === null) return true return Boolean(Number(v) || v === true) }) // 距离计算(米) function haversine(lat1, lon1, lat2, lon2) { const toRad = x => (x * Math.PI) / 180 const R = 6371000 const dLat = toRad(lat2 - lat1) const dLon = toRad(lon2 - lon1) const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2) const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return R * c } const distance = computed(() => { const lat = Number(location.latitude) const lon = Number(location.longitude) const lat2 = Number(config.value?.latitude) const lon2 = Number(config.value?.longitude) if (!lat || !lon || !lat2 || !lon2) return null return haversine(lat, lon, lat2, lon2) }) const inScope = computed(() => { const d = distance.value const scope = Number(config.value?.scope || 0) if (d === null || !scope) return true // 没有配置范围就默认允许 return d <= scope }) const distanceText = computed(() => { const d = distance.value if (d === null) return '' if (d < 1000) return `${Math.round(d)}m` return `${(d/1000).toFixed(2)}km` }) // 下一步动作 const nextAction = computed(() => { if (!isWorkday.value) return 'none' if (!today.on) return 'on' if (!today.off) return 'off' return 'done' }) const doneCount = computed(() => (today.on ? 1 : 0) + (today.off ? 1 : 0)) const topStatusText = computed(() => { if (!config.value) return '暂无打卡' if (nextAction.value === 'done') return '今日已完成打卡' return `已打卡 ${doneCount.value}/2` }) // 中央按钮文案 const bigLine1 = computed(() => { if (!config.value) return '加载中' if (!isWorkday.value) return '休息日' if (!inScope.value) return '外勤' return '正常' }) const bigLine2 = computed(() => { if (nextAction.value === 'on') return '上班打卡' if (nextAction.value === 'off') return '下班打卡' if (nextAction.value === 'done') return '已完成' return '—' }) const bigLine3 = computed(() => { const d = new Date() const hh = String(d.getHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0') const ss = String(d.getSeconds()).padStart(2, '0') return `${hh}:${mm}:${ss}` }) const canClock = computed(() => { if (!config.value) return false if (!isWorkday.value) return false return nextAction.value === 'on' || nextAction.value === 'off' }) const btnStateClass = computed(() => { if (!config.value) return 'btn-loading' if (!canClock.value) return 'btn-disabled' if (!inScope.value) return 'btn-warn' return 'btn-ok' }) // ---------- location ---------- function getLocation() { return new Promise((resolve, reject) => { uni.getLocation({ type: 'gcj02', success: (res) => { location.latitude = res.latitude location.longitude = res.longitude // address 没有反解析的话,先留空;用户可点“选择位置”拿 address resolve(res) }, fail: reject }) }) } function chooseLocation() { // 允许用户手动选择地点(会有 address) uni.chooseLocation({ success: (res) => { location.address = res.address || res.name || '' location.latitude = res.latitude location.longitude = res.longitude } }) } // ---------- API ---------- async function fetchConfig() { loadingConfig.value = true configError.value = '' try { const res = await ajax.post({ url: 'clockInConfigShow', // ✅ 16.clockInConfigShow method: 'post', data: { castingTeamId: String(castingTeamId.value), consumerId: String(consumerId.value) } }) if (!isSuccess(res)) { configError.value = respMsg(res) || '获取打卡配置失败' config.value = null return } const d = respData(res) config.value = d?.data || d } catch (e) { configError.value = '获取打卡配置失败' config.value = null } finally { loadingConfig.value = false } } // TODO:换成你们真实的“今日打卡回显”接口 async function fetchTodayRecords() { // 如果你们有:clockInTodayShow / clockInListByDay 之类,把这里替换掉 // 兜底:读本地缓存(这样打完卡也能立刻回显) const key = `clockin_today_${consumerId.value}_${castingTeamId.value}_${new Date().toISOString().slice(0,10)}` const cache = uni.getStorageSync(key) if (cache) { try { const obj = JSON.parse(cache) today.on = obj.on || null today.off = obj.off || null } catch {} } } function saveTodayCache() { const key = `clockin_today_${consumerId.value}_${castingTeamId.value}_${new Date().toISOString().slice(0,10)}` uni.setStorageSync(key, JSON.stringify({ on: today.on, off: today.off })) } // 点击刷新 async function refreshAll() { uni.vibrateShort?.() await Promise.all([fetchConfig(), fetchTodayRecords()]) try { await getLocation() } catch {} } // 打卡请求 async function clockIn(type) { clocking.value = true showSuccess.value = false const now = new Date() const payload = { id: config.value?.id, // ✅ 打卡设置id consumerId: String(consumerId.value), castingTeamId: String(castingTeamId.value), address: location.address || config.value?.address || '', clockTime: now, // 后端如果要字符串你再格式化 longitude: String(location.longitude || ''), latitude: String(location.latitude || ''), type, // 0/1/2/3/4 remark: '', state: 0, confirmConsumerId: '' // 正常打卡一般不需要;若后端要求再补 } try { const res = await ajax.post({ url: 'clockIn', // ✅ 4.clockIn method: 'post', data: payload }) if (!isSuccess(res)) { // 范围外 / 不允许等,做一个抖动提示 uni.showToast({ title: respMsg(res) || '打卡失败', icon: 'none' }) shakeBtn() return } // 成功回显(本地先顶上) const record = { clockTime: now, type, address: payload.address, longitude: payload.longitude, latitude: payload.latitude } if (type === 0 || type === 1) today.on = record if (type === 2 || type === 3) today.off = record saveTodayCache() showSuccess.value = true uni.showToast({ title: '打卡成功', icon: 'success' }) setTimeout(() => showSuccess.value = false, 1200) } catch (e) { uni.showToast({ title: '打卡失败', icon: 'none' }) shakeBtn() } finally { clocking.value = false } } function shakeBtn() { // 通过切 class 触发抖动 shakeFlag.value = true setTimeout(() => shakeFlag.value = false, 400) } const shakeFlag = ref(false) // 大按钮点击 async function onBigClockClick() { if (!config.value) return if (!isWorkday.value) { uni.showToast({ title: '今天非工作日', icon: 'none' }) return } if (!canClock.value) return if (clocking.value) return // 确保有定位 try { await getLocation() } catch { uni.showToast({ title: '定位失败,请授权或手动选点', icon: 'none' }) return } const act = nextAction.value const scopeOk = inScope.value // 类型映射:范围内 -> 正常上/下班;范围外 -> 外勤上/下班 if (act === 'on') { await clockIn(scopeOk ? 0 : 1) } else if (act === 'off') { await clockIn(scopeOk ? 2 : 3) } } // 补卡 function openMakeup() { makeup.visible = true } async function submitMakeup() { if (clocking.value) return if (!config.value) return // 拼补卡时间 const dt = new Date(`${makeup.date} ${makeup.time}:00`) if (Number.isNaN(dt.getTime())) { uni.showToast({ title: '时间不合法', icon: 'none' }) return } clocking.value = true try { // 补卡 type=4 const payload = { id: config.value?.id, consumerId: String(consumerId.value), castingTeamId: String(castingTeamId.value), address: location.address || config.value?.address || '', clockTime: dt, longitude: String(location.longitude || ''), latitude: String(location.latitude || ''), type: 4, remark: makeup.remark || '', state: 0, confirmConsumerId: makeup.confirmConsumerId || '' } const res = await ajax.post({ url: 'clockIn', method: 'post', data: payload }) if (!isSuccess(res)) { uni.showToast({ title: respMsg(res) || '补卡失败', icon: 'none' }) return } uni.showToast({ title: '补卡已提交', icon: 'success' }) makeup.visible = false makeup.remark = '' // 你若补卡成功要计入上/下班,可根据后端返回决定写入 today.on/off } catch { uni.showToast({ title: '补卡失败', icon: 'none' }) } finally { clocking.value = false } } // 底部入口(按你项目路由改) function goApprove() { uni.navigateTo({ url: '/pages/xxx/approve' }) } function goLeave() { uni.navigateTo({ url: '/pages/xxx/leave' }) } function goStats() { uni.navigateTo({ url: '/pages/xxx/stats' }) } // ---------- lifecycle ---------- async function init(option) { console.log(option, '里面的配置打卡') consumerId.value = uni.getStorageSync('consumerId') || '' castingTeamId.value = (decodeURIComponent(option?.castingTeamId)) userName.value = uni.getStorageSync('nickName') || '用户名' userAvatar.value = uni.getStorageSync('avatarUrl') || userAvatar.value await fetchConfig() await fetchTodayRecords() try { await getLocation() } catch {} } // 每秒更新一下按钮时间(不需要太精准,钉钉就是“动起来”) setInterval(() => {}, 1000) onLoad((option) => { if (option) { init(option || {}) } }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .content { min-height: 100vh; position: relative; background: #f2f2f2; } .overlay { position: absolute; inset: 0; background: #f2f2f2; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; height: 100%; } .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } .center-box { flex: none; } .title-text { font-size: 32rpx; color: #111; font-weight: 600; } .page { width: 94%; margin: 0 auto; padding-top: 14rpx; padding-bottom: 140rpx; } .user-row { display: flex; align-items: center; margin: 6rpx 0 14rpx; } .avatar { width: 84rpx; height: 84rpx; border-radius: 42rpx; background: #ddd; } .user-meta { margin-left: 16rpx; display: flex; flex-direction: row; align-items: baseline; } .username { font-size: 30rpx; color: #111; font-weight: 600; } .rules { font-size: 26rpx; color: #f0a23a; margin-left: 10rpx; } .main-card { background: #fff; border-radius: 18rpx; padding: 18rpx; } .card-top { position: relative; display: flex; align-items: center; justify-content: center; padding: 10rpx 0 8rpx; } .top-text { font-size: 28rpx; color: #444; } .refresh { position: absolute; right: 0; top: 0; bottom: 0; display: flex; align-items: center; padding: 0 10rpx; opacity: .9; } .skeleton { padding: 28rpx 10rpx; color: #888; font-size: 26rpx; } .row2 { display: flex; gap: 14rpx; margin-top: 10rpx; } .mini { flex: 1; background: #f2f2f2; border-radius: 14rpx; padding: 14rpx 14rpx 12rpx; } .mini-title { font-size: 26rpx; color: #333; } .mini-sub { margin-top: 10rpx; font-size: 24rpx; } .ok { color: #333; } .muted { color: #777; } .center-area { display: flex; flex-direction: column; align-items: center; padding: 26rpx 0 10rpx; } .big-btn { width: 260rpx; height: 260rpx; border-radius: 130rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; transform: scale(1); transition: transform .12s ease, filter .12s ease; user-select: none; overflow: hidden; } .big-btn.pressed { transform: scale(0.97); filter: brightness(0.98); } .btn-ok { background: #f0a23a; } .btn-warn { background: #f3b562; } .btn-disabled { background: #d8d8d8; } .btn-loading { background: #d8d8d8; } .pulse { position: absolute; inset: -18rpx; border-radius: 999px; border: 2rpx solid rgba(240, 162, 58, .45); animation: pulse 1.35s ease-in-out infinite; } @keyframes pulse { 0% { transform: scale(.92); opacity: .9; } 70% { transform: scale(1.06); opacity: .1; } 100% { transform: scale(1.06); opacity: 0; } } .big-line1 { font-size: 28rpx; color: #fff; font-weight: 600; } .big-line2 { margin-top: 10rpx; font-size: 30rpx; color: #fff; font-weight: 700; } .big-line3 { margin-top: 10rpx; font-size: 26rpx; color: rgba(255, 255, 255, .95); } .success-badge { position: absolute; right: 18rpx; top: 18rpx; width: 48rpx; height: 48rpx; border-radius: 24rpx; background: rgba(0, 0, 0, .16); display: flex; align-items: center; justify-content: center; animation: pop .18s ease-out; } @keyframes pop { 0% { transform: scale(.7); opacity: .2; } 100% { transform: scale(1); opacity: 1; } } .addr-row { margin-top: 18rpx; display: flex; align-items: center; max-width: 90%; } .dot { width: 14rpx; height: 14rpx; border-radius: 7rpx; margin-right: 10rpx; } .dot-ok { background: #38b000; } .dot-warn { background: #ff7a00; } .addr-text { font-size: 26rpx; color: #666; line-height: 34rpx; text-align: center; } .dist { color: #999; margin-left: 6rpx; } .tip-warn { margin-top: 10rpx; font-size: 24rpx; color: #ff7a00; } .bottom-bar { position: fixed; left: 3%; right: 3%; bottom: 18rpx; background: #fff; border-radius: 18rpx; padding: 18rpx 10rpx; display: flex; justify-content: space-around; box-shadow: 0 10rpx 22rpx rgba(0, 0, 0, .06); } .bar-item { width: 20%; display: flex; flex-direction: column; align-items: center; } .bar-ico { width: 70rpx; height: 70rpx; border-radius: 14rpx; background: #f2f2f2; display: flex; align-items: center; justify-content: center; } .bar-item.active .bar-ico { background: #f2f2f2; } .bar-txt { margin-top: 10rpx; font-size: 24rpx; color: #333; } /* modal */ .modal-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, .35); display: flex; align-items: flex-end; z-index: 1000; } .modal { width: 100%; background: #fff; border-top-left-radius: 22rpx; border-top-right-radius: 22rpx; padding: 20rpx 22rpx 26rpx; } .modal-title { font-size: 32rpx; font-weight: 700; color: #111; margin-bottom: 14rpx; } .form-row { display: flex; align-items: center; gap: 12rpx; margin-top: 12rpx; } .label { width: 140rpx; font-size: 26rpx; color: #333; } .picker { padding: 14rpx 16rpx; background: #f2f2f2; border-radius: 12rpx; font-size: 26rpx; color: #333; } .input { flex: 1; padding: 14rpx 16rpx; background: #f2f2f2; border-radius: 12rpx; font-size: 26rpx; } .btn-row { display: flex; justify-content: flex-end; gap: 12rpx; margin-top: 18rpx; } .btn { padding: 14rpx 22rpx; border-radius: 12rpx; background: #f0a23a; color: #fff; font-size: 26rpx; } .btn.ghost { background: #eee; color: #333; } .btn.disabled { opacity: .6; } </style>咖组text<view class="center-box"> <text class="title-text">打卡</text> </view> <view class="right-box" @click="openMore"> <uni-icons type="more-filled" color="#000000" size="22" /> </view> </view> </view> <!-- body --> <view class="page"> <!-- 顶部用户行 --> <view class="user-row"> <image class="avatar" :src="userAvatar" mode="aspectFill" /> <view class="user-meta"> <text class="username">{{ userName }}</text> <text class="rules" @click="openRules">(打卡规则)</text> </view> </view> <!-- 主卡片 --> <view class="main-card"> <view class="card-top"> <text class="top-text">{{ topStatusText }}</text> <view class="refresh" @click="refreshAll"> <uni-icons type="reload" color="#666666" size="18" /> </view> </view> <view v-if="loadingConfig" class="skeleton">加载打卡配置...</view> <template v-else> <view class="row2"> <!-- 上班 --> <view class="mini"> <text class="mini-title">上班{{ startHHMM }}</text> <text class="mini-sub" :class="today.on ? 'ok' : 'muted'"> {{ today.on ? ('打卡时间:' + formatTime(today.on.clockTime)) : '未打卡' }} </text> </view> <!-- 下班 --> <view class="mini"> <text class="mini-title">下班{{ overHHMM }}</text> <text class="mini-sub" :class="today.off ? 'ok' : 'muted'"> {{ today.off ? ('打卡时间:' + formatTime(today.off.clockTime)) : '未打卡' }} </text> </view> </view> <!-- 中央按钮 --> <view class="center-area"> <view :class="['big-btn', btnStateClass, pressed ? 'pressed' : '']" @touchstart="pressed = true" @touchend="pressed = false" @touchcancel="pressed = false" @click="onBigClockClick"> <view class="pulse" v-if="canClock && !clocking"></view> <text class="big-line1">{{ bigLine1 }}</text> <text class="big-line2">{{ bigLine2 }}</text> <text class="big-line3">{{ bigLine3 }}</text> <view class="success-badge" v-if="showSuccess"> <uni-icons type="checkmarkempty" color="#ffffff" size="20" /> </view> </view> <!-- 地址 / 范围提示 --> <view class="addr-row" @click="chooseLocation"> <view class="dot" :class="inScope ? 'dot-ok' : 'dot-warn'"></view> <text class="addr-text"> {{ location.address || '点击选择/刷新定位' }} <text v-if="distanceText" class="dist">({{ distanceText }})</text> </text> </view> <view v-if="!isWorkday" class="tip-warn">今天不是工作日,按规则无需打卡</view> <view v-else-if="configError" class="tip-warn">{{ configError }}</view> </view> </template> </view> <!-- 底部工具栏(和截图一致) --> <view class="bottom-bar"> <view class="bar-item" @click="goApprove"> <view class="bar-ico"><uni-icons type="list" color="#333333" size="22" /></view> <text class="bar-txt">审批</text> </view> <view class="bar-item active"> <view class="bar-ico"><uni-icons type="flag" color="#333333" size="22" /></view> <text class="bar-txt">打卡</text> </view> <view class="bar-item" @click="openMakeup"> <view class="bar-ico"><uni-icons type="compose" color="#333333" size="22" /></view> <text class="bar-txt">补卡</text> </view> <view class="bar-item" @click="goLeave"> <view class="bar-ico"><uni-icons type="calendar" color="#333333" size="22" /></view> <text class="bar-txt">请假</text> </view> <view class="bar-item" @click="goStats"> <view class="bar-ico"><uni-icons type="person" color="#333333" size="22" /></view> <text class="bar-txt">统计</text> </view> </view> </view> <!-- 补卡弹层(简单版) --> <view v-if="makeup.visible" class="modal-mask" @click.self="makeup.visible=false"> <view class="modal"> <view class="modal-title">补卡</view> <view class="form-row"> <text class="label">时间</text> <picker mode="date" :value="makeup.date" @change="e=>makeup.date=e.detail.value"> <view class="picker">{{ makeup.date }}</view> </picker> <picker mode="time" :value="makeup.time" @change="e=>makeup.time=e.detail.value"> <view class="picker">{{ makeup.time }}</view> </picker> </view> <view class="form-row"> <text class="label">备注</text> <input class="input" v-model="makeup.remark" placeholder="补卡原因..." /> </view> <view class="form-row"> <text class="label">审批人ID</text> <input class="input" v-model="makeup.confirmConsumerId" placeholder="confirmConsumerId(可选/按你后端要求)" /> </view> <view class="btn-row"> <view class="btn ghost" @click="makeup.visible=false">取消</view> <view class="btn" @click="submitMakeup" :class="clocking ? 'disabled' : ''">提交</view> </view> </view> </view> </view> </view> </view>
1.castingTeamChat咖组聊天
入口参数
String consumerId, 用户id
String castingTeamId,组id
String content, 内容
Integer type 类型
teamFile上传文件名
返回值
id
2.castingTeamChatListByCastingTeam组内聊天列表
入口参数
String castingTeamId, 组id Integer rowStartIdx, 起始行 Integer rowCount 行数
返回值
castingTeamChatArray 组聊天列表
id
content内容
type类型
fileName 文件名
showName 显示名
playURL 阿里云地址
createTime 建立时间
senderId 发送者id
3.searchCastingTeamChatList查询组聊天列表
入口参数
String consumerId,人id
String keyword 关键字
Integer rowStartIdx, 起始行
Integer rowCount 行数
返回值
castingTeamChatArray 组聊天列表
id
castingTeamId组id
castingTeamTitle组标题
senderId发送者id
senderNickname 发送者昵称
senderAvatarUrl 发送者头像
content内容
type类型
fileName 文件名
showName 显示名
playURL 阿里云地址
createTime 建立时间
senderId 发送者id
4.clockIn 打卡或补卡
入口参数
String id,
String consumerId, 用户id
String castingTeamId, 组id
String address, 地址
Date clockTime, 打卡时间
String longitude经度,
String latitude, 纬度
Integer type, 类型(0,正常上班 1,外勤上班2,正常下班3,外勤下班4,补卡)
String remark备注
Integer state 状态
String confirmConsumerId 审批人id
5.clockInList打卡列表
入口参数
String consumerId, 用户id
String castingTeamId, 组id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
clockInArray打卡列表
id
clockTime打卡时间
address地址
remark备注
latitude经度
longitude纬度
type类型
6.deleteCastingTeam 解散咖组
入口参数
String castingTeamId,群id
String consumerId, 用户id
7.deleteCastingTeamConsumer 删除组员、退出组
入口参数
String castingTeamId, 组id
String consumerId,用户id
8.castingTeamList 人的咖组列表
入口参数
String consumerId, 用户id
String ketword, 关键字
Integer rowStartIdx,起始行
Integer rowCount行数
String parentId 父组id
text返回值 castingTeamConsumerObject 组列表 avatarFileArray, 组员头像列表 castingTeamId, 组id castingTeamTitle, 组名称 castingTeamNotice, 组公告 castingTeamJoinlock, 进组方式 castingTeamMute, 是否禁言 castingTeamLock, 是否锁定该组
manager 是否为管理员
owner是否为组拥有者
doNotDisturb 消息免打扰
top 是否置顶
nickname 在组里的昵称
parentId 父组id
count 组内人数
lastChatContent 组里最后一条消息
lastChatSenderId 最后一条消息发送人id
9.castingTeam 组维护、加入、新建
入口参数
String id, 修改用
String parentId, 父组id
String title, 名称
String notice, 公告
String[] consumerIds, 加入组的用户
String ownerId,所有者id。新建时用,更换所有者时候用
String cancelOwnerId,取消管理员id,变更管理员时用
String[] managerIds,管理员id,变更管理员时用
String[] cancelManagerIds, 取消管理员id,变更管理员时用
返回值
id 组id
10.castingTeamAdminSetting 管理员、拥有者对组的设置
入口参数
String castingTeamId, 组id
String consumerId, 用户id
Boolean joinLock, 进群方式
Boolean mute, 全体禁言
Boolean lock, 锁定该组
String title, 群昵称
String notice, 群公告
String gesturePasswordId, 手势密码id
String stringPasswordId, 文字密码id
Integer defaultSecretPasswordType, 密码类型
Integer makeUpClockInCount,每月补卡设置
Integer forgetClockInTime,忘记打卡补卡天数
11.castingTeamSetting 个人在咖组中的设置
入口参数
String castingTeamId, 组id
String consumerId, 用户id
Boolean doNotDisturb, 免扰
Boolean top, 置顶
String nickname, 群昵称
12.castingTeamAdminSettingShow 管理员设置显示接口
入口参数
Sting castingTeamId, 组id
返回值
joinLock 进群方式
teamMute 全体禁言
lock 锁定该群
makeUpClockInCount 补卡次数
forgetClockInTime 忘记补卡补卡天数
gesturePasswordId手势密码ID
stringPasswordId文字密码id
defaultSecretPasswordType默认密码类型
title 群昵称
notice群公告
13.castingTeamSettingShow 个人在咖组中的设置显示
入口参数
Sting castingTeamId, 组id
String consumerId, 用户id
返回值
doNotDisturb 免打扰
top 置顶
nickname 群昵称
14.castingTeamConsumerList 组内用户列表
入口参数
String castingTeamId, 组id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
consumerId 用户id
nickname 用户昵称
avatarUrl 用户头像
owner 是否是拥有者
manager 是否是管理员
leaveApprover 是否是审批人
top 是否置顶
doNotDisturb是否免打扰
15.clockInConfig 打卡设置
入口参数
String id
String castingTeamId,组id
String consumerId, 用户id
Date startTime, 上班时间
Date overTime,下班时间
Boolean monday,周一是否上班
Boolean tuesday,周二是否上班
Boolean wednesday,周三是否上班
Boolean thursday,周四是否上班
Boolean friday,周五是否上班
Boolean saturday,周六是否上班
Boolean sunday,周日是否上班
String address,地址
String longitude,精度
String latitude,纬度
Integer scope,打卡范围
String clockInRule, 打卡规则
Integer advanceClockInTime, 提前多长时间打卡
返回值
id
16.clockInConfigShow 打卡设置显示
入口参数
String castingTeamId, 组id
String consumerId, 用户id
返回值
id 打卡设置id
startTime 上班时间
overTime 下班时间
address 地址
longitude 精度
latitude 纬度
scope 打卡范围
monday 周一是否上班
tuesday 周二是否上班
wednesday 周三是否上班
thursday 周四是否上班
friday 周五是否上班
saturday 周六是否上班
saturday 周日是否上班
clockInRule 打卡规则
advanceClockInTime 提前多长时间打卡
17.createLeave 请假申请
入口参数
String castingTeamId,组id
String consumerId, 用户id
Date startTime, 开始时间
Date overTime,结束时间
String leaveTypeId,审批人id
String content, 描述
String confirmConsumerId,审批人id
返回值
id
18.leaveAudit 请假审批
入口参数
id
String consumerId, 用户id
Integer state, 状态
String confirmContent, 审批理由
返回值
id
19.clockInAudit补卡审批
入口参数
id
String consumerId, 审批人id
Integer state, 状态
返回值
id
20.leaveAuditListt组审批人列表
入口参数
String castingTeamId, 组id
String consumerId, 审批人id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
adminArray
consumerId 用户id
nickname 群昵称
avatarUrl 头像
owner 是否是拥有者
manager 是否是管理员
leaveApprover 是否是审批者
21.leaveType 请假类型维护
入口参数
id
String castingTeamId, 组id
String name, 类型名称
返回值
id
22.leaveTypeList 请假类型列表
入口参数
String castingTeamId, 组id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
leaveTypeArray
id
name 类型名称
23.clockInAuditList 本人补卡列表
入口参数
String castingTeamId, 组id
String consumerId, 用户id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
clockInArray
id
clockTime 补卡时间
address 地址
remark 备注信息
longitude 精度
latitude 纬度
type 类型
state 状态
createTime 补卡申请时间
24.clockInUnprocessedAuditList 审批人收到的补卡待审核列表
入口参数
String castingTeamId, 组id
String consumerId, 审批人id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
clockInArray
id
consumerId 用户id
consumerName 昵称
castingTeamId 组id
clockTime 补卡时间
address 地址
remark 备注信息
longitude 精度
latitude 纬度
type 类型
state 状态
createTime 补卡申请时间
25.clockInProcessedAuditList 审批人已处理的补卡列表
入口参数
String castingTeamId, 组id
String consumerId, 审批人id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
clockInArray
id
consumerId 用户id
consumerName 昵称
castingTeamId 组id
clockTime 补卡时间
address 地址
remark 备注信息
longitude 精度
latitude 纬度
type 类型
state 状态
createTime 补卡申请时间
26.auditLeaveList 本人请假列表
入口参数
String castingTeamId, 组id
String consumerId, 用户id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
leaveArray
id
consumerId 用户id
consumerName 昵称
castingTeamId 组id
startTime 开始时间
overTime 结束时间
type 类型
content 描述
state 状态
confirmContent 审批理由
confirmConsumerName 审批人昵称
createTime 请假申请时间
27.leaveUnprocessedAuditList审批人收到的请假待审核列表
入口参数
String castingTeamId, 组id
String consumerId, 用户id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
leaveArray
id
consumerId 用户id
consumerName 昵称
castingTeamId 组id
startTime 开始时间
overTime 结束时间
type 类型
content 描述
state 状态
confirmContent 审批理由
confirmConsumerName 审批人昵称
createTime 请假申请时间
28.leaveProcessedAuditList审批人已处理的请假列表
入口参数
String castingTeamId, 组id
String consumerId, 用户id
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
leaveArray
id
consumerId 用户id
consumerName 昵称
castingTeamId 组id
startTime 开始时间
overTime 结束时间
type 类型
content 描述
state 状态
confirmContent 审批理由
confirmConsumerName 审批人昵称
createTime 请假申请时间
29.castingTeamFile组文件内文件上传
入口参数
teamFile 文件名
返回值
id 文件id
30.deleteCastingTeamFile删除组文件内文件
入口参数
id
31.castingTeamFileGroup组文件维护
入口参数
String id,
String consumerId, 用户id
String castingTeamId, 组id
Date startTime,开始时间
Date overTime, 结束时间
String title, 标题
Integer type,类型(0 组训,1 通告, 2 日志, 3 周报, 4 帐,5 课, 6 工作文档, 7 阅读文档)
String content, 内容
String[] castingTeamFileIds上传文件的id数组
String address,地址
String longitude,精度
String latitude,纬度
String[] tagIds,标签id
String[] consumerIds人员id
32.castingTeamTag组文件标签维护
入口参数
String consumerId, 用户id String id, String name 标签内容
返回值
id
33.castingTeamTagList组文件标签列表
入口参数
String keyword, 关键字
Integer rowStartIdx, 起始行
Integer rowCount行数
返回值 castingTeamTagArray id
name内容
34.castingTeamFileGroupList组文件列表
入口参数
String consumerId, 用户id
String castingTeamId, 组id
Integer type,类型
Integer rowStartIdx,起始行
Integer rowCount行数
返回值
castingTeamFileGroupArray组文件数组
id
consumerId用户id
content内容
startTime开始时间
overTime结束时间
title标题
type类型
address地址
latitude经度
longitude纬度castingTeamFileArray图片列表
showName 显示名
fileName 文件名
playURL 阿里云地址
35.castingTeamFileGroupLike组文件点赞(点击赞,再点取消)
入口参数
String consumerId, 用户id
String castingTeamFileGroupId组文件id
36.castingTeamFileGroupCommentLike组文件评论点赞(点击赞,再点取消)
入口参数
String consumerId, 用户id
String fileGroupCommentId组文件评论id
37.castingTeamFileGroupCommentCommentLike组文件评论的评论点赞(点击赞,再点取消)
入口参数
String consumerId, 用户id
String fileGroupCommentCommentId组文件评论的评论id
38.teamFileGroupComment文件评论
入口参数
String consumerId, 用户id
String castingTeamFileGroupId,组id
String id,
String comment评论内容
返回值
id
39.teamFileGroupCommentComment组文件评论的评论
入口参数
String consumerId, 用户id
String fileGroupCommentId,组文件评论id
String id,
String comment评论内容
返回值
id
40.teamFileGroupCommentList组文件评论列表
入口参数
String castingTeamFileGroupId,组文件id
Integer rowStartIdx, 起始行
Integer rowCount读取数量
返回值
fileGroupCommentArray评论列表
id
likeCount点赞数量
comment评论内容
consumerId 用户id
consumerNickname昵称
consumerAvatarUrl头像
41.teamFileGroupCommentCommentList组文件评论的评论列表
入口参数
String fileGroupCommentId,
Integer rowStartIdx,
Integer rowCount
返回值
fileGroupCommentCommentArray评论列表
id
likeCount点赞数量
comment评论内容
consumerId 用户id
consumerNickname昵称
consumerAvatarUrl头像
42.timekeeping考勤记录
入口参数
String consumerId, 用户id
String castingTeamId, 组id
Integer year, 年
Integer month月
返回值
timekeepingArray 考勤记录
date 日期
clockInArray打卡记录
address地址
longitude 经度
latitude纬度
type类型
clockTime打卡时间
createTime创建时间
remark备注
leaveArray请假列表
content理由
startTime开始时间
overTime结束时间
type 类型
43.statTimekeeping考勤统计
入口参数
String consumerId, 人
String castingTeamId, 组
Integer year, 年
Integer month月
返回值
leaveCount请假次数
clockCounts[] 打卡次数(数组,下标对应type)
44.webSocket长链接接口
/ws/{consumerId}建立链接:例如wss://www.doufan.net/ws/xxxxxxxxxxxxx
{consumerId}为当前用户的id
接受message格式:
{“type”:”teamChat”,”chatId”:”xxxxxxxx”}json转成的字符串格式 群内聊天
接受到正确的格式message后会向聊天双方或发送消息,消息格式:
[
content:“XXX” 聊天内容
type:0(0 文字 1 图片 2 视频 3 声音4 文件) fileName:“xxx”
showName:“xxx”
playURL:“xxx”
senderId:"xxx" 发送者
createTime: 建立时间
]
这个打卡主页面结合这个后端的api打卡的api 你看着用那个 修复一下 写一个完整的页面内容 在结合图片优化一下 弄一个完整的可以跑的打卡页面 把下面的审批 补卡照着图片往上面移动一下
下面给你一份可直接跑的 uni-app(Vue3 + <script setup>)打卡主页面,已经按你给的后端 API 做了对接:
把下面整段保存为一个页面(例如:/pages/clockin/index.vue),然后把你的路由跳转地址按你项目真实路径改一下即可。
注意:我默认你的
ajax.post({ url: 'xxx', method:'post', data })会自动拼接 baseURL 并发请求;如果你们实际是'/api/xxx'这种,改下 url 即可。
vue<template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <!-- Header --> <view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26" /> </view> <view class="center-box"> <text class="title-text">打卡</text> </view> <view class="right-box" @click="openMore"> <uni-icons type="more-filled" color="#000000" size="22" /> </view> </view> </view> <!-- body --> <view class="page"> <!-- 顶部用户行 --> <view class="user-row"> <image class="avatar" :src="userAvatar" mode="aspectFill" /> <view class="user-meta"> <text class="username">{{ userName }}</text> <text class="rules" @click="openRules">(打卡规则)</text> </view> </view> <!-- 主卡片 --> <view class="main-card"> <view class="card-top"> <text class="top-text">{{ topStatusText }}</text> <view class="refresh" @click="refreshAll"> <uni-icons type="reload" color="#666666" size="18" /> </view> </view> <view v-if="loadingConfig" class="skeleton">加载打卡配置...</view> <template v-else> <view class="row2"> <!-- 上班 --> <view class="mini"> <text class="mini-title">上班{{ startHHMM }}</text> <text class="mini-sub" :class="today.on ? 'ok' : 'muted'"> {{ today.on ? ('打卡时间:' + formatTime(today.on.clockTime)) : '未打卡' }} </text> </view> <!-- 下班 --> <view class="mini"> <text class="mini-title">下班{{ overHHMM }}</text> <text class="mini-sub" :class="today.off ? 'ok' : 'muted'"> {{ today.off ? ('打卡时间:' + formatTime(today.off.clockTime)) : '未打卡' }} </text> </view> </view> <!-- 中央按钮 --> <view class="center-area"> <view :class="[ 'big-btn', btnStateClass, pressed ? 'pressed' : '', shakeFlag ? 'shake' : '' ]" @touchstart="pressed = true" @touchend="pressed = false" @touchcancel="pressed = false" @click="onBigClockClick" > <view class="pulse" v-if="canClock && !clocking"></view> <text class="big-line1">{{ bigLine1 }}</text> <text class="big-line2">{{ bigLine2 }}</text> <text class="big-line3">{{ bigLine3 }}</text> <view class="success-badge" v-if="showSuccess"> <uni-icons type="checkmarkempty" color="#ffffff" size="20" /> </view> </view> <!-- 地址 / 范围提示(截图是在圆点下方) --> <view class="addr-row" @click="chooseLocation"> <view class="dot" :class="inScope ? 'dot-ok' : 'dot-warn'"></view> <text class="addr-text"> {{ location.address || config?.address || '点击选择/刷新定位' }} <text v-if="distanceText" class="dist">({{ distanceText }})</text> </text> </view> <view v-if="!isWorkday" class="tip-warn">今天不是工作日,按规则无需打卡</view> <view v-else-if="configError" class="tip-warn">{{ configError }}</view> </view> </template> </view> <!-- ✅ 工具栏:按截图“往上移动”,放到页面内容里(不 fixed 贴底) --> <view class="tool-card"> <view class="bar-item" @click="goApprove"> <view class="bar-ico"><uni-icons type="list" color="#333333" size="22" /></view> <text class="bar-txt">审批</text> </view> <view class="bar-item active"> <view class="bar-ico"><uni-icons type="flag" color="#333333" size="22" /></view> <text class="bar-txt">打卡</text> </view> <view class="bar-item" @click="openMakeup"> <view class="bar-ico"><uni-icons type="compose" color="#333333" size="22" /></view> <text class="bar-txt">补卡</text> </view> <view class="bar-item" @click="goLeave"> <view class="bar-ico"><uni-icons type="calendar" color="#333333" size="22" /></view> <text class="bar-txt">请假</text> </view> <view class="bar-item" @click="goStats"> <view class="bar-ico"><uni-icons type="person" color="#333333" size="22" /></view> <text class="bar-txt">统计</text> </view> </view> <!-- 给底部留一点空间(更像截图) --> <view style="height: 24rpx;"></view> </view> <!-- 补卡弹层 --> <view v-if="makeup.visible" class="modal-mask" @click.self="makeup.visible=false"> <view class="modal"> <view class="modal-title">补卡</view> <view class="form-row"> <text class="label">时间</text> <picker mode="date" :value="makeup.date" @change="e=>makeup.date=e.detail.value"> <view class="picker">{{ makeup.date }}</view> </picker> <picker mode="time" :value="makeup.time" @change="e=>makeup.time=e.detail.value"> <view class="picker">{{ makeup.time }}</view> </picker> </view> <view class="form-row"> <text class="label">备注</text> <input class="input" v-model="makeup.remark" placeholder="补卡原因..." /> </view> <view class="form-row"> <text class="label">审批人ID</text> <input class="input" v-model="makeup.confirmConsumerId" placeholder="confirmConsumerId(可选/按后端要求)" /> </view> <view class="btn-row"> <view class="btn ghost" @click="makeup.visible=false">取消</view> <view class="btn" @click="submitMakeup" :class="clocking ? 'disabled' : ''">提交</view> </view> </view> </view> </view> </view> </view> </template> <script setup> import ajax from '@/api/ajax.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import { ref, reactive, computed } from 'vue' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { onLoad, onUnload, onShow } from '@dcloudio/uni-app' // theme const store = useMainStore() const { theme } = storeToRefs(store) // header heights const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) // basic user const userName = ref('用户名') const userAvatar = ref('') // params const castingTeamId = ref('') // 组id const consumerId = ref('') // 用户id // config const loadingConfig = ref(false) const configError = ref('') const config = ref(null) // today records const today = reactive({ on: null, // { clockTime, type, address, longitude, latitude } off: null, }) // ui states const pressed = ref(false) const clocking = ref(false) const showSuccess = ref(false) const shakeFlag = ref(false) const location = reactive({ latitude: null, longitude: null, address: '' }) // 补卡 const makeup = reactive({ visible: false, date: new Date().toISOString().slice(0, 10), time: String(new Date().getHours()).padStart(2, '0') + ':' + String(new Date().getMinutes()).padStart(2, '0'), remark: '', confirmConsumerId: '' }) // 动态时间(让按钮上的秒表动起来) const nowTime = ref(new Date()) let timer = null // ---------- helpers ---------- function respData(res) { return res?.data || res || {} } function isSuccess(res) { const d = respData(res) const r = d?.result if (r === 'success' || r === true || r === 0 || r === '0') return true if (d?.code === 0 || d?.success === true) return true return false } function respMsg(res) { const d = respData(res) return d?.remark || d?.msg || d?.message || d?.error || '' } function navigateBack() { uni.navigateBack() } function openMore() { // 你原来的设置页 uni.navigateTo({ url: '/pages/sociAlize/Coffeegroup/application/CheckinSettings/Settings/Checksettings?castingTeamId=' + castingTeamId.value }) } function openRules() { if (!config.value) return uni.showModal({ title: '打卡规则', content: `上班:${startHHMM.value}\n下班:${overHHMM.value}\n范围:${config.value?.scope ?? '-'}m\n地址:${config.value?.address || ''}`, showCancel: false }) } // 取 HH:mm(兼容:Date/ISO/“HH:mm”/“YYYY-MM-DD HH:mm:ss”) function toHHMM(v) { if (!v) return '--:--' const s = String(v) const m = s.match(/(\d{2}):(\d{2})/) if (m) return `${m[1]}:${m[2]}` const d = new Date(v) if (!Number.isNaN(d.getTime())) { return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') } return '--:--' } // 时间显示(打卡时间用) function formatTime(v) { return toHHMM(v) } // 日期时间串(给后端 clockTime 用,避免 Date 直传被序列化不一致) function formatDateTime(d) { const dt = (d instanceof Date) ? d : new Date(d) const yyyy = dt.getFullYear() const MM = String(dt.getMonth() + 1).padStart(2, '0') const dd = String(dt.getDate()).padStart(2, '0') const hh = String(dt.getHours()).padStart(2, '0') const mm = String(dt.getMinutes()).padStart(2, '0') const ss = String(dt.getSeconds()).padStart(2, '0') return `${yyyy}-${MM}-${dd} ${hh}:${mm}:${ss}` } const startHHMM = computed(() => config.value?.startTime ? (' ' + toHHMM(config.value.startTime)) : ' --:--') const overHHMM = computed(() => config.value?.overTime ? (' ' + toHHMM(config.value.overTime)) : ' --:--') // 工作日判断 const isWorkday = computed(() => { if (!config.value) return true const day = new Date().getDay() // 0 Sun ... 6 Sat const map = { 1:'monday', 2:'tuesday', 3:'wednesday', 4:'thursday', 5:'friday', 6:'saturday', 0:'sunday' } const key = map[day] const v = config.value?.[key] if (v === undefined || v === null) return true // 兼容 boolean / 0|1 / "0"|"1" return Boolean(Number(v) || v === true) }) // 距离计算(米) function haversine(lat1, lon1, lat2, lon2) { const toRad = x => (x * Math.PI) / 180 const R = 6371000 const dLat = toRad(lat2 - lat1) const dLon = toRad(lon2 - lon1) const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2) const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return R * c } const distance = computed(() => { const lat = Number(location.latitude) const lon = Number(location.longitude) const lat2 = Number(config.value?.latitude) const lon2 = Number(config.value?.longitude) if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(lat2) || !Number.isFinite(lon2)) return null return haversine(lat, lon, lat2, lon2) }) const inScope = computed(() => { const d = distance.value const scope = Number(config.value?.scope || 0) if (d === null || !scope) return true return d <= scope }) const distanceText = computed(() => { const d = distance.value if (d === null) return '' if (d < 1000) return `${Math.round(d)}m` return `${(d / 1000).toFixed(2)}km` }) // 下一步动作 const nextAction = computed(() => { if (!isWorkday.value) return 'none' if (!today.on) return 'on' if (!today.off) return 'off' return 'done' }) const doneCount = computed(() => (today.on ? 1 : 0) + (today.off ? 1 : 0)) const topStatusText = computed(() => { if (!config.value) return '暂无打卡' if (nextAction.value === 'done') return '今日已完成打卡' return `已打卡 ${doneCount.value}/2` }) // 中央按钮文案(更贴近截图) const bigLine1 = computed(() => { if (!config.value) return '加载中' if (!isWorkday.value) return '休息日' if (!inScope.value) return '外勤' return '正常' }) const bigLine2 = computed(() => { if (nextAction.value === 'on') return '上班打卡' if (nextAction.value === 'off') return '下班打卡' if (nextAction.value === 'done') return '已完成' return '—' }) const bigLine3 = computed(() => { const d = nowTime.value const hh = String(d.getHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0') const ss = String(d.getSeconds()).padStart(2, '0') return `${hh}:${mm}:${ss}` }) const canClock = computed(() => { if (!config.value) return false if (!isWorkday.value) return false return nextAction.value === 'on' || nextAction.value === 'off' }) const btnStateClass = computed(() => { if (!config.value) return 'btn-loading' if (!canClock.value) return 'btn-disabled' if (!inScope.value) return 'btn-warn' return 'btn-ok' }) // ---------- location ---------- function getLocation() { return new Promise((resolve, reject) => { uni.getLocation({ type: 'gcj02', success: (res) => { location.latitude = res.latitude location.longitude = res.longitude resolve(res) }, fail: reject }) }) } function chooseLocation() { uni.chooseLocation({ success: (res) => { location.address = res.address || res.name || '' location.latitude = res.latitude location.longitude = res.longitude } }) } // ---------- API ---------- // 16.clockInConfigShow 打卡配置 async function fetchConfig() { loadingConfig.value = true configError.value = '' try { const res = await ajax.post({ url: 'clockInConfigShow', method: 'post', data: { castingTeamId: String(castingTeamId.value), consumerId: String(consumerId.value) } }) if (!isSuccess(res)) { configError.value = respMsg(res) || '获取打卡配置失败' config.value = null return } const d = respData(res) config.value = d?.data || d } catch (e) { configError.value = '获取打卡配置失败' config.value = null } finally { loadingConfig.value = false } } // 42.timekeeping 当月考勤(用于回显“今天上/下班”) async function fetchTodayRecords() { // 先尝试从后端拉 try { const now = new Date() const year = now.getFullYear() const month = now.getMonth() + 1 const res = await ajax.post({ url: 'timekeeping', method: 'post', data: { consumerId: String(consumerId.value), castingTeamId: String(castingTeamId.value), year, month } }) if (isSuccess(res)) { const d = respData(res) const arr = d?.timekeepingArray || d?.data?.timekeepingArray || d?.data || d || [] const todayStr = now.toISOString().slice(0, 10) // 找到今日记录 const one = Array.isArray(arr) ? arr.find(x => String(x?.date || '').slice(0, 10) === todayStr) : null // reset today.on = null today.off = null const clockArr = one?.clockInArray || [] // type: 0正常上班 1外勤上班 2正常下班 3外勤下班 4补卡 for (const r of clockArr) { const t = Number(r?.type) if (t === 0 || t === 1) { // 若有多个,取最早一次作为“上班” if (!today.on || new Date(r.clockTime).getTime() < new Date(today.on.clockTime).getTime()) { today.on = r } } if (t === 2 || t === 3) { // 若有多个,取最晚一次作为“下班” if (!today.off || new Date(r.clockTime).getTime() > new Date(today.off.clockTime).getTime()) { today.off = r } } } saveTodayCache() return } } catch (e) { // ignore,走缓存兜底 } // 兜底:读本地缓存 try { const key = todayCacheKey() const cache = uni.getStorageSync(key) if (cache) { const obj = JSON.parse(cache) today.on = obj.on || null today.off = obj.off || null } } catch {} } function todayCacheKey() { return `clockin_today_${consumerId.value}_${castingTeamId.value}_${new Date().toISOString().slice(0, 10)}` } function saveTodayCache() { uni.setStorageSync(todayCacheKey(), JSON.stringify({ on: today.on, off: today.off })) } // 点击刷新 async function refreshAll() { uni.vibrateShort?.() await Promise.all([fetchConfig(), fetchTodayRecords()]) try { await getLocation() } catch {} } // 4.clockIn 打卡/补卡 async function clockIn(type) { clocking.value = true showSuccess.value = false const now = new Date() const payload = { id: config.value?.id, // 打卡设置id consumerId: String(consumerId.value), castingTeamId: String(castingTeamId.value), address: location.address || config.value?.address || '', clockTime: formatDateTime(now), // 用字符串更稳 longitude: String(location.longitude ?? ''), latitude: String(location.latitude ?? ''), type, // 0/1/2/3/4 remark: '', state: 0, confirmConsumerId: '' } try { const res = await ajax.post({ url: 'clockIn', method: 'post', data: payload }) if (!isSuccess(res)) { uni.showToast({ title: respMsg(res) || '打卡失败', icon: 'none' }) shakeBtn() return } // 成功回显(本地先顶上) const record = { clockTime: payload.clockTime, type, address: payload.address, longitude: payload.longitude, latitude: payload.latitude } if (type === 0 || type === 1) today.on = record if (type === 2 || type === 3) today.off = record saveTodayCache() showSuccess.value = true uni.showToast({ title: '打卡成功', icon: 'success' }) setTimeout(() => (showSuccess.value = false), 1200) } catch (e) { uni.showToast({ title: '打卡失败', icon: 'none' }) shakeBtn() } finally { clocking.value = false } } function shakeBtn() { shakeFlag.value = true setTimeout(() => (shakeFlag.value = false), 420) } // 大按钮点击 async function onBigClockClick() { if (!config.value) return if (!isWorkday.value) { uni.showToast({ title: '今天非工作日', icon: 'none' }) return } if (!canClock.value || clocking.value) return // 确保有定位 try { await getLocation() } catch { uni.showToast({ title: '定位失败,请授权或手动选点', icon: 'none' }) return } const act = nextAction.value const scopeOk = inScope.value // 类型映射:范围内 -> 正常上/下班;范围外 -> 外勤上/下班 if (act === 'on') { await clockIn(scopeOk ? 0 : 1) } else if (act === 'off') { await clockIn(scopeOk ? 2 : 3) } } // 补卡 function openMakeup() { makeup.visible = true } async function submitMakeup() { if (clocking.value) return if (!config.value) return const dt = new Date(`${makeup.date} ${makeup.time}:00`) if (Number.isNaN(dt.getTime())) { uni.showToast({ title: '时间不合法', icon: 'none' }) return } clocking.value = true try { const payload = { id: config.value?.id, consumerId: String(consumerId.value), castingTeamId: String(castingTeamId.value), address: location.address || config.value?.address || '', clockTime: formatDateTime(dt), longitude: String(location.longitude ?? ''), latitude: String(location.latitude ?? ''), type: 4, // 补卡 remark: makeup.remark || '', state: 0, confirmConsumerId: makeup.confirmConsumerId || '' } const res = await ajax.post({ url: 'clockIn', method: 'post', data: payload }) if (!isSuccess(res)) { uni.showToast({ title: respMsg(res) || '补卡失败', icon: 'none' }) return } uni.showToast({ title: '补卡已提交', icon: 'success' }) makeup.visible = false makeup.remark = '' // 补卡是否要回显进 today.on/off,建议以你们后端返回的 type/规则为准再写入 } catch (e) { uni.showToast({ title: '补卡失败', icon: 'none' }) } finally { clocking.value = false } } // 底部入口(按你项目路由改) function goApprove() { uni.navigateTo({ url: '/pages/xxx/approve' }) } function goLeave() { uni.navigateTo({ url: '/pages/xxx/leave' }) } function goStats() { uni.navigateTo({ url: '/pages/xxx/stats' }) } // ---------- lifecycle ---------- async function init(option) { consumerId.value = uni.getStorageSync('consumerId') || '' castingTeamId.value = option?.castingTeamId ? decodeURIComponent(option.castingTeamId) : '' userName.value = uni.getStorageSync('nickName') || '用户名' userAvatar.value = uni.getStorageSync('avatarUrl') || userAvatar.value await fetchConfig() await fetchTodayRecords() try { await getLocation() } catch {} } onLoad((option) => { init(option || {}) // 秒表动起来 timer = setInterval(() => { nowTime.value = new Date() }, 1000) }) // 页面回到前台时,顺手刷新一次(避免从设置页回来不更新) onShow(() => { if (consumerId.value && castingTeamId.value) { fetchTodayRecords() } }) onUnload(() => { if (timer) clearInterval(timer) timer = null }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .content { min-height: 100vh; position: relative; background: #f2f2f2; } .overlay { position: absolute; inset: 0; background: #f2f2f2; } /* header */ .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; height: 100%; } .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } .title-text { font-size: 32rpx; color: #111; font-weight: 600; } .page { width: 94%; margin: 0 auto; padding-top: 14rpx; padding-bottom: 40rpx; } /* user */ .user-row { display: flex; align-items: center; margin: 6rpx 0 14rpx; } .avatar { width: 84rpx; height: 84rpx; border-radius: 42rpx; background: #ddd; } .user-meta { margin-left: 16rpx; display: flex; flex-direction: row; align-items: baseline; } .username { font-size: 30rpx; color: #111; font-weight: 600; } .rules { font-size: 26rpx; color: #f0a23a; margin-left: 10rpx; } /* main card */ .main-card { background: #fff; border-radius: 18rpx; padding: 18rpx; } .card-top { position: relative; display: flex; align-items: center; justify-content: center; padding: 10rpx 0 8rpx; } .top-text { font-size: 28rpx; color: #444; } .refresh { position: absolute; right: 0; top: 0; bottom: 0; display: flex; align-items: center; padding: 0 10rpx; opacity: .9; } .skeleton { padding: 28rpx 10rpx; color: #避免; font-size: 26rpx; } .row2 { display: flex; gap: 14rpx; margin-top: 10rpx; } .mini { flex: 1; background: #f2f2f2; border-radius: 14rpx; padding: 14rpx 14rpx 12rpx; } .mini-title { font-size: 26rpx; color: #333; } .mini-sub { margin-top: 10rpx; font-size: 24rpx; } .ok { color: #333; } .muted { color: #777; } /* center */ .center-area { display: flex; flex-direction: column; align-items: center; padding: 26rpx 0 10rpx; } .big-btn { width: 260rpx; height: 260rpx; border-radius: 130rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; transform: scale(1); transition: transform .12s ease, filter .12s ease; user-select: none; overflow: hidden; } .big-btn.pressed { transform: scale(0.97); filter: brightness(0.98); } .btn-ok { background: #f0a23a; } .btn-warn { background: #f3b562; } .btn-disabled, .btn-loading { background: #d8d8d8; } .pulse { position: absolute; inset: -18rpx; border-radius: 999px; border: 2rpx solid rgba(240, 162, 58, .45); animation: pulse 1.35s ease-in-out infinite; } @keyframes pulse { 0% { transform: scale(.92); opacity: .9; } 70% { transform: scale(1.06); opacity: .1; } 100% { transform: scale(1.06); opacity: 0; } } .big-line1 { font-size: 28rpx; color: #fff; font-weight: 600; } .big-line2 { margin-top: 10rpx; font-size: 30rpx; color: #fff; font-weight: 700; } .big-line3 { margin-top: 10rpx; font-size: 26rpx; color: rgba(255, 255, 255, .95); } .success-badge { position: absolute; right: 18rpx; top: 18rpx; width: 48rpx; height: 48rpx; border-radius: 24rpx; background: rgba(0, 0, 0, .16); display: flex; align-items: center; justify-content: center; animation: pop .18s ease-out; } @keyframes pop { 0% { transform: scale(.7); opacity: .2; } 100% { transform: scale(1); opacity: 1; } } /* shake */ .big-btn.shake { animation: shake .42s ease; } @keyframes shake { 0% { transform: translateX(0); } 15% { transform: translateX(-10rpx); } 30% { transform: translateX(10rpx); } 45% { transform: translateX(-8rpx); } 60% { transform: translateX(8rpx); } 75% { transform: translateX(-4rpx); } 100% { transform: translateX(0); } } /* address row (center like screenshot) */ .addr-row { margin-top: 18rpx; display: flex; align-items: center; justify-content: center; max-width: 90%; } .dot { width: 14rpx; height: 14rpx; border-radius: 7rpx; margin-right: 10rpx; } .dot-ok { background: #38b000; } .dot-warn { background: #ff7a00; } .addr-text { font-size: 26rpx; color: #666; line-height: 34rpx; text-align: center; } .dist { color: #999; margin-left: 6rpx; } .tip-warn { margin-top: 10rpx; font-size: 24rpx; color: #ff7a00; } /* ✅ tool-card : like screenshot, moved up (not fixed) */ .tool-card { margin-top: 16rpx; background: #fff; border-radius: 18rpx; padding: 18rpx 10rpx; display: flex; justify-content: space-around; } .bar-item { width: 20%; display: flex; flex-direction: column; align-items: center; } .bar-ico { width: 70rpx; height: 70rpx; border-radius: 14rpx; background: #f2f2f2; display: flex; align-items: center; justify-content: center; } .bar-txt { margin-top: 10rpx; font-size: 24rpx; color: #333; } /* modal */ .modal-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, .35); display: flex; align-items: flex-end; z-index: 1000; } .modal { width: 100%; background: #fff; border-top-left-radius: 22rpx; border-top-right-radius: 22rpx; padding: 20rpx 22rpx 26rpx; } .modal-title { font-size: 32rpx; font-weight: 700; color: #111; margin-bottom: 14rpx; } .form-row { display: flex; align-items: center; gap: 12rpx; margin-top: 12rpx; } .label { width: 140rpx; font-size: 26rpx; color: #333; } .picker { padding: 14rpx 16rpx; background: #f2f2f2; border-radius: 12rpx; font-size: 26rpx; color: #333; } .input { flex: 1; padding: 14rpx 16rpx; background: #f2f2f2; border-radius: 12rpx; font-size: 26rpx; } .btn-row { display: flex; justify-content: flex-end; gap: 12rpx; margin-top: 18rpx; } .btn { padding: 14rpx 22rpx; border-radius: 12rpx; background: #f0a23a; color: #fff; font-size: 26rpx; } .btn.ghost { background: #eee; color: #333; } .btn.disabled { opacity: .6; } </style>
如果你还想把“审批/补卡”做得更完整(比如:补卡时弹出审批人列表,直接从 20.leaveAuditListt 拉 adminArray 供选择,而不是手填 confirmConsumerId),我也能顺着你这套接口把那块补齐。只要告诉我你们审批页/请假页/统计页的真实路由路径就行。