feat: 考勤改为NFC打卡(替代蓝牙)

- API: onAndOffPunch → nfcOnAndOffPunch(v1.2.5新增)
- 参数: {beaconMacs, punchType} → {nfcId, punchType}
- 流程: 点按钮→开NFC→贴信标读卡→提交
- 10秒超时自动关闭NFC
- 离开页面自动关闭NFC

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-28 19:23:48 +09:30
parent 2d5726db9a
commit 967b001d46
2 changed files with 75 additions and 78 deletions

View File

@@ -8,8 +8,7 @@ import retrofit2.http.POST
/** /**
* 考勤打卡 API 接口 * 考勤打卡 API 接口
* 来源:discovery-map.md 考勤章节 * 来源:v1.2.5 punchApis.js + 用户确认NFC考勤流程
* 注意myCurrentAttendance 标注为 GET如返回 405 需改为 POST
*/ */
interface PunchApi { interface PunchApi {
@@ -17,9 +16,9 @@ interface PunchApi {
@GET("watchTask/myCurrentAttendance") @GET("watchTask/myCurrentAttendance")
suspend fun getAttendance(): ApiResponse<PunchStatus> suspend fun getAttendance(): ApiResponse<PunchStatus>
/** 上班/下班打卡 */ /** NFC 上班/下班打卡v1.2.5 新增) */
@POST("watchTask/onAndOffPunch") @POST("watchTask/nfcOnAndOffPunch")
suspend fun onAndOffPunch(@Body params: HashMap<String, Any>): ApiResponse<Any> suspend fun nfcOnAndOffPunch(@Body params: HashMap<String, Any>): ApiResponse<Any>
/** 撤销打卡 */ /** 撤销打卡 */
@POST("watchTask/revokePunch") @POST("watchTask/revokePunch")

View File

@@ -6,10 +6,10 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.xiaoqu.watch.R import com.xiaoqu.watch.R
import com.xiaoqu.watch.data.punch.PunchStatus import com.xiaoqu.watch.data.punch.PunchStatus
import com.xiaoqu.watch.databinding.FragmentPunchBinding import com.xiaoqu.watch.databinding.FragmentPunchBinding
import com.xiaoqu.watch.device.nfc.NfcController
import com.xiaoqu.watch.device.screen.ScreenController import com.xiaoqu.watch.device.screen.ScreenController
import com.xiaoqu.watch.event.AppEvent import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus import com.xiaoqu.watch.event.EventBus
@@ -20,31 +20,36 @@ import com.xiaoqu.watch.ui.common.BaseFragment
import com.xiaoqu.watch.ui.widget.QuConfirmDialog import com.xiaoqu.watch.ui.widget.QuConfirmDialog
import com.xiaoqu.watch.ui.widget.QuTipDialog import com.xiaoqu.watch.ui.widget.QuTipDialog
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
* 考勤打卡页面 * 考勤打卡页面NFC 方式)
* 入口:首页下拉手势
* 3 种状态:未上班→上班打卡→已上班→下班打卡/撤销
* *
* 流程(基于 discovery-map.md 考勤章节 + baseline/05 流程6 * 流程:
* 上班蓝牙识别1.5s → 确认弹窗 → POST onAndOffPunch成功 * 1. 进入页面 → GET myCurrentAttendance显示考勤状态
* 下班蓝牙识别1.5s → 直接提交 → POST onAndOffPunch → 成功+低耗电 * 2. 点击「上班打卡」→ 开启 NFC → "请将手表贴近打卡信标"
* 撤销:确认弹窗 → POST revokePunch → 恢复上班状态 * 3. NFC 读到卡号 → 上班弹确认 / 下班直接提交
* 4. POST nfcOnAndOffPunch {nfcId, punchType}
* 5. 成功 → 提示 + 更新工作状态 + 屏幕亮度
*
* 来源v1.2.5 punchApis.js nfcOnAndOffPunch + 用户确认
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class PunchFragment : BaseFragment<FragmentPunchBinding>() { class PunchFragment : BaseFragment<FragmentPunchBinding>() {
@Inject lateinit var punchApi: PunchApi @Inject lateinit var punchApi: PunchApi
@Inject lateinit var nfcController: NfcController
@Inject lateinit var screenController: ScreenController @Inject lateinit var screenController: ScreenController
@Inject lateinit var eventBus: EventBus @Inject lateinit var eventBus: EventBus
/** 当前考勤状态 */ /** 当前考勤状态 */
private var punchStatus: PunchStatus? = null private var punchStatus: PunchStatus? = null
/** 当前打卡类型0=上班, 1=下班) */
private var currentPunchType = 0
/** 提示弹窗 */ /** 提示弹窗 */
private lateinit var tipDialog: QuTipDialog private lateinit var tipDialog: QuTipDialog
@@ -66,10 +71,19 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
// 获取考勤状态 // 获取考勤状态
fetchAttendance() fetchAttendance()
// 监听系统状态事件(电量更新状态栏) // 监听系统状态事件
observeEvents() observeEvents()
} }
override fun onDestroyView() {
super.onDestroyView()
// 离开页面时关闭 NFC
nfcController.stopScan()
if (nfcController.isOpen()) {
nfcController.close()
}
}
// ===== 数据获取 ===== // ===== 数据获取 =====
/** 获取当前考勤状态 */ /** 获取当前考勤状态 */
@@ -83,7 +97,6 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
} }
is ApiResult.Error -> { is ApiResult.Error -> {
Timber.w("考勤: API 错误 ${result.code}") Timber.w("考勤: API 错误 ${result.code}")
// 默认显示未上班
displayStatus(PunchStatus()) displayStatus(PunchStatus())
} }
is ApiResult.NetworkError -> { is ApiResult.NetworkError -> {
@@ -96,105 +109,103 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
// ===== UI 显示 ===== // ===== UI 显示 =====
/** 根据考勤状态更新页面显示(基于业务逻辑矩阵) */ /** 根据考勤状态更新页面(基于业务逻辑矩阵) */
private fun displayStatus(status: PunchStatus) { private fun displayStatus(status: PunchStatus) {
// 重置所有可选元素
binding.btnRevoke.visibility = View.GONE binding.btnRevoke.visibility = View.GONE
binding.lowPowerHint.visibility = View.GONE binding.lowPowerHint.visibility = View.GONE
when { when {
// 未上班 → 显示「上班打卡」 // 未上班
!status.isOnDuty -> { !status.isOnDuty -> {
binding.tvPunchStatus.text = "未上班" binding.tvPunchStatus.text = "未上班"
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary)) binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary))
binding.btnPunch.text = "上班打卡" binding.btnPunch.text = "上班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startPunch(0) } binding.btnPunch.setOnClickListener { startNfcPunch(0) }
} }
// 已上班 + 已下班
// 已上班 + 已下班 → 显示「撤销」+「下班打卡」+ 低耗电提示
status.isOnDuty && status.isOffDuty -> { status.isOnDuty && status.isOffDuty -> {
binding.tvPunchStatus.text = "已下班" binding.tvPunchStatus.text = "已下班"
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary)) binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary))
binding.btnPunch.text = "下班打卡" binding.btnPunch.text = "下班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startPunch(1) } binding.btnPunch.setOnClickListener { startNfcPunch(1) }
binding.btnRevoke.visibility = View.VISIBLE binding.btnRevoke.visibility = View.VISIBLE
binding.btnRevoke.setOnClickListener { doRevoke() } binding.btnRevoke.setOnClickListener { doRevoke() }
binding.lowPowerHint.visibility = View.VISIBLE binding.lowPowerHint.visibility = View.VISIBLE
} }
// 已上班 + 未下班
// 已上班 + 未下班 → 显示「下班打卡」
status.isOnDuty && !status.isOffDuty -> { status.isOnDuty && !status.isOffDuty -> {
binding.tvPunchStatus.text = "已上班" binding.tvPunchStatus.text = "已上班"
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.success)) binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.success))
binding.btnPunch.text = "下班打卡" binding.btnPunch.text = "下班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startPunch(1) } binding.btnPunch.setOnClickListener { startNfcPunch(1) }
} }
} }
} }
// ===== 打卡操作 ===== // ===== NFC 打卡操作 =====
/** /**
* 开始打卡流程 * 开始 NFC 打卡
* 1. 显示蓝牙识别 Loading1.5秒) * 1. 开启 NFC
* 2. 读取 beacons当前用模拟数据 * 2. 显示"请将手表贴近打卡信标"
* 3. 上班:弹确认框;下班:直接提交 * 3. NFC 读到卡号后回调
*/ */
private fun startPunch(punchType: Int) { private fun startNfcPunch(punchType: Int) {
// 显示蓝牙识别提示 currentPunchType = punchType
// 开启 NFC
nfcController.open()
// 显示扫描提示
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.LOCATION, status = QuTipDialog.Status.LOCATION,
title = "蓝牙正在识别…", title = "请将手表贴近打卡信标",
back = false back = true,
step = 0,
countdown = 10, // 10秒超时自动关闭
onBack = {
// 用户取消或超时 → 关闭 NFC
nfcController.stopScan()
nfcController.close()
}
) )
viewLifecycleOwner.lifecycleScope.launch { // 开始 NFC 扫描,读到卡号后回调
// 等待 1.5 秒(模拟蓝牙扫描采集) nfcController.startScan { nfcId ->
delay(1500) Timber.d("考勤: NFC 读到卡号 $nfcId")
// 关闭 NFC 和提示
nfcController.stopScan()
nfcController.close()
tipDialog.dismiss() tipDialog.dismiss()
// 读取蓝牙信标TODO: 后续从 store.beacons 读取,当前用模拟数据) // 上班弹确认 / 下班直接提交
val beaconMacs = getMockBeaconMacs() if (currentPunchType == 0) {
if (beaconMacs.isEmpty()) {
// 无信标 → 提示失败
tipDialog.show(
status = QuTipDialog.Status.WARNING,
title = "打卡失败",
desc = "未搜索到蓝牙信标,请重试",
back = true, step = 0, countdown = 3
)
return@launch
}
if (punchType == 0) {
// 上班 → 弹确认弹窗
confirmDialog.showText( confirmDialog.showText(
text = "确定上班打卡?", text = "确定上班打卡?",
onConfirm = { doPunch(punchType, beaconMacs) } onConfirm = { doPunch(nfcId) },
onCancel = { /* 取消,不打卡 */ }
) )
} else { } else {
// 下班 → 不弹确认,直接提交 doPunch(nfcId)
doPunch(punchType, beaconMacs)
} }
} }
} }
/** 执行打卡 API 调用 */ /** 执行 NFC 打卡 API */
private fun doPunch(punchType: Int, beaconMacs: List<String>) { private fun doPunch(nfcId: String) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val params = hashMapOf<String, Any>( val params = hashMapOf<String, Any>(
"beaconMacs" to beaconMacs, "nfcId" to nfcId,
"punchType" to punchType "punchType" to currentPunchType
) )
val result = safeApiCall { punchApi.onAndOffPunch(params) } val result = safeApiCall { punchApi.nfcOnAndOffPunch(params) }
when (result) { when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
Timber.d("考勤: 打卡成功 punchType=$punchType") Timber.d("考勤: NFC 打卡成功 punchType=$currentPunchType")
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.SUCCESS, status = QuTipDialog.Status.SUCCESS,
title = "打卡成功", title = "打卡成功",
@@ -202,17 +213,14 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
) )
// 副作用:更新工作状态和屏幕亮度 // 副作用:更新工作状态和屏幕亮度
if (punchType == 0) { if (currentPunchType == 0) {
// 上班 → 屏幕正常亮度
screenController.turnOn() screenController.turnOn()
emitWorkState(true) emitWorkState(true)
} else { } else {
// 下班 → 低耗电(熄屏)
screenController.turnOff() screenController.turnOff()
emitWorkState(false) emitWorkState(false)
} }
// 刷新考勤状态
fetchAttendance() fetchAttendance()
} }
is ApiResult.Error -> { is ApiResult.Error -> {
@@ -245,13 +253,11 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
when (result) { when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
Timber.d("考勤: 撤销成功")
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.SUCCESS, status = QuTipDialog.Status.SUCCESS,
title = "撤销成功", title = "撤销成功",
back = true, step = 0, countdown = 2 back = true, step = 0, countdown = 2
) )
// 恢复上班状态
screenController.turnOn() screenController.turnOn()
emitWorkState(true) emitWorkState(true)
fetchAttendance() fetchAttendance()
@@ -277,15 +283,7 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
) )
} }
// ===== 辅助方法 ===== // ===== 辅助 =====
/**
* 模拟蓝牙信标 MAC 列表
* TODO: 蓝牙扫描模块完成后,改为从 store.beacons 读取
*/
private fun getMockBeaconMacs(): List<String> {
return listOf("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66")
}
/** 发送工作状态变更事件 */ /** 发送工作状态变更事件 */
private fun emitWorkState(isWorking: Boolean) { private fun emitWorkState(isWorking: Boolean) {