From f80a1bae787fcbb6328ee6584f84fad4f08d6154 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 4 Nov 2025 09:41:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(background-task):=20=E5=AE=9E=E7=8E=B0iOS?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1V2?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=B9=B6=E9=87=8D=E6=9E=84=E9=94=BB=E7=82=BC?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=B6=88=E6=81=AF=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增iOS原生BackgroundTaskBridge桥接模块,支持后台任务注册、调度和完成 - 重构BackgroundTaskManager为V2版本,集成原生iOS后台任务能力 - 在AppDelegate中注册后台任务处理器,确保应用启动时正确初始化 - 重构锻炼通知消息生成逻辑,使用配置化模板提升可维护性 - 扩展健康数据类型映射,支持更多运动项目的中文显示 - 替换原有backgroundTaskManager引用为backgroundTaskManagerV2 --- app/(tabs)/statistics.tsx | 2 +- app/_layout.tsx | 2 +- ios/OutLive.xcodeproj/project.pbxproj | 8 + ios/OutLive/AppDelegate.swift | 53 +++ ios/OutLive/BackgroundTaskBridge.m | 31 ++ ios/OutLive/BackgroundTaskBridge.swift | 420 ++++++++++++++++++++ services/backgroundTaskDebugger.ts | 4 +- services/backgroundTaskManagerV2.ts | 522 +++++++++++++++++++++++++ services/workoutNotificationService.ts | 254 ++++++++---- utils/health.ts | 101 ++++- 10 files changed, 1319 insertions(+), 78 deletions(-) create mode 100644 ios/OutLive/BackgroundTaskBridge.m create mode 100644 ios/OutLive/BackgroundTaskBridge.swift create mode 100644 services/backgroundTaskManagerV2.ts diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 81d0dce..d9f6241 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -14,7 +14,7 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; -import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; +import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2'; import { setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchTodayWaterStats } from '@/store/waterSlice'; diff --git a/app/_layout.tsx b/app/_layout.tsx index 0a2b0e6..e312b1f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -29,7 +29,7 @@ import { MembershipModalProvider } from '@/contexts/MembershipModalContext'; import { ToastProvider } from '@/contexts/ToastContext'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { STORAGE_KEYS } from '@/services/api'; -import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; +import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2'; import { fetchChallenges } from '@/store/challengesSlice'; import AsyncStorage from '@/utils/kvStore'; import { logger } from '@/utils/logger'; diff --git a/ios/OutLive.xcodeproj/project.pbxproj b/ios/OutLive.xcodeproj/project.pbxproj index 18f8fe0..bee4710 100644 --- a/ios/OutLive.xcodeproj/project.pbxproj +++ b/ios/OutLive.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; }; 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; }; 79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; }; + B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */; }; + B6B9273D2FD4F4A800C6391C /* BackgroundTaskBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */; }; 91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; }; AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; @@ -35,6 +37,8 @@ 79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = ""; }; 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = ""; }; 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = ""; }; + B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundTaskBridge.swift; path = OutLive/BackgroundTaskBridge.swift; sourceTree = ""; }; + B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BackgroundTaskBridge.m; path = OutLive/BackgroundTaskBridge.m; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = ""; }; B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; @@ -102,6 +106,8 @@ 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */, 79B2CB712E7B954F00B51753 /* HealthKitManager.m */, 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */, + B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */, + B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */, 13B07FAE1A68108700A75B9A /* OutLive */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 83CBBA001A601CBA00E9B192 /* Products */, @@ -369,6 +375,8 @@ 792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */, 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */, 79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */, + B6B9273D2FD4F4A800C6391C /* BackgroundTaskBridge.m in Sources */, + B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */, 79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */, F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, 32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */, diff --git a/ios/OutLive/AppDelegate.swift b/ios/OutLive/AppDelegate.swift index a7887e1..02a251f 100644 --- a/ios/OutLive/AppDelegate.swift +++ b/ios/OutLive/AppDelegate.swift @@ -1,6 +1,7 @@ import Expo import React import ReactAppDependencyProvider +import BackgroundTasks @UIApplicationMain public class AppDelegate: ExpoAppDelegate { @@ -13,6 +14,11 @@ public class AppDelegate: ExpoAppDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // 在应用启动完成前注册后台任务 + if #available(iOS 13.0, *) { + registerBackgroundTasks() + } + let delegate = ReactNativeDelegate() let factory = ExpoReactNativeFactory(delegate: delegate) delegate.dependencyProvider = RCTAppDependencyProvider() @@ -31,6 +37,53 @@ public class AppDelegate: ExpoAppDelegate { return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + // MARK: - Background Task Registration + + @available(iOS 13.0, *) + private func registerBackgroundTasks() { + let identifier = "com.anonymous.digitalpilates.task" + + // 注册后台任务处理器 + // 必须在应用启动完成前注册,否则会崩溃 + BGTaskScheduler.shared.register( + forTaskWithIdentifier: identifier, + using: nil + ) { [weak self] task in + // 尝试通知 BackgroundTaskBridge 处理任务 + // 如果 bridge 不可用,标记任务完成 + self?.handleBackgroundTask(task, identifier: identifier) + } + + NSLog("[AppDelegate] 后台任务已在应用启动时注册: \(identifier)") + } + + @available(iOS 13.0, *) + private func handleBackgroundTask(_ task: BGTask, identifier: String) { + // 尝试获取 BackgroundTaskBridge 实例来处理任务 + // 如果 React Native bridge 还未初始化,则直接完成任务 + guard let bridge = reactNativeFactory?.bridge, + bridge.isValid else { + NSLog("[AppDelegate] React Native bridge 未就绪,直接完成后台任务") + task.setTaskCompleted(success: false) + return + } + + // 通过 bridge 查找 BackgroundTaskBridge 模块 + DispatchQueue.main.async { + if let module = bridge.module(for: BackgroundTaskBridge.self) as? BackgroundTaskBridge { + // 通知 BackgroundTaskBridge 处理任务 + NotificationCenter.default.post( + name: NSNotification.Name("BackgroundTaskBridge.handleTask"), + object: nil, + userInfo: ["task": task, "identifier": identifier] + ) + } else { + NSLog("[AppDelegate] BackgroundTaskBridge 模块未找到,完成后台任务") + task.setTaskCompleted(success: false) + } + } + } // Linking API public override func application( diff --git a/ios/OutLive/BackgroundTaskBridge.m b/ios/OutLive/BackgroundTaskBridge.m new file mode 100644 index 0000000..5341530 --- /dev/null +++ b/ios/OutLive/BackgroundTaskBridge.m @@ -0,0 +1,31 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(BackgroundTaskBridge, RCTEventEmitter) + +RCT_EXTERN_METHOD(configure:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(schedule:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(complete:(NSNumber *)success + rescheduleAfter:(NSNumber *_Nullable)rescheduleAfter + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(cancelAll:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(getPendingRequests:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(backgroundRefreshStatus:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(simulateLaunch:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +@end diff --git a/ios/OutLive/BackgroundTaskBridge.swift b/ios/OutLive/BackgroundTaskBridge.swift new file mode 100644 index 0000000..1032826 --- /dev/null +++ b/ios/OutLive/BackgroundTaskBridge.swift @@ -0,0 +1,420 @@ +// +// BackgroundTaskBridge.swift +// OutLive +// +// Native bridge responsible for scheduling and relaying iOS background tasks to the JS runtime. +// + +import BackgroundTasks +import Foundation +import React +import UIKit + +@objc(BackgroundTaskBridge) +class BackgroundTaskBridge: RCTEventEmitter { + + private enum TaskKind: String { + case processing + case refresh + } + + private let queue = DispatchQueue(label: "com.anonymous.digitalpilates.background.bridge", qos: .utility) + + private var hasListeners = false + private var identifier: String? + private var kind: TaskKind = .processing + private var requiresNetworkConnectivity = false + private var requiresExternalPower = false + private var defaultDelay: TimeInterval = 60 * 30 // 30 minutes + private var isRegistered = false + private var currentTask: BGTask? + + override init() { + super.init() + // 监听来自 AppDelegate 的后台任务通知 + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTaskNotification(_:)), + name: NSNotification.Name("BackgroundTaskBridge.handleTask"), + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override static func requiresMainQueueSetup() -> Bool { + true + } + + override func startObserving() { + hasListeners = true + } + + override func stopObserving() { + hasListeners = false + } + + @objc + private func handleTaskNotification(_ notification: Notification) { + guard #available(iOS 13.0, *), + let userInfo = notification.userInfo, + let task = userInfo["task"] as? BGTask else { + return + } + + NSLog("[BackgroundTaskBridge] 收到来自 AppDelegate 的后台任务") + handle(task: task) + } + + override func supportedEvents() -> [String]! { + [ + "BackgroundTaskBridge.execute", + "BackgroundTaskBridge.expire" + ] + } + + @objc + func configure( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 13.0, *) else { + rejecter( + "BACKGROUND_TASK_UNAVAILABLE", + "BGTaskScheduler requires iOS 13 or later.", + nil + ) + return + } + + guard let identifier = options["identifier"] as? String, + !identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + rejecter("INVALID_IDENTIFIER", "A non-empty identifier is required.", nil) + return + } + + let taskTypeRaw = (options["taskType"] as? String)?.lowercased() + let taskKind = TaskKind(rawValue: taskTypeRaw ?? "") ?? .processing + + let requiresNetwork = (options["requiresNetworkConnectivity"] as? Bool) ?? false + let requiresPower = (options["requiresExternalPower"] as? Bool) ?? false + let defaultDelaySeconds = (options["defaultDelay"] as? NSNumber)?.doubleValue + + queue.async { [weak self] in + guard let self else { return } + + self.identifier = identifier + self.kind = taskKind + self.requiresNetworkConnectivity = requiresNetwork + self.requiresExternalPower = requiresPower + + if let delay = defaultDelaySeconds, delay > 0 { + self.defaultDelay = delay + } + + do { + try self.registerTaskIfNeeded(identifier: identifier) + try self.scheduleTask(after: self.defaultDelay) + resolver([ + "identifier": identifier, + "scheduled": true, + "defaultDelay": self.defaultDelay + ]) + } catch { + rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error) + } + } + } + + @objc + func schedule( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 13.0, *) else { + rejecter( + "BACKGROUND_TASK_UNAVAILABLE", + "BGTaskScheduler requires iOS 13 or later.", + nil + ) + return + } + + guard let identifier = identifier else { + rejecter("NOT_CONFIGURED", "Call configure() before scheduling.", nil) + return + } + + let delay = (options["delay"] as? NSNumber)?.doubleValue + let effectiveDelay = (delay?.isFinite == true && delay! >= 0) ? delay! : defaultDelay + + queue.async { [weak self] in + guard let self else { return } + + do { + try self.registerTaskIfNeeded(identifier: identifier) + try self.scheduleTask(after: effectiveDelay) + resolver([ + "identifier": identifier, + "scheduled": true, + "delay": effectiveDelay + ]) + } catch { + rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error) + } + } + } + + @objc + func complete( + _ success: NSNumber, + rescheduleAfter: NSNumber?, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 13.0, *) else { + rejecter( + "BACKGROUND_TASK_UNAVAILABLE", + "BGTaskScheduler requires iOS 13 or later.", + nil + ) + return + } + + let shouldRescheduleAfter = rescheduleAfter?.doubleValue ?? defaultDelay + let completionSuccess = success.boolValue + + queue.async { [weak self] in + guard let self else { return } + + if let task = self.currentTask { + task.setTaskCompleted(success: completionSuccess) + self.currentTask = nil + } + + do { + try self.scheduleTask(after: shouldRescheduleAfter) + resolver([ + "rescheduled": true, + "delay": shouldRescheduleAfter, + "success": completionSuccess + ]) + } catch { + rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error) + } + } + } + + @objc + func cancelAll( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 13.0, *) else { + rejecter( + "BACKGROUND_TASK_UNAVAILABLE", + "BGTaskScheduler requires iOS 13 or later.", + nil + ) + return + } + + queue.async { + BGTaskScheduler.shared.cancelAllTaskRequests() + resolver(["cancelled": true]) + } + } + + @objc + func getPendingRequests( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 13.0, *) else { + rejecter( + "BACKGROUND_TASK_UNAVAILABLE", + "BGTaskScheduler requires iOS 13 or later.", + nil + ) + return + } + + BGTaskScheduler.shared.getPendingTaskRequests { [weak self] requests in + guard let self else { return } + + let mapped = requests + .filter { request in + guard let identifier = self.identifier else { + return true + } + return request.identifier == identifier + } + .map { request -> [String: Any] in + var payload: [String: Any] = [ + "identifier": request.identifier + ] + + if let date = request.earliestBeginDate { + payload["earliestBegin"] = date.timeIntervalSince1970 * 1000 + } else { + payload["earliestBegin"] = NSNull() + } + + if let processingRequest = request as? BGProcessingTaskRequest { + payload["requiresNetworkConnectivity"] = processingRequest.requiresNetworkConnectivity + payload["requiresExternalPower"] = processingRequest.requiresExternalPower + payload["type"] = "processing" + } else { + payload["type"] = "refresh" + } + + return payload + } + + resolver(mapped) + } + } + + @objc + func backgroundRefreshStatus( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.backgroundRefreshStatus(resolver, rejecter: rejecter) + } + return + } + + let status = UIApplication.shared.backgroundRefreshStatus + switch status { + case .available: + resolver("available") + case .denied: + resolver("denied") + case .restricted: + resolver("restricted") + @unknown default: + resolver("unknown") + } + } + + @objc + func simulateLaunch( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard hasListeners else { + rejecter("NO_LISTENERS", "No JS listeners registered for background events.", nil) + return + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.sendEvent( + withName: "BackgroundTaskBridge.execute", + body: [ + "identifier": self.identifier ?? "simulated", + "timestamp": Date().timeIntervalSince1970 * 1000, + "simulated": true + ] + ) + resolver(["simulated": true]) + } + } + + // MARK: - Private helpers + + @available(iOS 13.0, *) + private func registerTaskIfNeeded(identifier: String) throws { + guard !isRegistered else { return } + + // 注意:任务标识符已在 AppDelegate 中注册 + // BGTaskScheduler 要求所有任务必须在应用启动完成前注册 + // 这里只标记为已注册,实际的任务处理将通过 AppDelegate 中的注册生效 + isRegistered = true + NSLog("[BackgroundTaskBridge] 使用 AppDelegate 中预注册的后台任务: \(identifier)") + } + + @available(iOS 13.0, *) + private func scheduleTask(after delay: TimeInterval) throws { + guard let identifier else { + throw NSError( + domain: "BackgroundTaskBridge", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Identifier is missing. Configure the bridge first."] + ) + } + + let request: BGTaskRequest + + switch kind { + case .processing: + let processing = BGProcessingTaskRequest(identifier: identifier) + processing.requiresNetworkConnectivity = requiresNetworkConnectivity + processing.requiresExternalPower = requiresExternalPower + processing.earliestBeginDate = Date(timeIntervalSinceNow: delay) + request = processing + case .refresh: + let refresh = BGAppRefreshTaskRequest(identifier: identifier) + refresh.earliestBeginDate = Date(timeIntervalSinceNow: delay) + request = refresh + } + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + throw error + } + } + + @available(iOS 13.0, *) + private func handle(task: BGTask) { + queue.async { [weak self] in + guard let self else { return } + + self.currentTask = task + + task.expirationHandler = { [weak self] in + guard let self else { return } + self.queue.async { + self.currentTask = nil + } + + guard self.hasListeners else { return } + + DispatchQueue.main.async { + self.sendEvent( + withName: "BackgroundTaskBridge.expire", + body: [ + "identifier": self.identifier ?? "", + "timestamp": Date().timeIntervalSince1970 * 1000 + ] + ) + } + } + + guard self.hasListeners else { + task.setTaskCompleted(success: false) + self.currentTask = nil + return + } + + DispatchQueue.main.async { + self.sendEvent( + withName: "BackgroundTaskBridge.execute", + body: [ + "identifier": self.identifier ?? "", + "timestamp": Date().timeIntervalSince1970 * 1000 + ] + ) + } + } + } +} diff --git a/services/backgroundTaskDebugger.ts b/services/backgroundTaskDebugger.ts index 2155639..4b0afeb 100644 --- a/services/backgroundTaskDebugger.ts +++ b/services/backgroundTaskDebugger.ts @@ -1,5 +1,5 @@ import AsyncStorage from '@/utils/kvStore'; -import { BackgroundTaskManager } from './backgroundTaskManager'; +import { BackgroundTaskManager } from './backgroundTaskManagerV2'; /** * 后台任务调试工具 @@ -174,4 +174,4 @@ export class BackgroundTaskDebugger { } } -export const backgroundTaskDebugger = BackgroundTaskDebugger.getInstance(); \ No newline at end of file +export const backgroundTaskDebugger = BackgroundTaskDebugger.getInstance(); diff --git a/services/backgroundTaskManagerV2.ts b/services/backgroundTaskManagerV2.ts new file mode 100644 index 0000000..a34ec08 --- /dev/null +++ b/services/backgroundTaskManagerV2.ts @@ -0,0 +1,522 @@ +import { NativeEventEmitter, NativeModules, Platform, type EmitterSubscription } from 'react-native'; + +import { listChallenges } from '@/services/challengesApi'; +import { resyncFastingNotifications } from '@/services/fastingNotifications'; +import { store } from '@/store'; +import { selectActiveFastingPlan, selectActiveFastingSchedule } from '@/store/fastingSlice'; +import { getWaterIntakeFromHealthKit } from '@/utils/health'; +import AsyncStorage from '@/utils/kvStore'; +import { log } from '@/utils/logger'; +import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; +import { getWaterGoalFromStorage } from '@/utils/userPreferences'; + +import dayjs from 'dayjs'; + +export const BACKGROUND_TASK_IDENTIFIER = 'com.anonymous.digitalpilates.task'; +const DEFAULT_RESCHEDULE_INTERVAL_SECONDS = 60 * 30; // 30 minutes +const BACKGROUND_EVENT = 'BackgroundTaskBridge.execute'; +const EXPIRATION_EVENT = 'BackgroundTaskBridge.expire'; + +const NativeBackgroundModule = NativeModules.BackgroundTaskBridge; + + +const isIosBackgroundModuleAvailable = Platform.OS === 'ios' && NativeBackgroundModule; + +// 检查通知权限 +async function checkNotificationPermissions(): Promise { + try { + const Notifications = await import('expo-notifications'); + const { status } = await Notifications.getPermissionsAsync(); + return status === 'granted'; + } catch (error) { + console.error('检查通知权限失败:', error); + return false; + } +} + +// 执行喝水提醒后台任务 +async function executeWaterReminderTask(): Promise { + try { + console.log('执行喝水提醒后台任务...'); + + let state; + try { + state = store.getState(); + } catch (error) { + console.log('无法获取 Redux state,使用本地存储:', error); + const dailyGoal = await getWaterGoalFromStorage(); + if (!dailyGoal || dailyGoal <= 0) { + console.log('没有设置喝水目标,跳过喝水提醒'); + return; + } + await sendSimpleWaterReminder(); + return; + } + + const waterStats = state.water?.todayStats; + const userProfile = state.user?.profile; + + let dailyGoal = waterStats?.dailyGoal ?? 0; + if (!dailyGoal || dailyGoal <= 0) { + dailyGoal = await getWaterGoalFromStorage(); + } + + if (!dailyGoal || dailyGoal <= 0) { + console.log('没有设置喝水目标,跳过喝水提醒'); + return; + } + + const currentHour = new Date().getHours(); + const userName = userProfile?.name || '朋友'; + + const todayRange = { + startDate: dayjs().startOf('day').toDate().toISOString(), + endDate: dayjs().endOf('day').toDate().toISOString() + }; + + let totalAmount = waterStats?.totalAmount ?? 0; + let completionRate = waterStats?.completionRate ?? (dailyGoal > 0 ? (totalAmount / dailyGoal) * 100 : 0); + + try { + const healthKitRecords = await getWaterIntakeFromHealthKit(todayRange); + if (Array.isArray(healthKitRecords) && healthKitRecords.length > 0) { + totalAmount = healthKitRecords.reduce((sum: number, record: unknown) => { + if (record && typeof record === 'object' && 'value' in record) { + const { value } = record as { value?: number | string }; + const numericValue = Number(value ?? 0); + return Number.isFinite(numericValue) ? sum + numericValue : sum; + } + return sum; + }, 0); + completionRate = Math.min((totalAmount / dailyGoal) * 100, 100); + } else { + console.log('HealthKit 未返回今日饮水记录,使用应用内缓存数据'); + } + } catch (healthKitError) { + console.error('从HealthKit获取饮水记录失败,使用应用内缓存数据:', healthKitError); + } + + const todayWaterStats = { + totalAmount, + dailyGoal, + completionRate: Number.isFinite(completionRate) ? completionRate : 0 + }; + + const notificationSent = await WaterNotificationHelpers.checkWaterGoalAndNotify( + userName, + todayWaterStats, + currentHour + ); + + if (notificationSent) { + console.log('后台喝水提醒通知已发送'); + await AsyncStorage.setItem('@last_background_water_check', Date.now().toString()); + } else { + console.log('无需发送后台喝水提醒通知'); + } + } catch (error) { + console.error('执行喝水提醒后台任务失败:', error); + } +} + +async function executeChallengeReminderTask(): Promise { + try { + console.log('执行挑战鼓励提醒后台任务...'); + + let userName = '朋友'; + try { + const state = store.getState(); + const normalizedUserName = state.user?.profile?.name?.trim(); + userName = normalizedUserName && normalizedUserName.length > 0 ? normalizedUserName : '朋友'; + } catch (error) { + console.log('无法获取用户名,使用默认值:', error); + } + + const challenges = await listChallenges(); + const joinedChallenges = challenges.filter((challenge) => challenge.isJoined && challenge.progress); + + if (!joinedChallenges.length) { + console.log('没有加入的挑战或挑战没有进度,跳过挑战提醒'); + return; + } + + const todayKey = new Date().toISOString().slice(0, 10); + + const eligibleChallenges = []; + for (const challenge of joinedChallenges) { + const progress = challenge.progress; + if (!progress || progress.checkedInToday) { + continue; + } + + const storageKey = `@challenge_encouragement_sent:${challenge.id}`; + const lastSent = await AsyncStorage.getItem(storageKey); + if (lastSent === todayKey) { + continue; + } + + eligibleChallenges.push(challenge); + } + + if (eligibleChallenges.length > 0) { + const randomIndex = Math.floor(Math.random() * eligibleChallenges.length); + const selectedChallenge = eligibleChallenges[randomIndex]; + + try { + await ChallengeNotificationHelpers.sendEncouragementNotification({ + userName, + challengeTitle: selectedChallenge.title, + challengeId: selectedChallenge.id, + }); + + const storageKey = `@challenge_encouragement_sent:${selectedChallenge.id}`; + await AsyncStorage.setItem(storageKey, todayKey); + + console.log(`已随机选择并发送挑战鼓励通知: ${selectedChallenge.title}`); + } catch (notificationError) { + console.error('发送挑战鼓励通知失败:', notificationError); + } + } else { + console.log('没有符合条件的挑战需要发送鼓励通知'); + } + + console.log('挑战鼓励提醒后台任务完成'); + } catch (error) { + console.error('执行挑战鼓励提醒后台任务失败:', error); + } +} + +// 执行断食通知后台任务 +async function executeFastingNotificationTask(): Promise { + try { + console.log('执行断食通知后台任务...'); + + let state; + try { + state = store.getState(); + } catch (error) { + console.log('无法获取 Redux state,跳过断食通知任务:', error); + return; + } + + const activeSchedule = selectActiveFastingSchedule(state); + const activePlan = selectActiveFastingPlan(state); + + if (!activeSchedule || !activePlan) { + console.log('没有激活的断食计划,跳过断食通知任务'); + return; + } + + const end = dayjs(activeSchedule.endISO); + if (end.isBefore(dayjs())) { + console.log('断食计划已结束,跳过断食通知任务'); + return; + } + + console.log('正在同步断食通知...', { + planId: activePlan.id, + start: activeSchedule.startISO, + end: activeSchedule.endISO, + }); + + await resyncFastingNotifications({ + schedule: activeSchedule, + plan: activePlan, + enabled: true, + }); + + console.log('断食通知后台同步完成'); + } catch (error) { + console.error('执行断食通知后台任务失败:', error); + } +} + +// 发送测试通知以验证后台任务执行 +async function sendTestNotification(): Promise { + try { + console.log('发送后台任务测试通知...'); + + const Notifications = await import('expo-notifications'); + + await Notifications.scheduleNotificationAsync({ + content: { + title: '后台任务测试', + body: `后台任务正在执行中... 时间: ${new Date().toLocaleTimeString()}`, + data: { + type: 'background_task_test', + timestamp: Date.now() + } + }, + trigger: null, + }); + + console.log('后台任务测试通知发送成功'); + await AsyncStorage.setItem('@last_background_test_notification', Date.now().toString()); + } catch (error) { + console.error('发送测试通知失败:', error); + } +} + +async function sendSimpleWaterReminder(): Promise { + try { + const userName = '朋友'; + const Notifications = await import('expo-notifications'); + + const notificationId = await Notifications.scheduleNotificationAsync({ + content: { + title: '💧 该喝水啦!', + body: `${userName},记得补充水分,保持身体健康~`, + data: { + type: 'water_reminder', + url: '/statistics' + }, + sound: 'default', + }, + trigger: null, + }); + + console.log('简单喝水提醒已发送,ID:', notificationId); + } catch (error) { + console.error('发送简单喝水提醒失败:', error); + } +} + +async function executeBackgroundTasks(): Promise { + console.log('开始执行后台任务...'); + + try { + const hasPermission = await checkNotificationPermissions(); + if (!hasPermission) { + console.log('没有通知权限,跳过后台任务'); + return; + } + + try { + const state = store.getState(); + if (!state) { + console.log('Redux store 未初始化,跳过后台任务'); + return; + } + } catch (error) { + console.log('无法访问 Redux store,跳过后台任务:', error); + return; + } + + const testNotificationsEnabled = await AsyncStorage.getItem('@background_test_notifications_enabled') === 'true'; + if (testNotificationsEnabled) { + await sendTestNotification(); + } + + await executeWaterReminderTask(); + await executeChallengeReminderTask(); + await executeFastingNotificationTask(); + + console.log('后台任务执行完成'); + } catch (error) { + console.error('执行后台任务失败:', error); + throw error; + } +} + +export class BackgroundTaskManagerV2 { + private static instance: BackgroundTaskManagerV2; + + private isInitialized = false; + private executingPromise: Promise | null = null; + private eventSubscription?: EmitterSubscription; + private expirationSubscription?: EmitterSubscription; + + static getInstance(): BackgroundTaskManagerV2 { + if (!BackgroundTaskManagerV2.instance) { + BackgroundTaskManagerV2.instance = new BackgroundTaskManagerV2(); + } + return BackgroundTaskManagerV2.instance; + } + + async initialize(): Promise { + if (this.isInitialized) { + return; + } + + if (!isIosBackgroundModuleAvailable) { + log.warn('[BackgroundTaskManagerV2] iOS 原生后台模块不可用,跳过初始化'); + this.isInitialized = false; + return; + } + + const emitter = new NativeEventEmitter(NativeBackgroundModule); + + this.eventSubscription = emitter.addListener(BACKGROUND_EVENT, (payload) => { + log.info('[BackgroundTaskManagerV2] 收到后台任务事件', payload); + this.handleBackgroundExecution(); + }); + + this.expirationSubscription = emitter.addListener(EXPIRATION_EVENT, (payload) => { + log.warn('[BackgroundTaskManagerV2] 后台任务在完成前即将过期', payload); + }); + + try { + await NativeBackgroundModule.configure({ + identifier: BACKGROUND_TASK_IDENTIFIER, + taskType: 'processing', + requiresNetworkConnectivity: false, + requiresExternalPower: false, + defaultDelay: DEFAULT_RESCHEDULE_INTERVAL_SECONDS, + }); + this.isInitialized = true; + log.info('[BackgroundTaskManagerV2] 已初始化并注册 iOS 后台任务'); + } catch (error: any) { + // BGTaskSchedulerErrorDomain 错误码 1 表示后台任务功能不可用 + // 这在模拟器上是正常的,因为模拟器不完全支持后台任务 + const errorMessage = error?.message || String(error); + const isBGTaskUnavailable = errorMessage.includes('BGTaskSchedulerErrorDomain') && + errorMessage.includes('错误1'); + + if (isBGTaskUnavailable) { + log.warn('[BackgroundTaskManagerV2] 后台任务功能在当前环境不可用(模拟器限制),将在真机上正常工作'); + this.removeListeners(); + this.isInitialized = false; + // 不抛出错误,因为这是预期行为 + return; + } + + log.error('[BackgroundTaskManagerV2] 初始化失败', error); + this.removeListeners(); + throw error; + } + } + + private async handleBackgroundExecution(): Promise { + if (this.executingPromise) { + log.info('[BackgroundTaskManagerV2] 已有后台任务在执行,忽略重复触发'); + return; + } + + this.executingPromise = executeBackgroundTasks() + .then(async () => { + if (isIosBackgroundModuleAvailable) { + try { + await NativeBackgroundModule.complete(true, DEFAULT_RESCHEDULE_INTERVAL_SECONDS); + } catch (error) { + log.error('[BackgroundTaskManagerV2] 标记后台任务成功完成失败', error); + } + } + }) + .catch(async (error) => { + log.error('[BackgroundTaskManagerV2] 后台任务执行失败', error); + if (isIosBackgroundModuleAvailable) { + try { + await NativeBackgroundModule.complete(false, DEFAULT_RESCHEDULE_INTERVAL_SECONDS); + } catch (completionError) { + log.error('[BackgroundTaskManagerV2] 标记后台任务失败状态时出错', completionError); + } + } + }) + .finally(() => { + this.executingPromise = null; + }); + + await this.executingPromise; + } + + async stop(): Promise { + if (!isIosBackgroundModuleAvailable) { + return; + } + + try { + await NativeBackgroundModule.cancelAll(); + } catch (error) { + log.error('[BackgroundTaskManagerV2] 停止后台任务失败', error); + } finally { + this.removeListeners(); + this.isInitialized = false; + } + } + + private removeListeners(): void { + this.eventSubscription?.remove(); + this.expirationSubscription?.remove(); + this.eventSubscription = undefined; + this.expirationSubscription = undefined; + } + + async getStatus(): Promise { + if (!isIosBackgroundModuleAvailable) { + return Platform.OS; + } + + try { + const status = await NativeBackgroundModule.backgroundRefreshStatus(); + return status; + } catch (error) { + log.error('[BackgroundTaskManagerV2] 获取后台任务状态失败', error); + return 'unknown'; + } + } + + async checkStatus(): Promise { + const status = await this.getStatus(); + switch (status) { + case 'available': + return '可用'; + case 'restricted': + return '受限制'; + case 'denied': + return '被拒绝'; + default: + return '未知'; + } + } + + async triggerTaskForTesting(): Promise { + if (!isIosBackgroundModuleAvailable) { + await executeBackgroundTasks(); + return; + } + + try { + await NativeBackgroundModule.simulateLaunch(); + } catch (error) { + log.error('[BackgroundTaskManagerV2] 模拟后台任务触发失败', error); + throw error; + } + } + + async testBackgroundTask(): Promise { + await this.triggerTaskForTesting(); + } + + async getLastBackgroundCheckTime(): Promise { + try { + const lastCheck = await AsyncStorage.getItem('@last_background_water_check'); + return lastCheck ? parseInt(lastCheck, 10) : null; + } catch (error) { + console.error('获取最后后台检查时间失败:', error); + return null; + } + } + + async getPendingRequests(): Promise { + if (!isIosBackgroundModuleAvailable) { + return []; + } + + try { + const requests = await NativeBackgroundModule.getPendingRequests(); + return Array.isArray(requests) ? requests : []; + } catch (error) { + log.error('[BackgroundTaskManagerV2] 获取待处理的后台任务请求失败', error); + return []; + } + } +} + +export type BackgroundTaskEvent = { + taskId: string; + timestamp: number; + success: boolean; + error?: string; +}; + +export const BackgroundTaskManager = BackgroundTaskManagerV2; diff --git a/services/workoutNotificationService.ts b/services/workoutNotificationService.ts index ba0d3ab..2ff9eed 100644 --- a/services/workoutNotificationService.ts +++ b/services/workoutNotificationService.ts @@ -72,88 +72,198 @@ export async function analyzeWorkoutAndSendNotification(workout: WorkoutData): P } } +interface WorkoutMessageConfig { + emoji: string; + titleTemplate: string; + bodyTemplate: (params: { + workoutType: string; + durationMinutes: number; + calories: number; + distanceKm?: string; + averageHeartRate?: number; + mets?: number; + }) => string; + encouragement: string; + dataExtractor?: (workout: WorkoutData, metrics: any) => Record; +} + +const WORKOUT_MESSAGES: Record = { + running: { + emoji: '🏃‍♂️', + titleTemplate: '跑步完成!', + bodyTemplate: ({ durationMinutes, calories, averageHeartRate }) => { + let body = `您完成了${durationMinutes}分钟的跑步`; + if (calories > 0) { + body += `,消耗${calories}千卡`; + } + if (averageHeartRate) { + body += `(平均心率${averageHeartRate}次/分)`; + } + return body + '!'; + }, + encouragement: '坚持就是胜利!💪', + dataExtractor: (workout, metrics) => ({ + heartRateContext: metrics.averageHeartRate ? 'provided' : 'none' + }) + }, + cycling: { + emoji: '🚴‍♂️', + titleTemplate: '骑行完成!', + bodyTemplate: ({ durationMinutes, calories, distanceKm }) => { + let body = `${durationMinutes}分钟骑行`; + if (distanceKm) { + body += `,行程${distanceKm}公里`; + } + if (calories > 0) { + body += `,消耗${calories}千卡`; + } + return body + '!'; + }, + encouragement: '追寻风的自由!🌟' + }, + swimming: { + emoji: '🏊‍♂️', + titleTemplate: '游泳完成!', + bodyTemplate: ({ durationMinutes }) => { + return `水中${durationMinutes}分钟的锻炼完成!`; + }, + encouragement: '全身肌肉都得到了锻炼!💦' + }, + yoga: { + emoji: '🧘‍♀️', + titleTemplate: '瑜伽完成!', + bodyTemplate: ({ durationMinutes }) => { + return `${durationMinutes}分钟的瑜伽练习`; + }, + encouragement: '身心合一,平静致远!🌸' + }, + functionalstrengthtraining: { + emoji: '💪', + titleTemplate: '力量训练完成!', + bodyTemplate: ({ durationMinutes, calories, mets }) => { + let body = `${durationMinutes}分钟力量训练`; + if (calories > 0) { + body += `,消耗${calories}千卡`; + } + if (mets && mets > 6) { + body += '(高强度)'; + } + return body + '!'; + }, + encouragement: '肌肉正在变得更强壮!🔥', + dataExtractor: (workout, metrics) => ({ + strengthLevel: metrics?.mets && metrics.mets > 6 ? 'high' : 'moderate' + }) + }, + traditionalstrengthtraining: { + emoji: '💪', + titleTemplate: '力量训练完成!', + bodyTemplate: ({ durationMinutes, calories, mets }) => { + let body = `${durationMinutes}分钟力量训练`; + if (calories > 0) { + body += `,消耗${calories}千卡`; + } + if (mets && mets > 6) { + body += '(高强度)'; + } + return body + '!'; + }, + encouragement: '肌肉正在变得更强壮!🔥', + dataExtractor: (workout, metrics) => ({ + strengthLevel: metrics?.mets && metrics.mets > 6 ? 'high' : 'moderate' + }) + }, + highintensityintervaltraining: { + emoji: '🔥', + titleTemplate: 'HIIT训练完成!', + bodyTemplate: ({ durationMinutes, calories }) => { + let body = `${durationMinutes}分钟高强度间歇训练`; + if (calories > 0) { + body += `,消耗${calories}千卡`; + } + return body + '!'; + }, + encouragement: '心肺功能显著提升!⚡', + dataExtractor: (workout, metrics) => ({ + hiitCompleted: true + }) + } +}; + +function getWorkoutMessage(workoutTypeString?: string): WorkoutMessageConfig | null { + if (!workoutTypeString) return null; + + const normalizedType = workoutTypeString.toLowerCase(); + if (normalizedType.includes('strength')) { + return WORKOUT_MESSAGES.traditionalstrengthtraining; + } + + return WORKOUT_MESSAGES[normalizedType] || null; +} + function generateEncouragementMessage( workout: WorkoutData, metrics: any ): WorkoutEncouragementMessage { - const workoutType = getWorkoutTypeDisplayName(workout.workoutActivityTypeString); - const durationMinutes = Math.round(workout.duration / 60); - const calories = workout.totalEnergyBurned ? Math.round(workout.totalEnergyBurned) : 0; - - // 基于锻炼类型和指标生成个性化消息 - let title = '锻炼完成!'; - let body = ''; - let data: Record = {}; - - switch (workout.workoutActivityTypeString?.toLowerCase()) { - case 'running': - title = '🏃‍♂️ 跑步完成!'; - body = `太棒了!您刚刚完成了${durationMinutes}分钟的跑步,消耗了约${calories}千卡热量。`; - if (metrics.averageHeartRate) { - body += `平均心率${metrics.averageHeartRate}次/分。`; - } - body += '坚持运动让身体更健康!💪'; - break; - - case 'cycling': - title = '🚴‍♂️ 骑行完成!'; - body = `骑行${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`; - if (workout.totalDistance) { - const distanceKm = (workout.totalDistance / 1000).toFixed(2); - body += `骑行距离${distanceKm}公里。`; - } - body += '享受骑行的自由吧!🌟'; - break; - - case 'swimming': - title = '🏊‍♂️ 游泳完成!'; - body = `游泳${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`; - body += '全身运动效果极佳,继续保持!💦'; - break; - - case 'yoga': - title = '🧘‍♀️ 瑜伽完成!'; - body = `${durationMinutes}分钟的瑜伽练习完成!提升了柔韧性和内心平静。`; - body += '继续保持这份宁静!🌸'; - break; - - case 'functionalstrengthtraining': - case 'traditionalstrengthtraining': - title = '💪 力量训练完成!'; - body = `力量训练${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`; - if (metrics.mets && metrics.mets > 6) { - body += '高强度训练,效果显著!🔥'; - } - body += '肌肉正在变得更强壮!'; - break; - - case 'highintensityintervaltraining': - title = '🔥 HIIT训练完成!'; - body = `高强度间歇训练${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`; - body += '心肺功能得到有效提升,您的努力值得称赞!⚡'; - break; - - default: - title = '🎯 锻炼完成!'; - body = `${workoutType}${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`; - body += '坚持运动,健康生活!🌟'; - break; + if (!workout) { + return { + title: '锻炼完成!', + body: '恭喜您完成锻炼!', + data: {} + }; } - // 添加心率区间分析(如果有心率数据) - if (metrics.heartRateZones && metrics.heartRateZones.length > 0) { - const dominantZone = metrics.heartRateZones.reduce((prev: any, current: any) => - current.durationMinutes > prev.durationMinutes ? current : prev - ); + const workoutType = getWorkoutTypeDisplayName(workout.workoutActivityTypeString) || '锻炼'; + const durationMinutes = workout.duration ? Math.round(workout.duration / 60) : 0; + const calories = workout.totalEnergyBurned ? Math.round(workout.totalEnergyBurned) : 0; + const distanceKm = workout.totalDistance && workout.totalDistance > 0 + ? (workout.totalDistance / 1000).toFixed(2) + : undefined; - if (dominantZone.durationMinutes > 5) { - data.heartRateZone = dominantZone.key; - data.heartRateZoneLabel = dominantZone.label; + const messageConfig = getWorkoutMessage(workout.workoutActivityTypeString); + let title = '🎯 锻炼完成!'; + let body = ''; + const data: Record = {}; + + if (messageConfig) { + title = `${messageConfig.emoji} ${messageConfig.titleTemplate}`; + body = messageConfig.bodyTemplate({ + workoutType, + durationMinutes, + calories, + distanceKm, + averageHeartRate: metrics?.averageHeartRate, + mets: metrics?.mets + }); + body += messageConfig.encouragement; + + if (messageConfig.dataExtractor) { + Object.assign(data, messageConfig.dataExtractor(workout, metrics)); + } + } else { + body = `${workoutType} ${durationMinutes}分钟完成!`; + if (calories > 0) { + body += `消耗${calories}千卡热量。`; + } + body += '坚持运动,收获健康!🌟'; + } + + if (metrics?.heartRateZones && Array.isArray(metrics.heartRateZones) && metrics.heartRateZones.length > 0) { + try { + const dominantZone = metrics.heartRateZones.reduce((prev: any, current: any) => + current?.durationMinutes > prev?.durationMinutes ? current : prev + ); + + if (dominantZone?.durationMinutes > 5) { + data.heartRateZone = dominantZone.key; + data.heartRateZoneLabel = dominantZone.label; + } + } catch (error) { + console.warn('心率区间分析失败:', error); } } - // 添加锻炼强度评估 - if (metrics.mets) { + if (metrics?.mets) { data.intensity = metrics.mets < 3 ? 'low' : metrics.mets < 6 ? 'moderate' : 'high'; } diff --git a/utils/health.ts b/utils/health.ts index 9f150d9..050f719 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -1647,6 +1647,7 @@ const WORKOUT_TYPE_LABELS: Record = { cycling: '骑行', swimming: '游泳', yoga: '瑜伽', + pilates: '普拉提', functionalstrengthtraining: '功能性力量训练', traditionalstrengthtraining: '传统力量训练', crosstraining: '交叉训练', @@ -1654,7 +1655,6 @@ const WORKOUT_TYPE_LABELS: Record = { highintensityintervaltraining: '高强度间歇训练', flexibility: '柔韧性训练', cooldown: '放松运动', - pilates: '普拉提', dance: '舞蹈', danceinspiredtraining: '舞蹈训练', cardiodance: '有氧舞蹈', @@ -1664,11 +1664,72 @@ const WORKOUT_TYPE_LABELS: Record = { underwaterdiving: '水下潜水', pickleball: '匹克球', americanfootball: '美式橄榄球', + australianfootball: '澳式橄榄球', + archery: '射箭', badminton: '羽毛球', baseball: '棒球', basketball: '篮球', - tennis: '网球', + bowling: '保龄球', + boxing: '拳击', + climbing: '攀岩', + cricket: '板球', + curling: '冰壶', + elliptical: '椭圆机', + equestriansports: '马术', + fencing: '击剑', + fishing: '钓鱼', + golf: '高尔夫', + gymnastics: '体操', + handball: '手球', + hiking: '徒步', + hockey: '曲棍球', + hunting: '狩猎', + lacrosse: '长曲棍球', + martialarts: '武术', + mindandbody: '身心运动', + mixedmetaboliccardiotraining: '混合代谢有氧训练', + paddlesports: '桨类运动', + play: '自由活动', + preparationandrecovery: '准备与恢复', + racquetball: '壁球', + rowing: '划船', + rugby: '橄榄球', + sailing: '帆船', + skatingsports: '滑冰运动', + snowsports: '雪上运动', + soccer: '足球', + softball: '垒球', + squash: '壁球', + stairclimbing: '爬楼梯', + surfing: '冲浪', + surfingsports: '冲浪运动', tabletennis: '乒乓球', + tennis: '网球', + trackandfield: '田径', + volleyball: '排球', + waterfitness: '水中健身', + watersports: '水上运动', + weighttraining: '重量训练', + wrestling: '摔跤', + barre: '芭蕾杆训练', + corebTraining: '核心训练', + jumprope: '跳绳', + kickboxing: '踢拳', + taichi: '太极', + taichichuan: '太极拳', + nordicwalking: '北欧式行走', + frisbee: '飞盘', + ultimatefrisbee: '极限飞盘', + mountainbiking: '山地自行车', + roadcycling: '公路骑行', + virtualrunning: '虚拟跑步', + virtualcycling: '虚拟骑行', + trailrunning: '越野跑', + treadmillrunning: '跑步机跑步', + trackrunning: '场地跑', + openwaterswimming: '公开水域游泳', + poolswimming: '游泳池游泳', + apneadiving: '自由潜', functionalStrengthTraining: '功能性力量训练', other: '其他运动', }; @@ -1730,6 +1791,42 @@ export function getWorkoutTypeDisplayName(workoutType: WorkoutActivityType | str return '放松运动'; case WorkoutActivityType.Tennis: return '网球'; + case WorkoutActivityType.Basketball: + return '篮球'; + case WorkoutActivityType.Soccer: + return '足球'; + case WorkoutActivityType.Baseball: + return '棒球'; + case WorkoutActivityType.Volleyball: + return '排球'; + case WorkoutActivityType.Dance: + return '舞蹈'; + case WorkoutActivityType.DanceInspiredTraining: + return '舞蹈训练'; + case WorkoutActivityType.Elliptical: + return '椭圆机'; + case WorkoutActivityType.Rowing: + return '划船'; + case WorkoutActivityType.StairClimbing: + return '爬楼梯'; + case WorkoutActivityType.Hiking: + return '徒步'; + case WorkoutActivityType.Climbing: + return '攀岩'; + case WorkoutActivityType.MindAndBody: + return '身心运动'; + case WorkoutActivityType.MartialArts: + return '武术'; + case WorkoutActivityType.Golf: + return '高尔夫'; + case WorkoutActivityType.Boxing: + return '拳击'; + case WorkoutActivityType.SnowSports: + return '雪上运动'; + case WorkoutActivityType.SurfingSports: + return '冲浪运动'; + case WorkoutActivityType.WaterFitness: + return '水中健身'; case WorkoutActivityType.Other: return '其他运动'; default: