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:
@@ -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")
|
||||||
|
|||||||
@@ -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. 显示蓝牙识别 Loading(1.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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user