diff --git a/Assets/Res/language/language.json b/Assets/Res/language/language.json index b899c54..8cce917 100644 --- a/Assets/Res/language/language.json +++ b/Assets/Res/language/language.json @@ -1134,6 +1134,16 @@ "zh": "固件传输失败,请重试", "en": "Firmware transfer failed. Please try again." }, + { + "key": "ota_update_failed", + "zh": "固件更新失败,请重试", + "en": "Firmware update failed. Please try again." + }, + { + "key": "ota_waiting_reboot", + "zh": "设备更新中...", + "en": "Device updating..." + }, { "key": "100228", "zh": "温度错误:", @@ -1663,6 +1673,11 @@ "key": "100333", "zh": "设备需WiFi在线方可进行OTA升级", "en": "OTA update requires the device to be online via WiFi" + }, + { + "key": "100334", + "zh": "蓝牙未开启,如需使用请打开", + "en": "Bluetooth is off. Turn it on if needed." } diff --git a/Assets/Res/ui/deviceinfopage/固件更新.prefab b/Assets/Res/ui/deviceinfopage/固件更新.prefab index 9acfda1..7b92f0e 100644 --- a/Assets/Res/ui/deviceinfopage/固件更新.prefab +++ b/Assets/Res/ui/deviceinfopage/固件更新.prefab @@ -1,5 +1,139 @@ %YAML 1.1 %TAG !u! tag:unity3d.com,2011: +--- !u!1 &241336363346333283 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6901814223954429598} + - component: {fileID: 7794302058416943133} + - component: {fileID: 469724078145029111} + - component: {fileID: 5745045663283300389} + m_Layer: 5 + m_Name: "\u66F4\u65B0\u8FDB\u5EA6" + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!224 &6901814223954429598 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 241336363346333283} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1.0003841, y: 1.0003841, z: 1.0003841} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1682688447280925076} + - {fileID: 1221440869875413360} + m_Father: {fileID: 6542542256051934179} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 0} + m_AnchoredPosition: {x: 0, y: 120} + m_SizeDelta: {x: -60, y: 90} + m_Pivot: {x: 0.5, y: 0} +--- !u!222 &7794302058416943133 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 241336363346333283} + m_CullTransparentMesh: 1 +--- !u!114 &469724078145029111 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 241336363346333283} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.09019608, g: 0.09019608, b: 0.09019608, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: e70cab5c4dbadd748a0e5e007f58bf9d, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 2 +--- !u!114 &5745045663283300389 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 241336363346333283} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 0 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 469724078145029111} + m_OnClick: + m_PersistentCalls: + m_Calls: + - m_Target: {fileID: 0} + m_TargetAssemblyTypeName: Kill.UI.Pages.DeviceInfoPage, Assembly-CSharp + m_MethodName: RestoreSettings0 + m_Mode: 1 + m_Arguments: + m_ObjectArgument: {fileID: 0} + m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine + m_IntArgument: 0 + m_FloatArgument: 0 + m_StringArgument: + m_BoolArgument: 0 + m_CallState: 2 --- !u!1 &312154182751522083 GameObject: m_ObjectHideFlags: 0 @@ -34,6 +168,7 @@ RectTransform: - {fileID: 2568352390314877935} - {fileID: 3959655657766269314} - {fileID: 5168603418929363938} + - {fileID: 6901814223954429598} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -97,6 +232,9 @@ MonoBehaviour: changeLogText: {fileID: 1957090092938833670} backButton: {fileID: 5686861562038373004} updateButton: {fileID: 2226008222436017730} + progressPanel: {fileID: 241336363346333283} + progressBar: {fileID: 4875656352718239638} + progressText: {fileID: 1165127137834906993} --- !u!1 &414212200923698105 GameObject: m_ObjectHideFlags: 0 @@ -852,6 +990,174 @@ MonoBehaviour: m_FillOrigin: 0 m_UseSpriteMesh: 0 m_PixelsPerUnitMultiplier: 1 +--- !u!1 &3751683427085812987 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1221440869875413360} + - component: {fileID: 6517004505051842296} + - component: {fileID: 1165127137834906993} + - component: {fileID: 2493490058903432914} + m_Layer: 5 + m_Name: "\u6570\u503C" + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1221440869875413360 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3751683427085812987} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 6901814223954429598} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &6517004505051842296 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3751683427085812987} + m_CullTransparentMesh: 1 +--- !u!114 &1165127137834906993 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3751683427085812987} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_FontData: + m_Font: {fileID: 12800000, guid: f4c31d68e7cd3454685bb4c132a52f58, type: 3} + m_FontSize: 30 + m_FontStyle: 1 + m_BestFit: 0 + m_MinSize: 3 + m_MaxSize: 40 + m_Alignment: 4 + m_AlignByGeometry: 0 + m_RichText: 1 + m_HorizontalOverflow: 0 + m_VerticalOverflow: 0 + m_LineSpacing: 1 + m_Text: 0% +--- !u!114 &2493490058903432914 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3751683427085812987} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 89f1d49b50a31514787b4e268b1c73bb, type: 3} + m_Name: + m_EditorClassIdentifier: + textKey: 100221 +--- !u!1 &4223866644516014858 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1682688447280925076} + - component: {fileID: 8395495456873232610} + - component: {fileID: 4875656352718239638} + m_Layer: 5 + m_Name: "\u6761" + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1682688447280925076 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4223866644516014858} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 6901814223954429598} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &8395495456873232610 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4223866644516014858} + m_CullTransparentMesh: 1 +--- !u!114 &4875656352718239638 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4223866644516014858} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: bc956526bf19a8e4da8da24025fe8240, type: 3} + m_Type: 3 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 0 + m_FillAmount: 0 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 --- !u!1 &4561661931995402753 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/Res/ui/deviceinfopage/更新进度.png b/Assets/Res/ui/deviceinfopage/更新进度.png new file mode 100644 index 0000000..751a771 Binary files /dev/null and b/Assets/Res/ui/deviceinfopage/更新进度.png differ diff --git a/Assets/Res/ui/deviceinfopage/更新进度.png.meta b/Assets/Res/ui/deviceinfopage/更新进度.png.meta new file mode 100644 index 0000000..c072629 --- /dev/null +++ b/Assets/Res/ui/deviceinfopage/更新进度.png.meta @@ -0,0 +1,127 @@ +fileFormatVersion: 2 +guid: bc956526bf19a8e4da8da24025fe8240 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 3 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Bluetooth/BluetoothManager.cs b/Assets/Scripts/Bluetooth/BluetoothManager.cs index 395ced0..14f973c 100644 --- a/Assets/Scripts/Bluetooth/BluetoothManager.cs +++ b/Assets/Scripts/Bluetooth/BluetoothManager.cs @@ -243,16 +243,15 @@ namespace Kill.Bluetooth if (!isEnabled) { - Log("蓝牙未开启,使用 Intent 请求用户启用..."); - - using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) - using (AndroidJavaObject currentActivity = unityPlayer.GetStatic("currentActivity")) - { - AndroidJavaObject intent = new AndroidJavaObject("android.content.Intent", "android.bluetooth.adapter.action.REQUEST_ENABLE"); - currentActivity.Call("startActivityForResult", intent, 1); - } - - _enableBtCoroutine = StartCoroutine(WaitForBluetoothEnabled(onBluetoothReady)); + // 不再弹出系统蓝牙开启弹窗(弹窗会打断 App 生命周期,导致返回后页面卡住不刷新), + // 仅提示用户手动去系统设置中开启蓝牙 + Log("蓝牙未开启,仅提示用户手动开启蓝牙"); + ToastUI.Show("100334"); + _enableBtCoroutine = null; + _pendingStartScan = false; + _pendingConnectAddress = null; + OnError?.Invoke("蓝牙未开启"); + OnInitialized?.Invoke(false); } else { @@ -350,20 +349,23 @@ namespace Kill.Bluetooth Log("iOS 蓝牙已开启"); onBluetoothReady?.Invoke(); } - // 4 = PoweredOff (蓝牙关闭) — iOS 系统会自动弹出蓝牙开启对话框,无需手动跳转设置 + // 4 = PoweredOff (蓝牙关闭) — 仅提示用户手动开启,不等待系统弹窗 else if (state == 4) { - Log("iOS 蓝牙未开启,等待系统弹出蓝牙开启提示..."); - // 不调用 ShowIOSTurnOnBluetoothPrompt(),让 iOS 原生弹窗处理 - // 等待蓝牙状态变化(检测焦点变化 + 轮询蓝牙状态) - _enableBtCoroutine = StartCoroutine(WaitForIOSBluetoothEnabled(onBluetoothReady)); + Log("iOS 蓝牙未开启,仅提示用户手动开启蓝牙"); + ToastUI.Show("100334"); + _enableBtCoroutine = null; + OnError?.Invoke("蓝牙未开启"); + OnInitialized?.Invoke(false); } - // 3 = Unauthorized (未授权) — 需要跳转到应用设置页面让用户开启蓝牙权限 + // 3 = Unauthorized (未授权) — 仅提示用户去系统设置中开启蓝牙权限,不自动跳转 else if (state == 3) { - Log("iOS 蓝牙未授权,跳转到应用设置页..."); - ShowIOSTurnOnBluetoothPrompt(); - _enableBtCoroutine = StartCoroutine(WaitForIOSBluetoothEnabled(onBluetoothReady)); + Log("iOS 蓝牙未授权,仅提示用户去系统设置中开启蓝牙权限"); + ToastUI.Show("100334"); + _enableBtCoroutine = null; + OnError?.Invoke("蓝牙未授权"); + OnInitialized?.Invoke(false); } // 2 = Unsupported (不支持蓝牙) else if (state == 2) diff --git a/Assets/Scripts/Managers/DataManager.cs b/Assets/Scripts/Managers/DataManager.cs index 4aa1a35..1fca575 100644 --- a/Assets/Scripts/Managers/DataManager.cs +++ b/Assets/Scripts/Managers/DataManager.cs @@ -53,6 +53,79 @@ namespace Kill.Managers { selectedDeviceMac=PlayerPrefs.GetString("selectedDeviceMac", ""); } + + #region 设备列表本地缓存(断网时使用) + private const string OWNED_DEVICES_KEY = "ownedDevicesCache"; + private const string SHARED_DEVICES_KEY = "sharedDevicesCache"; + + /// + /// 将当前 OwnedDevices/SharedDevices 持久化到本地(按当前登录用户隔离) + /// + public void SaveDeviceLists() + { + try + { + string userId = userInfo?.id ?? ""; + string ownedJson = JsonUtility.ToJson(OwnedDevices != null ? new DeviceInfoListWrapper(OwnedDevices) : new DeviceInfoListWrapper()); + string sharedJson = JsonUtility.ToJson(SharedDevices != null ? new DeviceInfoListWrapper(SharedDevices) : new DeviceInfoListWrapper()); + PlayerPrefs.SetString(OWNED_DEVICES_KEY + "_" + userId, ownedJson); + PlayerPrefs.SetString(SHARED_DEVICES_KEY + "_" + userId, sharedJson); + PlayerPrefs.Save(); + } + catch (Exception ex) + { + Debug.LogError($"[DataManager] 保存设备列表缓存失败: {ex.Message}"); + } + } + + /// + /// 从本地恢复 OwnedDevices/SharedDevices(返回是否成功恢复) + /// + public bool LoadDeviceLists() + { + try + { + string userId = userInfo?.id ?? ""; + string ownedJson = PlayerPrefs.GetString(OWNED_DEVICES_KEY + "_" + userId, ""); + string sharedJson = PlayerPrefs.GetString(SHARED_DEVICES_KEY + "_" + userId, ""); + OwnedDevices = string.IsNullOrEmpty(ownedJson) ? new List() : JsonUtility.FromJson(ownedJson)?.ToList() ?? new List(); + SharedDevices = string.IsNullOrEmpty(sharedJson) ? new List() : JsonUtility.FromJson(sharedJson)?.ToList() ?? new List(); + return OwnedDevices.Count > 0 || SharedDevices.Count > 0; + } + catch (Exception ex) + { + Debug.LogError($"[DataManager] 读取设备列表缓存失败: {ex.Message}"); + OwnedDevices = new List(); + SharedDevices = new List(); + return false; + } + } + + /// + /// 清空本地缓存的设备列表(登出时调用) + /// + public void ClearDeviceLists() + { + string userId = userInfo?.id ?? ""; + PlayerPrefs.DeleteKey(OWNED_DEVICES_KEY + "_" + userId); + PlayerPrefs.DeleteKey(SHARED_DEVICES_KEY + "_" + userId); + PlayerPrefs.Save(); + OwnedDevices = new List(); + SharedDevices = new List(); + } + + [Serializable] + private class DeviceInfoListWrapper + { + public List items = new List(); + public DeviceInfoListWrapper() { } + public DeviceInfoListWrapper(List source) + { + if (source != null) items.AddRange(source); + } + public List ToList() => new List(items); + } + #endregion public void InitUser() { token = PlayerPrefs.GetString("token", ""); @@ -124,7 +197,8 @@ namespace Kill.Managers onError: (code, msg) => { Debug.LogError($"[DataManager] 获取设备配置失败: {msg}"); - } + }, + silentErrorTip: true ); } @@ -164,7 +238,8 @@ namespace Kill.Managers onError: (code, msg) => { Debug.LogError($"[DataManager] 修改设备配置失败: {msg}"); - } + }, + silentErrorTip: true ); return success; diff --git a/Assets/Scripts/Network/ResponseCode.cs b/Assets/Scripts/Network/ResponseCode.cs index 2d720c7..1cabffb 100644 --- a/Assets/Scripts/Network/ResponseCode.cs +++ b/Assets/Scripts/Network/ResponseCode.cs @@ -93,21 +93,23 @@ namespace Kill.Network /// 输出状态码 /// 输出消息 /// 是否已全局处理 - public static bool CheckGlobalHandle(HttpResponse response, out int code, out string message) + public static bool CheckGlobalHandle(HttpResponse response, out int code, out string message, bool silentErrorTip = false) { code = -1; message = null; // 空响应(网络异常) if (response == null) { - ShowErrorTip("100036"); + message = "网络异常"; + if (!silentErrorTip) ShowErrorTip("100036"); return true; } // HTTP错误(网络层错误) if (!response.IsSuccess) { - ShowErrorTip("100036"); + message = string.IsNullOrEmpty(response.ErrorMessage) ? "网络异常" : response.ErrorMessage; + if (!silentErrorTip) ShowErrorTip("100036"); return true; } @@ -132,13 +134,19 @@ namespace Kill.Network /// HTTP响应 /// 成功回调(code=200) /// 其他错误回调(可选,不传则自动提示) - public static void HandleResponse(HttpResponse response, Action onSuccess, Action onError = null) + /// true 时静默处理网络错误(不弹100036),仅触发 onError + public static void HandleResponse(HttpResponse response, Action onSuccess, Action onError = null, bool silentErrorTip = false) { // 全局处理(网络错误、601等) int code; string message; - if (CheckGlobalHandle(response, out code, out message)) + if (CheckGlobalHandle(response, out code, out message, silentErrorTip)) { + // silentErrorTip=true 时若调用方提供了 onError,仍触发以便业务继续 + if (silentErrorTip && onError != null) + { + onError.Invoke(code, message ?? "网络异常"); + } return; // 已被全局处理 } diff --git a/Assets/Scripts/UI/Pages/DeviceInfoPage/FirmwareUpdatePage.cs b/Assets/Scripts/UI/Pages/DeviceInfoPage/FirmwareUpdatePage.cs index 54ff148..05b33ca 100644 --- a/Assets/Scripts/UI/Pages/DeviceInfoPage/FirmwareUpdatePage.cs +++ b/Assets/Scripts/UI/Pages/DeviceInfoPage/FirmwareUpdatePage.cs @@ -21,7 +21,22 @@ namespace Kill.UI.Pages public Text changeLogText; public Button backButton; public Button updateButton; + // WiFi OTA 页内进度条(非全屏,不阻塞操作) + public GameObject progressPanel; + public Image progressBar; // Fill Method = Horizontal, Fill Origin = Left + public Text progressText; OTAData otaData; + // 当前设备持久化的 OTA transferId(WiFi 模式),空表示无进行中的传输 + private const string OTA_TRANSFER_ID_KEY = "otaTransferId"; + private const string OTA_TRANSFER_VER_KEY = "otaTransferVer"; + private const string OTA_TRANSFER_STATUS_KEY = "otaTransferStatus"; + private const string OTA_TRANSFER_DEADLINE_KEY = "otaTransferDeadline"; // 等设备重启的绝对截止时间戳(Unix 秒) + // 等设备重启的总时长(秒);持久化截止时间,避免退出/重进页面时计时重置 + private const int OTA_WAIT_DEADLINE_SECONDS = 30; + // status: "pushing"=传输中, "waiting_reboot"=传输完成等待设备重启/重连 + private string currentTransferId; + private string currentTransferStatus; + private bool isUpdating; // OTA升级进行中(防连点重入) void Start() { UIManager.Instance.RegisterBackAction(Back); @@ -30,6 +45,7 @@ namespace Kill.UI.Pages updateButton.onClick.RemoveAllListeners(); updateButton.onClick.AddListener(OnUpdateButtonClick); updateButton.gameObject.SetActive(false); + if (progressPanel != null) progressPanel.SetActive(false); GetOtaInfo(); } @@ -90,6 +106,9 @@ namespace Kill.UI.Pages versionText = latestTip.transform.Find("value").GetComponent(); versionText.text = otaData.version; } + + // 进入页面:若本地有未结束的 transferId,则恢复订阅进度(非全屏,不阻塞) + TryResumeWifiOta(); } public void Back() { @@ -97,7 +116,6 @@ namespace Kill.UI.Pages Destroy(gameObject); Screen.sleepTimeout = SleepTimeout.SystemSetting; } - private bool isUpdating; // OTA升级进行中(防连点重入) public async void OnUpdateButtonClick() { // 防连点:升级流程进行中忽略重复触发,避免并发启动两个OTA @@ -113,12 +131,9 @@ namespace Kill.UI.Pages return; } - // WiFi 模式:push OTA 指令,SSE 订阅传输进度 + // WiFi 模式:push OTA 指令,SSE 订阅传输进度(非全屏,不阻塞操作,可关闭页面后下次进入继续订阅) if (DataManager.Instance.hasWifi) { - ToastUI.Show("100294"); - UpdateLoadingUI.Instance.Show(); - UpdateLoadingUI.Instance.SetPrecent(0); try { var pushRequest = new OTAPushRequest @@ -127,39 +142,38 @@ namespace Kill.UI.Pages version = otaData.version }; var pushResponse = await NetworkCtrl.Instance.Post("/api/v1/ota/push", pushRequest); - if (pushResponse.IsSuccess && pushResponse.Data != null && pushResponse.Data.data != null && !string.IsNullOrEmpty(pushResponse.Data.data.transferId)) + // 提取 transferId:优先从 data.transferId,否则在 code=608(已有OTA进行中)时从 message 解析 "transferId=xxx" + string transferId = null; + if (pushResponse.Data != null && pushResponse.Data.data != null && !string.IsNullOrEmpty(pushResponse.Data.data.transferId)) + { + transferId = pushResponse.Data.data.transferId; + } + else if (pushResponse.Data != null && pushResponse.Data.code == 608 && !string.IsNullOrEmpty(pushResponse.Data.message)) + { + var match = System.Text.RegularExpressions.Regex.Match(pushResponse.Data.message, @"transferId\s*=\s*([A-Za-z0-9_\-]+)"); + if (match.Success) + { + transferId = match.Groups[1].Value; + Debug.Log($"WiFi OTA push 返回 code=608(已有OTA进行中),从message提取 transferId: {transferId}"); + } + } + if (!string.IsNullOrEmpty(transferId)) { - string transferId = pushResponse.Data.data.transferId; Debug.Log($"WiFi OTA push 成功,transferId: {transferId}"); - // SSE 订阅传输进度,传输完成返回 true - bool success = await SubscribeOtaProgress(transferId, p => UpdateLoadingUI.Instance.SetPrecent(p / 100f)); - if (success) - { - ToastUI.Show("100226"); - DataManager.Instance.selectedDevice.firmware_version = otaData.version; - // 传输完成后等待 10s 再返回主页(设备升级后需要时间重启) - StartCoroutine(DelayedGoHome(10f)); - } - else - { - ToastUI.Show("100227"); - UIManager.Instance.OpenMainPage(UIManager.PageName.homePage); - } + // 持久化 transferId,订阅后台进行,不阻塞页面操作 + SaveTransferId(transferId, otaData.version, "pushing"); + StartWifiOtaSubscription(transferId); } else { ToastUI.Show("100227"); Debug.LogError($"WiFi OTA push 失败: {pushResponse.ErrorMessage ?? pushResponse.RawResponse}"); - UpdateLoadingUI.Instance.Hide(); - UIManager.Instance.OpenMainPage(UIManager.PageName.homePage); } } catch (System.Exception ex) { Debug.LogError($"WiFi OTA push 异常: {ex.Message}"); - UpdateLoadingUI.Instance.Hide(); ToastUI.Show("100227"); - UIManager.Instance.OpenMainPage(UIManager.PageName.homePage); } finally { @@ -201,17 +215,13 @@ namespace Kill.UI.Pages UpdateLoadingUI.Instance.Show(); uint firmwareVersion = ParseFirmwareVersion(otaData.version); OTAManager.Instance.OnTransferProgress += OnOTATransferProgress; - // OTA 传输期间清空返回事件,防止用户触发返回中断升级;传输结束恢复 - var otaBackAction = UIManager.Instance.GetBackAction(); + // OTA 传输期间(进度显示)清空返回事件,返回键走两次返回退出App,防止误返回中断升级(成败都会返回主页) UIManager.Instance.ClearBackAction(); StartCoroutine(OTAManager.Instance.PerformFullUpgrade(_selectedFirmwareData, firmwareVersion, (success) => { Loom.QueueOnMainThread(() => { isUpdating = false; - // 恢复返回事件 - if (otaBackAction != null) - UIManager.Instance.RegisterBackAction(otaBackAction); if (success) { DataManager.Instance.selectedDevice.firmware_version = otaData.version; @@ -244,19 +254,318 @@ namespace Kill.UI.Pages } /// - /// 订阅 WiFi OTA 传输进度(SSE),返回是否传输完成 + /// 订阅 WiFi OTA 传输进度(SSE),返回是否传输完成(仅供兼容旧调用,BLE 仍走全屏 OTAManager) /// private Task SubscribeOtaProgress(string transferId, Action onProgress) { var tcs = new TaskCompletionSource(); - StartCoroutine(SSEProgressCoroutine(transferId, onProgress, success => tcs.TrySetResult(success))); + StartCoroutine(SSELegacyCoroutine(transferId, onProgress, success => tcs.TrySetResult(success))); return tcs.Task; } + private IEnumerator SSELegacyCoroutine(string transferId, Action onProgress, Action onDone) + { + bool finished = false; + yield return SubscribeOtaProgressCoroutine(transferId, onProgress, done => finished = done); + onDone?.Invoke(finished); + } + /// - /// SSE 进度协程:持续接收进度事件,传输完成(status/percentage)或连接结束后结束 + /// 保存当前设备的 OTA transferId(与设备mac绑定,切换设备不影响) /// - private IEnumerator SSEProgressCoroutine(string transferId, Action onProgress, Action onDone) + private void SaveTransferId(string transferId, string version, string status) + { + string mac = DataManager.Instance.selectedDevice?.ble_mac ?? ""; + PlayerPrefs.SetString(GetTransferIdKey(mac), transferId); + PlayerPrefs.SetString(GetTransferVerKey(mac), version ?? ""); + PlayerPrefs.SetString(GetTransferStatusKey(mac), status ?? ""); + PlayerPrefs.Save(); + currentTransferId = transferId; + currentTransferStatus = status; + } + + private void UpdateTransferStatus(string status) + { + string mac = DataManager.Instance.selectedDevice?.ble_mac ?? ""; + PlayerPrefs.SetString(GetTransferStatusKey(mac), status ?? ""); + PlayerPrefs.Save(); + currentTransferStatus = status; + } + + /// + /// 设置等待设备重启的绝对截止时间戳(Unix 秒)。退出页面再回来仍按此截止时间判定,不会重置。 + /// + private void SetWaitDeadline() + { + string mac = DataManager.Instance.selectedDevice?.ble_mac ?? ""; + long deadline = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + OTA_WAIT_DEADLINE_SECONDS; + PlayerPrefs.SetString(GetTransferDeadlineKey(mac), deadline.ToString()); + PlayerPrefs.Save(); + } + + private long LoadWaitDeadline() + { + string mac = DataManager.Instance.selectedDevice?.ble_mac ?? ""; + string v = PlayerPrefs.GetString(GetTransferDeadlineKey(mac), ""); + if (long.TryParse(v, out long d)) return d; + return 0; + } + + /// + /// 读取当前设备的 OTA transferId(空表示无) + /// + private string LoadTransferId() + { + string mac = DataManager.Instance.selectedDevice?.ble_mac ?? ""; + return PlayerPrefs.GetString(GetTransferIdKey(mac), ""); + } + + /// + /// 清除当前设备的 OTA transferId + /// + private void ClearTransferId() + { + string mac = DataManager.Instance.selectedDevice?.ble_mac ?? ""; + PlayerPrefs.DeleteKey(GetTransferIdKey(mac)); + PlayerPrefs.DeleteKey(GetTransferVerKey(mac)); + PlayerPrefs.DeleteKey(GetTransferStatusKey(mac)); + PlayerPrefs.DeleteKey(GetTransferDeadlineKey(mac)); + PlayerPrefs.Save(); + currentTransferId = null; + currentTransferStatus = null; + } + + private string GetTransferIdKey(string mac) => OTA_TRANSFER_ID_KEY + "_" + mac; + private string GetTransferVerKey(string mac) => OTA_TRANSFER_VER_KEY + "_" + mac; + private string GetTransferStatusKey(string mac) => OTA_TRANSFER_STATUS_KEY + "_" + mac; + private string GetTransferDeadlineKey(string mac) => OTA_TRANSFER_DEADLINE_KEY + "_" + mac; + + /// + /// 启动 WiFi OTA 订阅:仅更新页内小进度条,不阻塞页面,可关闭/重入 + /// + private void StartWifiOtaSubscription(string transferId) + { + ShowProgressPanel(0); + updateButton.gameObject.SetActive(false); // 升级中禁用更新按钮 + StartCoroutine(SubscribeAndHandle(transferId)); + } + + /// + /// 订阅协程:完成/失败/连接结束统一处理(不强制回主页) + /// + private IEnumerator SubscribeAndHandle(string transferId) + { + bool finished = false; + // SSE 回调本身在主线程,直接同步赋值即可(不再绕 Loom,否则 yield 之后判断时 finished 仍是旧值) + yield return SubscribeOtaProgressCoroutine(transferId, + p => ShowProgressPanel(p), + done => finished = done); + // 协程结束:根据是否完成做后续 + if (finished) + { + ShowProgressPanel(100); + // 传输完成 ≠ 设备升级完成:等待设备重启/重连,并以新版本号上线后再清理 transferId、刷新 UI + UpdateTransferStatus("waiting_reboot"); + // 仅在尚未设置过截止时间时才设置(避免重复订阅重置倒计时) + if (LoadWaitDeadline() <= 0) SetWaitDeadline(); + StartCoroutine(WaitDeviceRebootAndFinish(transferId)); + } + else + { + // 中途失败/中断 + ToastUI.Show("100227"); + ClearTransferId(); + HideProgressPanel(); + updateButton.gameObject.SetActive(true); + } + } + + /// + /// 传输完成后等待设备重启+重连:轮询设备列表直到当前设备以新版本号上线,否则按持久化的绝对截止时间超时退出。 + /// 期间保持 transferId 持久化、保留页内进度条显示「升级中」文案,重新进入页面也能继续等待(截止时间不会重置)。 + /// + private IEnumerator WaitDeviceRebootAndFinish(string transferId) + { + const float pollInterval = 3f; // 轮询间隔 + long deadline = LoadWaitDeadline(); + // 兜底:若没设置过截止时间,立即设置一个 + if (deadline <= 0) + { + SetWaitDeadline(); + deadline = LoadWaitDeadline(); + } + // 已超过绝对截止时间:直接走超时分支并清掉 transferId,避免每次进入都重复提示 + if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() >= deadline) + { + ClearTransferId(); + HideProgressPanel(); + updateButton.gameObject.SetActive(true); + ToastUI.Show("ota_update_failed"); + yield break; + } + ShowProgressPanelWaitingForDevice(); + ToastUI.Show("100226"); // 传输完成提示 + string targetVersion = otaData != null ? otaData.version : ""; + bool rebooted = false; + while (DateTimeOffset.UtcNow.ToUnixTimeSeconds() < deadline) + { + yield return new WaitForSeconds(pollInterval); + if (gameObject == null) yield break; // 页面已销毁 + // 仅当持久化的还是同一个 transferId 时才继续等待(用户重新点击/切换设备会清掉) + if (LoadTransferId() != transferId) yield break; + string nowVersion = null; + yield return FetchDeviceFirmwareVersion(r => nowVersion = r); + if (!string.IsNullOrEmpty(nowVersion) && IsVersionGE(nowVersion, targetVersion)) + { + rebooted = true; + break; + } + // 期间截止时间可能被重置(用户重新点击升级等),以最新值为准 + long currentDeadline = LoadWaitDeadline(); + if (currentDeadline > 0 && currentDeadline != deadline) deadline = currentDeadline; + } + if (gameObject == null) yield break; + if (LoadTransferId() != transferId) yield break; + if (rebooted) + { + // 设备已用新版本上线,更新本地版本并清理状态 + if (DataManager.Instance.selectedDevice != null && !string.IsNullOrEmpty(targetVersion)) + { + DataManager.Instance.selectedDevice.firmware_version = targetVersion; + } + ClearTransferId(); + HideProgressPanel(); + InitOtaUI(); // 重新判断:已是最新 / 又有新版本 + } + else + { + // 超时:升级失败,提示并清理 transferId(避免下次进入页面再触发) + ToastUI.Show("ota_update_failed"); + ClearTransferId(); + HideProgressPanel(); + updateButton.gameObject.SetActive(true); + } + } + + /// + /// 拉取当前设备的最新 firmware_version(仅赋值给回调,返回空表示失败/离线) + /// + private IEnumerator FetchDeviceFirmwareVersion(Action onResult) + { + string result = null; + var task = FetchDeviceFirmwareVersionAsync(v => result = v); + while (!task.IsCompleted) yield return null; + onResult?.Invoke(result); + } + + private async Task FetchDeviceFirmwareVersionAsync(Action onResult) + { + try + { + if (DataManager.Instance.userInfo == null || string.IsNullOrEmpty(DataManager.Instance.userInfo.id)) + { + onResult?.Invoke(null); + return; + } + string url = $"/api/v1/device/user/list?user_id={DataManager.Instance.userInfo.id}"; + var response = await NetworkCtrl.Instance.Get(url); + if (response == null || !response.IsSuccess || response.Data == null || response.Data.data == null) + { + onResult?.Invoke(null); + return; + } + string mac = DataManager.Instance.selectedDevice?.ble_mac ?? ""; + var list = response.Data.data.owned_devices; + if (list != null) + { + foreach (var d in list) + { + if (d != null && d.ble_mac == mac) + { + onResult?.Invoke(d.firmware_version); + return; + } + } + } + var shared = response.Data.data.shared_devices; + if (shared != null) + { + foreach (var d in shared) + { + if (d != null && d.ble_mac == mac) + { + onResult?.Invoke(d.firmware_version); + return; + } + } + } + onResult?.Invoke(null); + } + catch (System.Exception ex) + { + Debug.LogError($"查询设备版本失败: {ex.Message}"); + onResult?.Invoke(null); + } + } + + /// + /// 比较版本号:a >= b 返回 true(按 "." 分段整数比较) + /// + private bool IsVersionGE(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + string[] ap = a.Split('.'); + string[] bp = b.Split('.'); + int len = Mathf.Max(ap.Length, bp.Length); + for (int i = 0; i < len; i++) + { + int av = 0, bv = 0; + if (i < ap.Length) int.TryParse(ap[i], out av); + if (i < bp.Length) int.TryParse(bp[i], out bv); + if (av > bv) return true; + if (av < bv) return false; + } + return true; + } + + /// + /// 进度条切换到「升级中...」状态(fill 保持满,文字提示) + /// + private void ShowProgressPanelWaitingForDevice() + { + if (progressPanel != null) progressPanel.SetActive(true); + if (progressBar != null) progressBar.fillAmount = 1f; + if (progressText != null && LanguageManager.Instance != null) + progressText.text = LanguageManager.Instance.GetLanguage("ota_waiting_reboot"); + } + + /// + /// 进入页面时:若本地存在 transferId,则恢复订阅并显示进度条 + /// + private void TryResumeWifiOta() + { + string mac = DataManager.Instance.selectedDevice?.ble_mac ?? ""; + string savedId = LoadTransferId(); + string savedStatus = PlayerPrefs.GetString(GetTransferStatusKey(mac), ""); + if (string.IsNullOrEmpty(savedId)) return; + currentTransferId = savedId; + currentTransferStatus = savedStatus; + updateButton.gameObject.SetActive(false); + // 已经传输完成等待设备重启:直接走轮询分支,不重新订阅 SSE + if (savedStatus == "waiting_reboot") + { + ShowProgressPanelWaitingForDevice(); + StartCoroutine(WaitDeviceRebootAndFinish(savedId)); + return; + } + ShowProgressPanel(0); + StartCoroutine(SubscribeAndHandle(savedId)); + } + + /// + /// 仅订阅进度协程,传出是否完成(不弹出后续处理) + /// + private IEnumerator SubscribeOtaProgressCoroutine(string transferId, Action onProgress, Action onDone) { bool transferFinished = false; string sseUrl = $"/api/v1/ota/transfer/{transferId}/progress"; @@ -270,7 +579,8 @@ namespace Kill.UI.Pages if (info == null) return; onProgress?.Invoke(info.percentage); string status = (info.status ?? "").ToLower(); - if (info.percentage >= 100 || status.Contains("completed") || status.Contains("success") || status.Contains("done")) + Debug.Log($"OTA 进度: {status}, {info.percentage}%"); + if (info.percentage >= 100 || status.Contains("completed")) { transferFinished = true; } @@ -284,10 +594,29 @@ namespace Kill.UI.Pages { Debug.LogError($"OTA 进度连接错误: {err}"); }); - // SSE 连接结束(服务端关闭或出错),以最后收到的进度状态判定是否完成 onDone?.Invoke(transferFinished); } + /// + /// 显示页内进度条并更新百分比 + /// + private void ShowProgressPanel(int percent) + { + if (progressPanel != null) progressPanel.SetActive(true); + if (progressBar != null) progressBar.fillAmount = Mathf.Clamp01(percent / 100f); + if (progressText != null) progressText.text = $"{percent}%"; + } + + /// + /// 隐藏页内进度条 + /// + private void HideProgressPanel() + { + if (progressBar != null) progressBar.fillAmount = 0f; + if (progressText != null) progressText.text = "0%"; + if (progressPanel != null) progressPanel.SetActive(false); + } + /// /// 加载固件文件 /// diff --git a/Assets/Scripts/UI/Pages/HomePage/HomePageCtrl.cs b/Assets/Scripts/UI/Pages/HomePage/HomePageCtrl.cs index 93aac5b..8fd566e 100644 --- a/Assets/Scripts/UI/Pages/HomePage/HomePageCtrl.cs +++ b/Assets/Scripts/UI/Pages/HomePage/HomePageCtrl.cs @@ -79,7 +79,7 @@ namespace Kill.UI.Pages // 订阅蓝牙事件 SubscribeBluetoothEvents(); - // 获取设备列表 + // 获取设备列表(失败时使用上一次缓存的列表,继续后续流程) await FetchDeviceList(); Init(); } @@ -129,6 +129,13 @@ namespace Kill.UI.Pages BluetoothManager.Instance.OnStartScan+=deviceState.OnStartScan; } + // 网络状态:断网立刻把 WiFi 设备标离线,联网后异步重新确认 + if (NetworkStateManager.Instance != null) + { + NetworkStateManager.Instance.OnNetworkDisconnected += OnNetworkDisconnectedHandler; + NetworkStateManager.Instance.OnNetworkConnected += OnNetworkConnectedHandler; + } + if (BLECommunicationManager.Instance != null) { // 订阅用户登录结果事件 @@ -159,6 +166,12 @@ namespace Kill.UI.Pages BluetoothManager.Instance.OnStartScan-=deviceState.OnStartScan; } + if (NetworkStateManager.Instance != null) + { + NetworkStateManager.Instance.OnNetworkDisconnected -= OnNetworkDisconnectedHandler; + NetworkStateManager.Instance.OnNetworkConnected -= OnNetworkConnectedHandler; + } + if (BLECommunicationManager.Instance != null) { // 取消订阅用户登录结果事件 @@ -460,6 +473,33 @@ namespace Kill.UI.Pages #endregion + #region 网络状态事件处理 + + /// + /// 网络断开:立刻把 WiFi 设备标离线并刷新 UI + /// + private void OnNetworkDisconnectedHandler(object sender, EventArgs e) + { + Debug.Log("[HomePageCtrl] 网络断开,立即刷新设备WiFi为未连接"); + hasWifi = false; + DataManager.Instance.hasWifi = false; + pendingUIUpdates.Add(() => + { + deviceState?.UpdateDeviceState(hasBluetooth, hasWifi); + }); + } + + /// + /// 网络恢复:异步重新查询设备在线状态(局域网通但设备可能下线) + /// + private void OnNetworkConnectedHandler(object sender, EventArgs e) + { + Debug.Log("[HomePageCtrl] 网络恢复,重新查询设备在线状态"); + RefreshWifiOnlineStatusAsync(); + } + + #endregion + #region 设备状态回调 // 当前工作模式 @@ -823,7 +863,7 @@ namespace Kill.UI.Pages public async Task Init() { LoadingUI.Show(); - if (DataManager.Instance.userInfo.device_count == 0) + if (DataManager.Instance.userInfo.device_count == 0 || (OwnedDevices.Count == 0 && SharedDevices.Count == 0)) { noDevicePage.SetActive(true); normalPage.SetActive(false); @@ -880,12 +920,15 @@ namespace Kill.UI.Pages } selectDeviceButton.GetComponentInChildren().text = selectedDevice.device_name; selectDeviceButton.gameObject.SetActive(true); - hasWifi=await GetDeviceOnlineStatus(); - DataManager.Instance.hasWifi=hasWifi; - + // 蓝牙:直接触发连接,状态以连接回调为准(不阻塞) hasBluetooth = BluetoothManager.Instance.ChangeAimMac(selectedDevice.ble_mac); DataManager.Instance.hasBluetooth=hasBluetooth; - + + // WiFi:不阻塞主线,异步短超时查询;进入页面先按未知/未连接显示,断网时立刻离线,命中在线再补做初始化 + hasWifi = false; + DataManager.Instance.hasWifi = false; + RefreshWifiOnlineStatusAsync(); + // 先初始化所有 UI 为未连接状态 ResetAllDeviceUI(); @@ -969,8 +1012,6 @@ namespace Kill.UI.Pages /// public async Task FetchDeviceList() { - LoadingUI.Show(); - try { Debug.Log("[HomePageCtrl] 开始获取设备列表..."); @@ -980,15 +1021,20 @@ namespace Kill.UI.Pages if (string.IsNullOrEmpty(userId)) { Debug.LogError("[HomePageCtrl] 获取设备列表失败: 用户未登录"); - LoadingUI.Hide(); return; } + // 断网/接口失败兜底:先尝试从 PlayerPrefs 读取上次缓存(同步赋值给当前页字段,供随后 Init 渲染) + if (OwnedDevices.Count == 0 && SharedDevices.Count == 0 && DataManager.Instance.LoadDeviceLists()) + { + OwnedDevices = new List(DataManager.Instance.OwnedDevices); + SharedDevices = new List(DataManager.Instance.SharedDevices); + Debug.Log($"[HomePageCtrl] 使用本地缓存设备列表: 自有={OwnedDevices.Count}, 共享={SharedDevices.Count}"); + } + string url = $"/api/v1/device/user/list?user_id={userId}"; var response = await NetworkCtrl.Instance.Get(url); - LoadingUI.Hide(); - ResponseCodeHandler.HandleResponse(response, onSuccess: (data) => { @@ -1016,17 +1062,48 @@ namespace Kill.UI.Pages Debug.Log($"[HomePageCtrl] 获取设备列表成功: 自有设备={OwnedDevices.Count}, 共享设备={SharedDevices.Count}"); DataManager.Instance.userInfo.device_count = OwnedDevices.Count + SharedDevices.Count; + // 同步到 DataManager 并持久化到本地(断网时使用) + DataManager.Instance.OwnedDevices = new List(OwnedDevices); + DataManager.Instance.SharedDevices = new List(SharedDevices); + DataManager.Instance.SaveDeviceLists(); + // 设备列表更新后,刷新主页选中设备与 UI(不阻塞初次进入) + Loom.QueueOnMainThread(() => RefreshAfterDeviceListUpdated()); }, onError: null ); } catch (Exception ex) { - LoadingUI.Hide(); Debug.LogError($"[HomePageCtrl] 获取设备列表异常: {ex.Message}"); } } + /// + /// 设备列表更新后:重新解析 selectedDevice 并刷新主页相关 UI(不重复 LoadingUI) + /// + private void RefreshAfterDeviceListUpdated() + { + if (DataManager.Instance == null) return; + // 若初次进入时无设备列表而走了「无设备」分支,列表就绪后再切回正常页 + if (OwnedDevices.Count > 0 || SharedDevices.Count > 0) + { + noDevicePage.SetActive(false); + normalPage.SetActive(true); + } + // 重新定位 selectedDevice + if (DataManager.Instance.selectedDevice == null && (OwnedDevices.Count > 0 || SharedDevices.Count > 0)) + { + selectedDevice = OwnedDevices.Count > 0 ? OwnedDevices[0] : SharedDevices[0]; + DataManager.Instance.selectedDeviceMac = selectedDevice.ble_mac; + DataManager.Instance.SavaSelectedDeviceMac(selectedDevice.ble_mac); + DataManager.Instance.selectedDevice = selectedDevice; + selectDeviceButton.GetComponentInChildren().text = selectedDevice.device_name; + selectDeviceButton.gameObject.SetActive(true); + } + DataManager.Instance.OwnedDevices = OwnedDevices; + DataManager.Instance.SharedDevices = SharedDevices; + } + void Update() { // 执行所有挂起的UI更新 @@ -1683,7 +1760,8 @@ namespace Kill.UI.Pages var response = await NetworkCtrl.Instance.Get( "/api/v1/device/online", - new Dictionary { { "mac", selectedDevice.ble_mac } } + new Dictionary { { "mac", selectedDevice.ble_mac } }, + new HttpRequestConfig { Timeout = 5, MaxRetries = 1, RetryDelayMs = 500, UseExponentialBackoff = false } ); ResponseCodeHandler.HandleResponse(response, @@ -1697,12 +1775,78 @@ namespace Kill.UI.Pages { Debug.LogError($"[HomePageCtrl] 查询设备在线状态失败: {msg}"); tcs.TrySetResult(false); - } + }, + silentErrorTip: true ); + // 兜底:silentErrorTip=true 时 CheckGlobalHandle 会直接 return,导致 onError 永远不被调, + // 这里加超时与兜底,避免 tcs 永久 hang(否则后台轮询将停在这一帧) + if (!tcs.Task.IsCompleted) + { + Debug.LogWarning("[HomePageCtrl] 设备在线状态查询未返回结果,兜底设为未连接"); + tcs.TrySetResult(false); + } + return await tcs.Task; } + /// + /// 非阻塞刷新 WiFi 在线状态:失败立即标记未连接并刷新 UI;后台轮询会持续覆盖 + /// 若从离线变为在线且蓝牙未连接,补做 WiFi 分支的设备状态初始化(拉取 config/schedule) + /// + public async void RefreshWifiOnlineStatusAsync() + { + if (selectedDevice == null || string.IsNullOrEmpty(selectedDevice.ble_mac)) return; + bool wasOnline = hasWifi; + bool online; + try + { + online = await GetDeviceOnlineStatus(); + } + catch (System.Exception ex) + { + Debug.LogError($"[HomePageCtrl] 刷新WiFi在线状态异常: {ex.Message}"); + online = false; + } + if (this == null || selectedDevice == null) return; + // 失败(online=false)一律立即刷为未连接,避免 UI 残留已连接态 + hasWifi = online; + DataManager.Instance.hasWifi = online; + pendingUIUpdates.Add(() => + { + deviceState?.UpdateDeviceState(hasBluetooth, hasWifi); + }); + if (!online) + { + Debug.Log("[HomePageCtrl] WiFi在线状态获取失败,已将设备刷新为未连接"); + return; + } + // 由离线变为在线且蓝牙未连接:补做 WiFi 分支的设备状态初始化 + if (!wasOnline && !hasBluetooth) + { + Debug.Log("[HomePageCtrl] WiFi 由离线变为在线,补做设备状态初始化"); + _ = InitWifiOnlyDeviceStateAsync(); + } + } + + /// + /// WiFi-only 分支的设备状态初始化(拉取配置/定时任务),与 Init() 中对应分支保持一致 + /// + private async Task InitWifiOnlyDeviceStateAsync() + { + if (selectedDevice == null) return; + deviceCtrl?.InitDeviceControl(false); + await DataManager.Instance.GetDeviceConfig(selectedDevice.ble_mac); + await DataManager.Instance.GetScheduleTasks(selectedDevice.ble_mac); + // 期间蓝牙可能已连接,避免用 WiFi 数据覆盖 BLE 数据 + if (!hasBluetooth) + { + scheduleSettingButton.interactable = true; + moreSettingButton.interactable = true; + InitDeviceUIFromConfig(); + } + } + /// /// 启动设备在线状态轮询(始终运行,后台检测WiFi状态变化) /// @@ -1739,6 +1883,14 @@ namespace Kill.UI.Pages deviceState?.UpdateDeviceState(hasBluetooth, hasWifi); }); } + else + { + // 服务器超时/网络异常:无论初始 wasOnline 状态,都强制刷新 UI 一次以确保图标同步 + pendingUIUpdates.Add(() => + { + deviceState?.UpdateDeviceState(hasBluetooth, hasWifi); + }); + } } else if (online && !wasOnline && !hasBluetooth) {