2394 lines
76 KiB
C#
2394 lines
76 KiB
C#
|
|
using System;
|
|||
|
|
using System.Collections.Generic;
|
|||
|
|
using System.IO;
|
|||
|
|
using System.Linq;
|
|||
|
|
using System.Text;
|
|||
|
|
using UnityEngine;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// BFI测试历史记录管理服务
|
|||
|
|
/// 负责保存、加载和管理历史测试记录
|
|||
|
|
/// </summary>
|
|||
|
|
public class BFIHistoryManager : MonoBehaviour
|
|||
|
|
{
|
|||
|
|
private const int MIN_HISTORY_RECORD_LIMIT = 1000;
|
|||
|
|
private const string HISTORY_FILE_NAME = "bfi_history.json";
|
|||
|
|
private const string HISTORY_BACKUP_FILE_NAME = "bfi_history_backup.json";
|
|||
|
|
private const string HISTORY_RECORDS_DIR_NAME = "bfi_history_records";
|
|||
|
|
private const string CURRENT_RECORDING_JOURNAL_FILE_NAME = "bfi_current_recording.jsonl";
|
|||
|
|
private const int HISTORY_INDEX_VERSION = 2;
|
|||
|
|
private const string HISTORY_STORAGE_MODE_PER_RECORD = "PerRecordFiles";
|
|||
|
|
private const string RECORD_BACKUP_EXTENSION = ".bak";
|
|||
|
|
private static readonly string[] HISTORY_CANDIDATE_FILE_NAMES = { HISTORY_FILE_NAME, HISTORY_BACKUP_FILE_NAME };
|
|||
|
|
|
|||
|
|
private static BFIHistoryManager _instance;
|
|||
|
|
public static BFIHistoryManager Instance
|
|||
|
|
{
|
|||
|
|
get
|
|||
|
|
{
|
|||
|
|
if (_instance == null)
|
|||
|
|
{
|
|||
|
|
var go = new GameObject("BFIHistoryManager");
|
|||
|
|
_instance = go.AddComponent<BFIHistoryManager>();
|
|||
|
|
DontDestroyOnLoad(go);
|
|||
|
|
}
|
|||
|
|
return _instance;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Header("配置")]
|
|||
|
|
[SerializeField] private int maxHistoryRecords = 1000; // 最大历史记录数
|
|||
|
|
[SerializeField] private bool autoSave = true; // 自动保存
|
|||
|
|
[SerializeField] private float saveInterval = 60f; // 保存间隔(秒)
|
|||
|
|
|
|||
|
|
private List<BFITestRecord> historyRecords;
|
|||
|
|
private string historyFilePath;
|
|||
|
|
private string historyMirrorFilePath;
|
|||
|
|
private string historyRecordsDirectoryPath;
|
|||
|
|
private float lastSaveTime;
|
|||
|
|
private bool historyFileLoadFailed;
|
|||
|
|
private bool androidStoragePermissionRequested;
|
|||
|
|
private bool historyIndexDirty;
|
|||
|
|
|
|||
|
|
// 事件
|
|||
|
|
public event System.Action<BFITestRecord> OnRecordAdded;
|
|||
|
|
public event System.Action<BFITestRecord> OnRecordUpdated;
|
|||
|
|
public event System.Action<string> OnRecordRemoved;
|
|||
|
|
public event System.Action OnHistoryLoaded;
|
|||
|
|
// public bool LastSaveSucceeded { get; private set; }
|
|||
|
|
|
|||
|
|
private void Awake()
|
|||
|
|
{
|
|||
|
|
if (_instance == null)
|
|||
|
|
{
|
|||
|
|
_instance = this;
|
|||
|
|
DontDestroyOnLoad(gameObject);
|
|||
|
|
Initialize();
|
|||
|
|
}
|
|||
|
|
else if (_instance != this)
|
|||
|
|
{
|
|||
|
|
Destroy(gameObject);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void Initialize()
|
|||
|
|
{
|
|||
|
|
maxHistoryRecords = Mathf.Max(maxHistoryRecords, MIN_HISTORY_RECORD_LIMIT);
|
|||
|
|
historyRecords = new List<BFITestRecord>();
|
|||
|
|
|
|||
|
|
// 历史索引是导出面板的数据源,主文件使用Unity持久化目录,和报警记录一致。
|
|||
|
|
// historyMirrorFilePath仅用于清理旧版本曾写入Download的镜像文件。
|
|||
|
|
historyFilePath = ResolveHistoryFilePath();
|
|||
|
|
historyMirrorFilePath = ResolveHistoryMirrorFilePath();
|
|||
|
|
historyRecordsDirectoryPath = GetRecordFilesDirectory(historyFilePath);
|
|||
|
|
EnsureHistoryDirectoryExists();
|
|||
|
|
|
|||
|
|
LoadHistoryRecords(true, true);
|
|||
|
|
|
|||
|
|
Debug.Log($"BFI历史管理器已初始化,历史文件路径: {historyFilePath}, 单条记录目录: {historyRecordsDirectoryPath}");
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
AndroidFileLogger.Instance?.LogInfo("BFIHistory", $"历史文件路径: {historyFilePath}");
|
|||
|
|
AndroidFileLogger.Instance?.LogInfo("BFIHistory", $"单条记录目录: {historyRecordsDirectoryPath}");
|
|||
|
|
#endif
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string ResolveHistoryFilePath()
|
|||
|
|
{
|
|||
|
|
return Path.Combine(Application.persistentDataPath, HISTORY_FILE_NAME);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string ResolveHistoryMirrorFilePath()
|
|||
|
|
{
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
return Path.Combine(GetAndroidPublicDownloadDirectory(), "DCX_Export", HISTORY_FILE_NAME);
|
|||
|
|
#else
|
|||
|
|
return null;
|
|||
|
|
#endif
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public string GetHistoryFilePath()
|
|||
|
|
{
|
|||
|
|
return historyFilePath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public string GetHistoryMirrorFilePath()
|
|||
|
|
{
|
|||
|
|
return historyMirrorFilePath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public string GetHistoryRecordsDirectoryPath()
|
|||
|
|
{
|
|||
|
|
return historyRecordsDirectoryPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
private string GetAndroidPublicDownloadDirectory()
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
using (var environment = new AndroidJavaClass("android.os.Environment"))
|
|||
|
|
using (var downloadDir = environment.CallStatic<AndroidJavaObject>(
|
|||
|
|
"getExternalStoragePublicDirectory",
|
|||
|
|
environment.GetStatic<string>("DIRECTORY_DOWNLOADS")))
|
|||
|
|
{
|
|||
|
|
string path = downloadDir?.Call<string>("getAbsolutePath");
|
|||
|
|
if (!string.IsNullOrEmpty(path))
|
|||
|
|
{
|
|||
|
|
return path;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"获取Android公共Download目录失败,回退persistentDataPath: {ex.Message}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Application.persistentDataPath;
|
|||
|
|
}
|
|||
|
|
#endif
|
|||
|
|
|
|||
|
|
private void EnsureHistoryDirectoryExists()
|
|||
|
|
{
|
|||
|
|
EnsureHistoryDirectoryExists(historyFilePath, false);
|
|||
|
|
EnsureDirectoryExists(historyRecordsDirectoryPath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool EnsureDirectoryExists(string directory)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(directory))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Directory.Exists(directory))
|
|||
|
|
{
|
|||
|
|
Directory.CreateDirectory(directory);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"创建目录失败: {directory}, {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool EnsureHistoryDirectoryExists(string filePath, bool requestExternalPermission)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(filePath))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string dir = Path.GetDirectoryName(filePath);
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
if (requestExternalPermission)
|
|||
|
|
{
|
|||
|
|
EnsureAndroidDownloadPermission(dir);
|
|||
|
|
}
|
|||
|
|
#endif
|
|||
|
|
|
|||
|
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
|||
|
|
{
|
|||
|
|
Directory.CreateDirectory(dir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"创建历史记录目录失败: {filePath}, {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
private void EnsureAndroidDownloadPermission(string directory)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(directory) || !directory.Contains("/Download"))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
using (var buildVersion = new AndroidJavaClass("android.os.Build$VERSION"))
|
|||
|
|
{
|
|||
|
|
int sdkInt = buildVersion.GetStatic<int>("SDK_INT");
|
|||
|
|
if (sdkInt >= 30 && !AndroidExportPermissionManager.HasManageExternalStoragePermission())
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning("BFI历史索引镜像保存到Download需要管理外部存储权限;主索引仍保存在应用内部目录");
|
|||
|
|
if (!androidStoragePermissionRequested)
|
|||
|
|
{
|
|||
|
|
androidStoragePermissionRequested = true;
|
|||
|
|
AndroidExportPermissionManager.RequestManageExternalStoragePermission();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (sdkInt <= 29 && !AndroidExportPermissionManager.HasWriteExternalStoragePermission())
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning("BFI历史索引镜像保存到Download需要写入外部存储权限;主索引仍保存在应用内部目录");
|
|||
|
|
if (!androidStoragePermissionRequested)
|
|||
|
|
{
|
|||
|
|
androidStoragePermissionRequested = true;
|
|||
|
|
AndroidExportPermissionManager.RequestWriteExternalStoragePermission();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"检查Download写入权限失败: {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
#endif
|
|||
|
|
|
|||
|
|
private void Update()
|
|||
|
|
{
|
|||
|
|
// 自动保存
|
|||
|
|
if (autoSave && Time.time - lastSaveTime > saveInterval)
|
|||
|
|
{
|
|||
|
|
if (!historyIndexDirty || SaveHistoryIndex())
|
|||
|
|
{
|
|||
|
|
historyIndexDirty = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lastSaveTime = Time.time;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 添加新的测试记录
|
|||
|
|
/// </summary>
|
|||
|
|
public bool AddTestRecord(BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
if (record == null)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning("尝试添加空的测试记录");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (historyRecords == null)
|
|||
|
|
{
|
|||
|
|
historyRecords = new List<BFITestRecord>();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
|
|||
|
|
// 检查是否已存在
|
|||
|
|
var existingRecord = historyRecords.FirstOrDefault(r => r.TestId == record.TestId);
|
|||
|
|
if (existingRecord != null)
|
|||
|
|
{
|
|||
|
|
if (!ShouldReplaceRecord(existingRecord, record))
|
|||
|
|
{
|
|||
|
|
bool existingPersisted = IsRecordPersisted(existingRecord.TestId, existingRecord.DataPoints?.Count ?? 0);
|
|||
|
|
if (!existingPersisted)
|
|||
|
|
{
|
|||
|
|
existingPersisted = SaveHistoryRecordFile(existingRecord);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
SaveHistoryIndex();
|
|||
|
|
Debug.Log($"忽略较旧的BFI记录快照: {record.TestName}");
|
|||
|
|
return existingPersisted;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 关键原则:先写实体文件,写成功后才进入内存历史,避免界面显示“假记录”。
|
|||
|
|
bool recordSaved = SaveHistoryRecordFile(record);
|
|||
|
|
if (!recordSaved)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"BFI记录实体文件保存失败,已拒绝加入内存历史: {record.TestName}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (existingRecord != null)
|
|||
|
|
{
|
|||
|
|
int index = historyRecords.IndexOf(existingRecord);
|
|||
|
|
historyRecords[index] = record;
|
|||
|
|
OnRecordUpdated?.Invoke(record);
|
|||
|
|
Debug.Log($"更新测试记录: {record.TestName}");
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
// 添加新记录
|
|||
|
|
historyRecords.Add(record);
|
|||
|
|
OnRecordAdded?.Invoke(record);
|
|||
|
|
Debug.Log($"添加新测试记录: {record.TestName}");
|
|||
|
|
|
|||
|
|
// 检查记录数量限制
|
|||
|
|
if (historyRecords.Count > maxHistoryRecords)
|
|||
|
|
{
|
|||
|
|
RemoveOldestRecord();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 刷新轻量索引。记录实体文件已经先保存,索引失败也不会丢失记录本体。
|
|||
|
|
bool indexSaved = SaveHistoryIndex();
|
|||
|
|
if (!indexSaved)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"BFI历史索引保存失败,但记录实体文件已保存: {record.TestName}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return recordSaved;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"添加测试记录失败: {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 保存自动记录中的中间快照。实时逐点日志已经承担断电恢复职责,
|
|||
|
|
/// 这里避免每次快照都重写索引和反读整条记录,降低安卓板主线程IO压力。
|
|||
|
|
/// </summary>
|
|||
|
|
public bool AddTestRecordSnapshot(BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
if (record == null)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (historyRecords == null)
|
|||
|
|
{
|
|||
|
|
historyRecords = new List<BFITestRecord>();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
|
|||
|
|
var existingRecord = historyRecords.FirstOrDefault(r => r.TestId == record.TestId);
|
|||
|
|
if (existingRecord != null && !ShouldReplaceRecord(existingRecord, record))
|
|||
|
|
{
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool recordSaved = SaveHistoryRecordFile(record, verifyAfterWrite: false, verboseLog: false);
|
|||
|
|
if (!recordSaved)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (existingRecord != null)
|
|||
|
|
{
|
|||
|
|
int index = historyRecords.IndexOf(existingRecord);
|
|||
|
|
historyRecords[index] = record;
|
|||
|
|
OnRecordUpdated?.Invoke(record);
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
historyRecords.Add(record);
|
|||
|
|
OnRecordAdded?.Invoke(record);
|
|||
|
|
|
|||
|
|
if (historyRecords.Count > maxHistoryRecords)
|
|||
|
|
{
|
|||
|
|
RemoveOldestRecord();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
historyIndexDirty = true;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"保存BFI记录快照失败: {record?.TestName}, {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 移除测试记录
|
|||
|
|
/// </summary>
|
|||
|
|
public bool RemoveTestRecord(string testId)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
var record = historyRecords.FirstOrDefault(r => r.TestId == testId);
|
|||
|
|
if (record != null)
|
|||
|
|
{
|
|||
|
|
historyRecords.Remove(record);
|
|||
|
|
DeleteHistoryRecordFile(record);
|
|||
|
|
OnRecordRemoved?.Invoke(testId);
|
|||
|
|
SaveHistoryIndex();
|
|||
|
|
Debug.Log($"移除测试记录: {record.TestName}");
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"移除测试记录失败: {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 获取所有历史记录
|
|||
|
|
/// </summary>
|
|||
|
|
public List<BFITestRecord> GetAllRecords()
|
|||
|
|
{
|
|||
|
|
return GetRecordsSortedByRecentFirst(historyRecords);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 根据ID获取记录
|
|||
|
|
/// </summary>
|
|||
|
|
public BFITestRecord GetRecord(string testId)
|
|||
|
|
{
|
|||
|
|
return historyRecords.FirstOrDefault(r => r.TestId == testId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public BFITestRecord GetFullRecord(string testId)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(testId))
|
|||
|
|
{
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var cachedRecord = GetRecord(testId);
|
|||
|
|
if (cachedRecord != null && cachedRecord.DataPoints != null && cachedRecord.DataPoints.Count > 0)
|
|||
|
|
{
|
|||
|
|
return cachedRecord;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string defaultRecordFilePath = GetHistoryRecordFilePath(testId);
|
|||
|
|
string recordFilePath = !string.IsNullOrEmpty(cachedRecord?.FilePath)
|
|||
|
|
? cachedRecord.FilePath
|
|||
|
|
: defaultRecordFilePath;
|
|||
|
|
|
|||
|
|
BFITestRecord bestRecord = cachedRecord;
|
|||
|
|
if (TryReadHistoryRecord(recordFilePath, out var record))
|
|||
|
|
{
|
|||
|
|
bestRecord = ChooseRecordWithMoreData(bestRecord, record);
|
|||
|
|
if (HasDataPoints(record))
|
|||
|
|
{
|
|||
|
|
AddOrReplaceRecoveredRecordToList(historyRecords, record);
|
|||
|
|
return record;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!string.Equals(recordFilePath, defaultRecordFilePath, StringComparison.OrdinalIgnoreCase) &&
|
|||
|
|
TryReadHistoryRecord(defaultRecordFilePath, out record))
|
|||
|
|
{
|
|||
|
|
bestRecord = ChooseRecordWithMoreData(bestRecord, record);
|
|||
|
|
if (HasDataPoints(record))
|
|||
|
|
{
|
|||
|
|
AddOrReplaceRecoveredRecordToList(historyRecords, record);
|
|||
|
|
return record;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (TryFindBestHistoryRecordById(testId, out var recoveredRecord, out var recoveredPath))
|
|||
|
|
{
|
|||
|
|
recoveredRecord.FilePath = recoveredPath;
|
|||
|
|
AddOrReplaceRecoveredRecordToList(historyRecords, recoveredRecord);
|
|||
|
|
|
|||
|
|
if (!string.Equals(recoveredPath, defaultRecordFilePath, StringComparison.OrdinalIgnoreCase))
|
|||
|
|
{
|
|||
|
|
SaveHistoryRecordFile(recoveredRecord, verifyAfterWrite: true, verboseLog: false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Debug.Log($"已为BFI导出恢复完整记录: {recoveredRecord.TestName}, 点数: {recoveredRecord.DataPoints?.Count ?? 0}");
|
|||
|
|
return recoveredRecord;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (bestRecord != null)
|
|||
|
|
{
|
|||
|
|
AddOrReplaceRecoveredRecordToList(historyRecords, bestRecord);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Debug.LogWarning($"未找到完整BFI记录明细: ID={testId}, 文件={recordFilePath}");
|
|||
|
|
return bestRecord;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 根据日期范围获取记录
|
|||
|
|
/// </summary>
|
|||
|
|
public List<BFITestRecord> GetRecordsByDateRange(DateTime startDate, DateTime endDate)
|
|||
|
|
{
|
|||
|
|
return GetRecordsSortedByRecentFirst(historyRecords.Where(r => r.StartTime >= startDate && r.StartTime <= endDate));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 根据患者ID获取记录
|
|||
|
|
/// </summary>
|
|||
|
|
public List<BFITestRecord> GetRecordsByPatient(string patientId)
|
|||
|
|
{
|
|||
|
|
return GetRecordsSortedByRecentFirst(historyRecords.Where(r => r.PatientId == patientId));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 搜索记录
|
|||
|
|
/// </summary>
|
|||
|
|
public List<BFITestRecord> SearchRecords(string keyword)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(keyword))
|
|||
|
|
return GetAllRecords();
|
|||
|
|
|
|||
|
|
keyword = keyword.ToLower();
|
|||
|
|
return GetRecordsSortedByRecentFirst(historyRecords.Where(r =>
|
|||
|
|
r.TestName.ToLower().Contains(keyword) ||
|
|||
|
|
r.PatientName?.ToLower().Contains(keyword) == true ||
|
|||
|
|
r.PatientId?.ToLower().Contains(keyword) == true
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 获取最近的记录
|
|||
|
|
/// </summary>
|
|||
|
|
public List<BFITestRecord> GetRecentRecords(int count = 10)
|
|||
|
|
{
|
|||
|
|
return GetRecordsSortedByRecentFirst(historyRecords).Take(count).ToList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private List<BFITestRecord> GetRecordsSortedByRecentFirst(IEnumerable<BFITestRecord> records)
|
|||
|
|
{
|
|||
|
|
return (records ?? Enumerable.Empty<BFITestRecord>())
|
|||
|
|
.Where(r => r != null)
|
|||
|
|
.OrderByDescending(r => r.StartTime)
|
|||
|
|
.ThenByDescending(r => r.EndTime)
|
|||
|
|
.ToList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 清空历史记录
|
|||
|
|
/// </summary>
|
|||
|
|
public void ClearHistory()
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
historyRecords.Clear();
|
|||
|
|
historyFileLoadFailed = false;
|
|||
|
|
historyIndexDirty = false;
|
|||
|
|
DeleteAllHistoryStorageFiles();
|
|||
|
|
SaveHistoryRecords();
|
|||
|
|
OnHistoryLoaded?.Invoke();
|
|||
|
|
Debug.Log("历史记录已清空");
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"清空历史记录失败: {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 保存历史记录到文件
|
|||
|
|
/// </summary>
|
|||
|
|
public bool SaveHistoryRecords()
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (historyFileLoadFailed && (historyRecords == null || historyRecords.Count == 0))
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning("历史文件加载失败,跳过空历史保存,避免覆盖已有索引");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
EnsureHistoryDirectoryExists();
|
|||
|
|
|
|||
|
|
bool allRecordsSaved = true;
|
|||
|
|
if (historyRecords != null)
|
|||
|
|
{
|
|||
|
|
foreach (var record in historyRecords)
|
|||
|
|
{
|
|||
|
|
if (record == null)
|
|||
|
|
{
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
int pointCount = record.DataPoints?.Count ?? 0;
|
|||
|
|
if (!IsRecordPersisted(record.TestId, pointCount))
|
|||
|
|
{
|
|||
|
|
allRecordsSaved &= SaveHistoryRecordFile(record);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool indexSaved = SaveHistoryIndex();
|
|||
|
|
historyFileLoadFailed = false;
|
|||
|
|
// LastSaveSucceeded = true;
|
|||
|
|
|
|||
|
|
Debug.Log($"历史记录已保存,共 {historyRecords?.Count ?? 0} 条记录,单条文件目录: {historyRecordsDirectoryPath}");
|
|||
|
|
return allRecordsSaved && indexSaved;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
// LastSaveSucceeded = false;
|
|||
|
|
Debug.LogError($"保存历史记录失败: {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool SaveHistoryIndex()
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
EnsureHistoryDirectoryExists();
|
|||
|
|
|
|||
|
|
var indexEntries = new List<BFIHistoryRecordIndexEntry>();
|
|||
|
|
if (historyRecords != null)
|
|||
|
|
{
|
|||
|
|
foreach (var record in historyRecords.Where(r => r != null))
|
|||
|
|
{
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
indexEntries.Add(CreateIndexEntry(record));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var wrapper = new BFIHistoryWrapper
|
|||
|
|
{
|
|||
|
|
Version = HISTORY_INDEX_VERSION,
|
|||
|
|
StorageMode = HISTORY_STORAGE_MODE_PER_RECORD,
|
|||
|
|
Records = new List<BFITestRecord>(),
|
|||
|
|
RecordFiles = indexEntries
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
string json = JsonUtility.ToJson(wrapper, true);
|
|||
|
|
WriteAllTextAtomic(historyFilePath, json);
|
|||
|
|
historyIndexDirty = false;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"保存BFI历史索引失败: {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private BFIHistoryRecordIndexEntry CreateIndexEntry(BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
string defaultFilePath = GetHistoryRecordFilePath(record);
|
|||
|
|
string filePath = string.IsNullOrEmpty(record.FilePath) ? defaultFilePath : record.FilePath;
|
|||
|
|
string fileName = GetIndexEntryFileName(filePath, defaultFilePath);
|
|||
|
|
int pointCount = record.DataPoints?.Count ?? 0;
|
|||
|
|
bool summaryOnlyRecord = pointCount == 0 && (record.Statistics?.Count ?? 0) > 0;
|
|||
|
|
if (summaryOnlyRecord)
|
|||
|
|
{
|
|||
|
|
pointCount = record.Statistics.Count;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return new BFIHistoryRecordIndexEntry
|
|||
|
|
{
|
|||
|
|
TestId = record.TestId,
|
|||
|
|
TestName = record.TestName,
|
|||
|
|
PatientId = record.PatientId,
|
|||
|
|
PatientName = record.PatientName,
|
|||
|
|
FileName = fileName,
|
|||
|
|
StartTimeTicks = record.StartTimeTicks,
|
|||
|
|
EndTimeTicks = record.EndTimeTicks,
|
|||
|
|
PointCount = pointCount,
|
|||
|
|
MinValue = record.Statistics?.MinValue ?? 0f,
|
|||
|
|
MaxValue = record.Statistics?.MaxValue ?? 0f,
|
|||
|
|
Average = record.Statistics?.Average ?? 0f
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string GetIndexEntryFileName(string filePath, string defaultFilePath)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(filePath))
|
|||
|
|
{
|
|||
|
|
return Path.GetFileName(defaultFilePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string directory = Path.GetDirectoryName(filePath);
|
|||
|
|
if (!string.IsNullOrEmpty(directory) &&
|
|||
|
|
!string.IsNullOrEmpty(historyRecordsDirectoryPath) &&
|
|||
|
|
string.Equals(
|
|||
|
|
Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
|||
|
|
Path.GetFullPath(historyRecordsDirectoryPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
|||
|
|
StringComparison.OrdinalIgnoreCase))
|
|||
|
|
{
|
|||
|
|
return Path.GetFileName(filePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Path.IsPathRooted(filePath) ? filePath : Path.GetFileName(filePath);
|
|||
|
|
}
|
|||
|
|
catch
|
|||
|
|
{
|
|||
|
|
return Path.GetFileName(defaultFilePath);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool SaveHistoryRecordFile(BFITestRecord record, bool verifyAfterWrite = true, bool verboseLog = true)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (record == null)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
string filePath = GetHistoryRecordFilePath(record);
|
|||
|
|
record.FilePath = filePath;
|
|||
|
|
|
|||
|
|
if ((record.DataPoints == null || record.DataPoints.Count == 0) &&
|
|||
|
|
(record.Statistics?.Count ?? 0) > 0)
|
|||
|
|
{
|
|||
|
|
if (TryFindBestHistoryRecordById(record.TestId, out var recoveredRecord, out var recoveredPath))
|
|||
|
|
{
|
|||
|
|
recoveredRecord.FilePath = recoveredPath;
|
|||
|
|
MergeRecordMetadata(recoveredRecord, record);
|
|||
|
|
|
|||
|
|
if (!string.Equals(recoveredPath, filePath, StringComparison.OrdinalIgnoreCase))
|
|||
|
|
{
|
|||
|
|
return SaveHistoryRecordFile(recoveredRecord, verifyAfterWrite, verboseLog);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
MergeRecordMetadata(record, recoveredRecord);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Debug.LogWarning($"跳过保存仅摘要BFI记录实体文件,避免覆盖明细数据: {record.TestName}, {filePath}");
|
|||
|
|
return IsRecordPersisted(record.TestId, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (TryPromoteExistingTempRecordFile(record, filePath))
|
|||
|
|
{
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string json = JsonUtility.ToJson(record, true);
|
|||
|
|
WriteAllTextAtomic(filePath, json);
|
|||
|
|
|
|||
|
|
if (!verifyAfterWrite)
|
|||
|
|
{
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool persisted = IsRecordPersisted(record.TestId, record.DataPoints?.Count ?? 0);
|
|||
|
|
if (!persisted)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"BFI记录写入后校验失败: {record.TestName}, {filePath}");
|
|||
|
|
}
|
|||
|
|
else if (verboseLog)
|
|||
|
|
{
|
|||
|
|
Debug.Log($"BFI记录实体文件已保存: {filePath}");
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
AndroidFileLogger.Instance?.LogInfo("BFIHistory", $"记录实体文件已保存: {filePath}, 点数: {record.DataPoints?.Count ?? 0}");
|
|||
|
|
#endif
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return persisted;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"保存BFI单条历史记录失败: {record?.TestName}, {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool TryPromoteExistingTempRecordFile(BFITestRecord record, string filePath)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (record == null || string.IsNullOrEmpty(filePath))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string tempPath = filePath + ".tmp";
|
|||
|
|
if (!File.Exists(tempPath))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!TryReadHistoryRecordFile(tempPath, out var tempRecord) ||
|
|||
|
|
!string.Equals(tempRecord.TestId, record.TestId, StringComparison.Ordinal))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int tempPointCount = tempRecord.DataPoints?.Count ?? 0;
|
|||
|
|
int recordPointCount = record.DataPoints?.Count ?? 0;
|
|||
|
|
if (tempPointCount < recordPointCount)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (File.Exists(filePath))
|
|||
|
|
{
|
|||
|
|
if (IsRecordPersisted(record.TestId, recordPointCount))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
TryCopyFile(filePath, filePath + RECORD_BACKUP_EXTENSION);
|
|||
|
|
File.Copy(tempPath, filePath, true);
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
File.Move(tempPath, filePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool persisted = IsRecordPersisted(record.TestId, recordPointCount);
|
|||
|
|
if (persisted)
|
|||
|
|
{
|
|||
|
|
DeleteFileIfExists(tempPath);
|
|||
|
|
Debug.Log($"已将断电遗留BFI临时记录转正: {filePath}");
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
AndroidFileLogger.Instance?.LogInfo("BFIHistory", $"已将断电遗留BFI临时记录转正: {filePath}");
|
|||
|
|
#endif
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return persisted;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"转正BFI临时记录失败,将尝试重新写入: {filePath}, {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public bool IsRecordPersisted(string testId, int minPointCount = 0)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(testId))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string filePath = GetHistoryRecordFilePath(testId);
|
|||
|
|
if (!TryReadHistoryRecord(filePath, out var savedRecord))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!string.Equals(savedRecord.TestId, testId, StringComparison.Ordinal))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (savedRecord.DataPoints?.Count ?? 0) >= minPointCount;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"确认BFI记录持久化失败: {testId}, {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool ShouldReplaceRecord(BFITestRecord existingRecord, BFITestRecord candidateRecord)
|
|||
|
|
{
|
|||
|
|
if (existingRecord == null)
|
|||
|
|
{
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (candidateRecord == null)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(existingRecord);
|
|||
|
|
NormalizeRecordForStorage(candidateRecord);
|
|||
|
|
|
|||
|
|
int existingPointCount = existingRecord.DataPoints?.Count ?? 0;
|
|||
|
|
int candidatePointCount = candidateRecord.DataPoints?.Count ?? 0;
|
|||
|
|
if (candidatePointCount != existingPointCount)
|
|||
|
|
{
|
|||
|
|
return candidatePointCount > existingPointCount;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return candidateRecord.EndTime >= existingRecord.EndTime;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool HasDataPoints(BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
return record?.DataPoints != null && record.DataPoints.Count > 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private BFITestRecord ChooseRecordWithMoreData(BFITestRecord current, BFITestRecord candidate)
|
|||
|
|
{
|
|||
|
|
if (current == null)
|
|||
|
|
{
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (candidate == null)
|
|||
|
|
{
|
|||
|
|
return current;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int currentPointCount = current.DataPoints?.Count ?? 0;
|
|||
|
|
int candidatePointCount = candidate.DataPoints?.Count ?? 0;
|
|||
|
|
if (candidatePointCount != currentPointCount)
|
|||
|
|
{
|
|||
|
|
return candidatePointCount > currentPointCount ? candidate : current;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int currentSummaryCount = current.Statistics?.Count ?? 0;
|
|||
|
|
int candidateSummaryCount = candidate.Statistics?.Count ?? 0;
|
|||
|
|
if (candidateSummaryCount != currentSummaryCount)
|
|||
|
|
{
|
|||
|
|
return candidateSummaryCount > currentSummaryCount ? candidate : current;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return candidate.EndTime > current.EndTime ? candidate : current;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool TryFindBestHistoryRecordById(string testId, out BFITestRecord bestRecord, out string bestRecordPath)
|
|||
|
|
{
|
|||
|
|
bestRecord = null;
|
|||
|
|
bestRecordPath = null;
|
|||
|
|
|
|||
|
|
if (string.IsNullOrEmpty(testId))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
int bestPointCount = 0;
|
|||
|
|
foreach (var recordFilePath in FindHistoryRecordFiles(true))
|
|||
|
|
{
|
|||
|
|
if (!TryReadHistoryRecordFile(recordFilePath, out var candidate) ||
|
|||
|
|
!string.Equals(candidate.TestId, testId, StringComparison.Ordinal))
|
|||
|
|
{
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int candidatePointCount = candidate.DataPoints?.Count ?? 0;
|
|||
|
|
if (candidatePointCount <= 0)
|
|||
|
|
{
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (bestRecord == null || candidatePointCount > bestPointCount)
|
|||
|
|
{
|
|||
|
|
bestRecord = candidate;
|
|||
|
|
bestRecordPath = recordFilePath;
|
|||
|
|
bestPointCount = candidatePointCount;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"搜索完整BFI记录失败: {testId}, {ex.Message}");
|
|||
|
|
bestRecord = null;
|
|||
|
|
bestRecordPath = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return bestRecord != null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool AreAllHistoryRecordsPersistedAsFiles()
|
|||
|
|
{
|
|||
|
|
if (historyRecords == null || historyRecords.Count == 0)
|
|||
|
|
{
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var record in historyRecords)
|
|||
|
|
{
|
|||
|
|
if (record == null)
|
|||
|
|
{
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
if (!IsRecordPersisted(record.TestId, record.DataPoints?.Count ?? 0))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void NormalizeRecordForStorage(BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
if (record == null)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (record.DataPoints == null)
|
|||
|
|
{
|
|||
|
|
record.DataPoints = new List<BFIDataPoint>();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (record.Statistics == null)
|
|||
|
|
{
|
|||
|
|
record.Statistics = new BFIStatistics();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool hasSummaryStatisticsWithoutPoints =
|
|||
|
|
record.DataPoints.Count == 0 &&
|
|||
|
|
record.Statistics.Count > 0;
|
|||
|
|
BFIStatistics preservedSummaryStatistics = hasSummaryStatisticsWithoutPoints
|
|||
|
|
? CloneBFIStatistics(record.Statistics)
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
record.RestoreAfterDeserialization();
|
|||
|
|
|
|||
|
|
if (hasSummaryStatisticsWithoutPoints && record.DataPoints.Count == 0)
|
|||
|
|
{
|
|||
|
|
record.Statistics = preservedSummaryStatistics;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (string.IsNullOrWhiteSpace(record.TestId))
|
|||
|
|
{
|
|||
|
|
record.TestId = GenerateStableRecordId(record);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (string.IsNullOrWhiteSpace(record.TestName))
|
|||
|
|
{
|
|||
|
|
record.TestName = $"BFI测试_{record.StartTime:yyyyMMdd_HHmmss}";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
record.PrepareForSerialization();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private BFIStatistics CloneBFIStatistics(BFIStatistics source)
|
|||
|
|
{
|
|||
|
|
if (source == null)
|
|||
|
|
{
|
|||
|
|
return new BFIStatistics();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return new BFIStatistics
|
|||
|
|
{
|
|||
|
|
Count = source.Count,
|
|||
|
|
MinValue = source.MinValue,
|
|||
|
|
MaxValue = source.MaxValue,
|
|||
|
|
Average = source.Average,
|
|||
|
|
StandardDeviation = source.StandardDeviation,
|
|||
|
|
Duration = source.Duration,
|
|||
|
|
LastUpdated = source.LastUpdated
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string GenerateStableRecordId(BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
long startTicks = record.StartTimeTicks > 0 ? record.StartTimeTicks : record.StartTime.Ticks;
|
|||
|
|
long endTicks = record.EndTimeTicks;
|
|||
|
|
int pointCount = record.DataPoints?.Count ?? 0;
|
|||
|
|
long firstPointTicks = 0;
|
|||
|
|
long lastPointTicks = 0;
|
|||
|
|
float firstBfi = 0f;
|
|||
|
|
float lastBfi = 0f;
|
|||
|
|
|
|||
|
|
if (record.DataPoints != null && record.DataPoints.Count > 0)
|
|||
|
|
{
|
|||
|
|
var firstPoint = record.DataPoints.FirstOrDefault(p => p != null);
|
|||
|
|
var lastPoint = record.DataPoints.LastOrDefault(p => p != null);
|
|||
|
|
firstPointTicks = firstPoint?.TimestampTicks ?? 0;
|
|||
|
|
lastPointTicks = lastPoint?.TimestampTicks ?? 0;
|
|||
|
|
firstBfi = firstPoint?.BFI ?? 0f;
|
|||
|
|
lastBfi = lastPoint?.BFI ?? 0f;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string identity = string.Join("|",
|
|||
|
|
record.TestName ?? string.Empty,
|
|||
|
|
record.PatientId ?? string.Empty,
|
|||
|
|
record.PatientName ?? string.Empty,
|
|||
|
|
startTicks.ToString(),
|
|||
|
|
endTicks.ToString(),
|
|||
|
|
pointCount.ToString(),
|
|||
|
|
firstPointTicks.ToString(),
|
|||
|
|
lastPointTicks.ToString(),
|
|||
|
|
firstBfi.ToString("R"),
|
|||
|
|
lastBfi.ToString("R"));
|
|||
|
|
|
|||
|
|
return $"legacy_{ComputeStableHash(identity):x16}";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private ulong ComputeStableHash(string value)
|
|||
|
|
{
|
|||
|
|
unchecked
|
|||
|
|
{
|
|||
|
|
const ulong offset = 14695981039346656037UL;
|
|||
|
|
const ulong prime = 1099511628211UL;
|
|||
|
|
ulong hash = offset;
|
|||
|
|
|
|||
|
|
foreach (char c in value ?? string.Empty)
|
|||
|
|
{
|
|||
|
|
hash ^= c;
|
|||
|
|
hash *= prime;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return hash;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string GetHistoryRecordFilePath(BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
return GetHistoryRecordFilePath(record.TestId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string GetHistoryRecordFilePath(string testId)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(historyRecordsDirectoryPath))
|
|||
|
|
{
|
|||
|
|
historyRecordsDirectoryPath = GetRecordFilesDirectory(historyFilePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string safeId = SanitizeFileComponent(string.IsNullOrEmpty(testId) ? Guid.NewGuid().ToString() : testId);
|
|||
|
|
return Path.Combine(historyRecordsDirectoryPath, $"{safeId}.json");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string SanitizeFileComponent(string value)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(value))
|
|||
|
|
{
|
|||
|
|
return "unknown";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var invalidChars = new HashSet<char>(Path.GetInvalidFileNameChars());
|
|||
|
|
var builder = new StringBuilder(value.Length);
|
|||
|
|
foreach (char c in value)
|
|||
|
|
{
|
|||
|
|
if (invalidChars.Contains(c))
|
|||
|
|
{
|
|||
|
|
builder.Append('_');
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
builder.Append(c);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return builder.Length == 0 ? "unknown" : builder.ToString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void DeleteHistoryRecordFile(BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
if (record == null || string.IsNullOrEmpty(record.TestId))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
DeleteHistoryRecordFile(record.TestId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void DeleteHistoryRecordFile(string testId)
|
|||
|
|
{
|
|||
|
|
string filePath = GetHistoryRecordFilePath(testId);
|
|||
|
|
DeleteFileIfExists(filePath);
|
|||
|
|
DeleteFileIfExists(filePath + RECORD_BACKUP_EXTENSION);
|
|||
|
|
DeleteFileIfExists(filePath + ".tmp");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddRecordsToMergeMap(Dictionary<string, BFITestRecord> recordsById, IEnumerable<BFITestRecord> records)
|
|||
|
|
{
|
|||
|
|
if (records == null)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var record in records)
|
|||
|
|
{
|
|||
|
|
AddOrReplaceRecoveredRecord(recordsById, record);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void DeleteAllHistoryStorageFiles()
|
|||
|
|
{
|
|||
|
|
var pathsToDelete = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|||
|
|
AddHistoryFileDeleteTargets(pathsToDelete, historyFilePath);
|
|||
|
|
AddHistoryFileDeleteTargets(pathsToDelete, historyMirrorFilePath);
|
|||
|
|
|
|||
|
|
var indexPaths = new List<string>();
|
|||
|
|
AddIndexPath(indexPaths, historyFilePath);
|
|||
|
|
AddIndexPath(indexPaths, historyMirrorFilePath);
|
|||
|
|
|
|||
|
|
foreach (var indexPath in FindHistoryIndexFiles(true))
|
|||
|
|
{
|
|||
|
|
AddIndexPath(indexPaths, indexPath);
|
|||
|
|
AddHistoryFileDeleteTargets(pathsToDelete, indexPath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var filePath in pathsToDelete)
|
|||
|
|
{
|
|||
|
|
DeleteFileIfExists(filePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var indexPath in indexPaths)
|
|||
|
|
{
|
|||
|
|
DeleteRecordDirectoryForIndex(indexPath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
DeleteAllRecordDirectoriesFromSearchRoots();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddIndexPath(List<string> indexPaths, string indexPath)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(indexPath))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!indexPaths.Any(path => string.Equals(path, indexPath, StringComparison.OrdinalIgnoreCase)))
|
|||
|
|
{
|
|||
|
|
indexPaths.Add(indexPath);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddHistoryFileDeleteTargets(HashSet<string> pathsToDelete, string indexFilePath)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(indexFilePath))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pathsToDelete.Add(indexFilePath);
|
|||
|
|
pathsToDelete.Add(indexFilePath + ".tmp");
|
|||
|
|
pathsToDelete.Add(indexFilePath + RECORD_BACKUP_EXTENSION);
|
|||
|
|
|
|||
|
|
string directory = Path.GetDirectoryName(indexFilePath);
|
|||
|
|
if (!string.IsNullOrEmpty(directory))
|
|||
|
|
{
|
|||
|
|
pathsToDelete.Add(Path.Combine(directory, HISTORY_BACKUP_FILE_NAME));
|
|||
|
|
pathsToDelete.Add(Path.Combine(directory, CURRENT_RECORDING_JOURNAL_FILE_NAME));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void DeleteRecordDirectoryForIndex(string indexFilePath)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
string recordsDirectory = GetRecordFilesDirectory(indexFilePath);
|
|||
|
|
if (!string.IsNullOrEmpty(recordsDirectory) && Directory.Exists(recordsDirectory))
|
|||
|
|
{
|
|||
|
|
Directory.Delete(recordsDirectory, true);
|
|||
|
|
Debug.Log($"已删除BFI单条记录目录: {recordsDirectory}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"删除BFI单条记录目录失败: {indexFilePath}, {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void DeleteAllRecordDirectoriesFromSearchRoots()
|
|||
|
|
{
|
|||
|
|
foreach (var root in GetHistoryIndexSearchRoots())
|
|||
|
|
{
|
|||
|
|
DeleteRecordDirectory(root);
|
|||
|
|
TryDeleteRecordDirectoriesOneLevel(root);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void TryDeleteRecordDirectoriesOneLevel(string root)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var directory in Directory.GetDirectories(root))
|
|||
|
|
{
|
|||
|
|
DeleteRecordDirectory(directory);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"搜索待删除BFI单条记录目录失败: {root}, {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void DeleteRecordDirectory(string root)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(root))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string recordsDirectory = Path.Combine(root, HISTORY_RECORDS_DIR_NAME);
|
|||
|
|
if (Directory.Exists(recordsDirectory))
|
|||
|
|
{
|
|||
|
|
Directory.Delete(recordsDirectory, true);
|
|||
|
|
Debug.Log($"已删除BFI单条记录目录: {recordsDirectory}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"删除BFI单条记录目录失败: {root}, {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void DeleteFileIfExists(string filePath)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath))
|
|||
|
|
{
|
|||
|
|
File.Delete(filePath);
|
|||
|
|
Debug.Log($"已删除BFI历史文件: {filePath}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"删除BFI历史文件失败: {filePath}, {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void WriteAllTextAtomic(string filePath, string content)
|
|||
|
|
{
|
|||
|
|
string directory = Path.GetDirectoryName(filePath);
|
|||
|
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
|||
|
|
{
|
|||
|
|
Directory.CreateDirectory(directory);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string tempPath = filePath + ".tmp";
|
|||
|
|
string backupPath = filePath + RECORD_BACKUP_EXTENSION;
|
|||
|
|
byte[] bytes = Encoding.UTF8.GetBytes(content ?? string.Empty);
|
|||
|
|
EnsureEnoughFreeSpace(filePath, bytes.Length);
|
|||
|
|
|
|||
|
|
DeleteFileIfExists(tempPath);
|
|||
|
|
|
|||
|
|
using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.Read, 4096))
|
|||
|
|
{
|
|||
|
|
stream.Write(bytes, 0, bytes.Length);
|
|||
|
|
FlushFileStream(stream);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (File.Exists(filePath))
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
TryCopyFile(filePath, backupPath);
|
|||
|
|
File.Delete(filePath);
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"备份旧BFI历史文件失败,将继续尝试覆盖: {filePath}, {ex.Message}");
|
|||
|
|
DeleteFileIfExists(filePath);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
File.Move(tempPath, filePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void FlushFileStream(FileStream stream)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
stream.Flush(true);
|
|||
|
|
}
|
|||
|
|
catch
|
|||
|
|
{
|
|||
|
|
stream.Flush();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void TryCopyFile(string sourcePath, string targetPath)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (!string.IsNullOrEmpty(sourcePath) && File.Exists(sourcePath))
|
|||
|
|
{
|
|||
|
|
File.Copy(sourcePath, targetPath, true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"备份BFI历史文件失败: {sourcePath}, {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void EnsureEnoughFreeSpace(string filePath, long requiredBytes)
|
|||
|
|
{
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
// Android 的 /storage/emulated/0 路径在部分系统上通过 DriveInfo 会返回 0MB,
|
|||
|
|
// 但实际 FileStream 仍然可以写入。这里不做硬拦截,真实空间不足交给实际写入报错。
|
|||
|
|
return;
|
|||
|
|
#else
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
string root = Path.GetPathRoot(Path.GetFullPath(filePath));
|
|||
|
|
if (string.IsNullOrEmpty(root))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var drive = new DriveInfo(root);
|
|||
|
|
const long safetyReserveBytes = 10L * 1024L * 1024L;
|
|||
|
|
long availableFreeSpace = drive.AvailableFreeSpace;
|
|||
|
|
if (availableFreeSpace <= 0)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"无法可靠获取BFI历史存储剩余空间,将继续尝试写入: {filePath}");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (drive.IsReady && availableFreeSpace < requiredBytes + safetyReserveBytes)
|
|||
|
|
{
|
|||
|
|
throw new IOException($"BFI历史存储空间不足,剩余 {availableFreeSpace / 1024 / 1024}MB,需要至少 {(requiredBytes + safetyReserveBytes) / 1024 / 1024}MB");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (IOException)
|
|||
|
|
{
|
|||
|
|
throw;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"检查BFI历史存储空间失败,将继续尝试写入: {ex.Message}");
|
|||
|
|
}
|
|||
|
|
#endif
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string GetRecordFilesDirectory(string indexFilePath)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(indexFilePath))
|
|||
|
|
{
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string indexDirectory = Path.GetDirectoryName(indexFilePath);
|
|||
|
|
return string.IsNullOrEmpty(indexDirectory) ? null : Path.Combine(indexDirectory, HISTORY_RECORDS_DIR_NAME);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 从文件加载历史记录
|
|||
|
|
/// </summary>
|
|||
|
|
public void LoadHistoryRecords(bool notifyListeners = true, bool recoverMissingIndex = true)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
maxHistoryRecords = Mathf.Max(maxHistoryRecords, MIN_HISTORY_RECORD_LIMIT);
|
|||
|
|
|
|||
|
|
if (!File.Exists(historyFilePath))
|
|||
|
|
{
|
|||
|
|
historyRecords = SafeRecoverHistoryRecordsFromIndexes(null, recoverMissingIndex);
|
|||
|
|
historyFileLoadFailed = false;
|
|||
|
|
Debug.Log($"历史索引文件不存在,{(historyRecords.Count > 0 ? "已从单条记录文件恢复" : "创建新列表")}");
|
|||
|
|
if (historyRecords.Count > 0)
|
|||
|
|
{
|
|||
|
|
SaveHistoryRecords();
|
|||
|
|
}
|
|||
|
|
if (notifyListeners)
|
|||
|
|
{
|
|||
|
|
OnHistoryLoaded?.Invoke();
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (TryReadHistoryRecords(historyFilePath, out var loadedRecords))
|
|||
|
|
{
|
|||
|
|
historyRecords = loadedRecords;
|
|||
|
|
historyFileLoadFailed = false;
|
|||
|
|
Debug.Log($"历史索引已加载,共 {historyRecords.Count} 条记录");
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
if (historyRecords == null)
|
|||
|
|
{
|
|||
|
|
historyRecords = new List<BFITestRecord>();
|
|||
|
|
}
|
|||
|
|
historyFileLoadFailed = false;
|
|||
|
|
Debug.Log($"历史索引为空或无法解析,保留内存记录 {historyRecords.Count} 条");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int beforeCount = historyRecords.Count;
|
|||
|
|
historyRecords = SafeRecoverHistoryRecordsFromIndexes(historyRecords, recoverMissingIndex);
|
|||
|
|
if (historyRecords.Count != beforeCount || !AreAllHistoryRecordsPersistedAsFiles())
|
|||
|
|
{
|
|||
|
|
Debug.Log($"历史记录文件恢复完成,恢复前 {beforeCount} 条,恢复后 {historyRecords.Count} 条");
|
|||
|
|
SaveHistoryRecords();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (notifyListeners)
|
|||
|
|
{
|
|||
|
|
OnHistoryLoaded?.Invoke();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"加载历史记录失败: {ex.Message}");
|
|||
|
|
historyRecords = SafeRecoverHistoryRecordsFromIndexes(null, recoverMissingIndex);
|
|||
|
|
historyFileLoadFailed = historyRecords.Count == 0;
|
|||
|
|
|
|||
|
|
if (historyRecords.Count > 0)
|
|||
|
|
{
|
|||
|
|
SaveHistoryRecords();
|
|||
|
|
if (notifyListeners)
|
|||
|
|
{
|
|||
|
|
OnHistoryLoaded?.Invoke();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private List<BFITestRecord> SafeRecoverHistoryRecordsFromIndexes(List<BFITestRecord> baseRecords, bool includeSearchRoots)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
return RecoverHistoryRecordsFromIndexes(baseRecords, includeSearchRoots);
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"恢复BFI历史索引失败: {ex.Message}");
|
|||
|
|
return baseRecords != null ? new List<BFITestRecord>(baseRecords) : new List<BFITestRecord>();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private List<BFITestRecord> RecoverHistoryRecordsFromIndexes(List<BFITestRecord> baseRecords, bool includeSearchRoots)
|
|||
|
|
{
|
|||
|
|
var mergedRecords = new Dictionary<string, BFITestRecord>();
|
|||
|
|
|
|||
|
|
if (baseRecords != null)
|
|||
|
|
{
|
|||
|
|
foreach (var record in baseRecords)
|
|||
|
|
{
|
|||
|
|
AddOrReplaceRecoveredRecord(mergedRecords, record);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var indexPath in FindHistoryIndexFiles(includeSearchRoots))
|
|||
|
|
{
|
|||
|
|
if (!TryReadHistoryRecords(indexPath, out var recoveredRecords))
|
|||
|
|
{
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var record in recoveredRecords)
|
|||
|
|
{
|
|||
|
|
AddOrReplaceRecoveredRecord(mergedRecords, record);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var recordFilePath in FindHistoryRecordFiles(includeSearchRoots))
|
|||
|
|
{
|
|||
|
|
if (TryReadHistoryRecord(recordFilePath, out var recoveredRecord))
|
|||
|
|
{
|
|||
|
|
AddOrReplaceRecoveredRecord(mergedRecords, recoveredRecord);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return mergedRecords.Values
|
|||
|
|
.OrderByDescending(r => r.StartTime)
|
|||
|
|
.Take(maxHistoryRecords)
|
|||
|
|
.OrderBy(r => r.StartTime)
|
|||
|
|
.ToList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool TryReadHistoryRecords(string filePath, out List<BFITestRecord> records)
|
|||
|
|
{
|
|||
|
|
records = new List<BFITestRecord>();
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string json = File.ReadAllText(filePath);
|
|||
|
|
if (string.IsNullOrWhiteSpace(json))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var wrapper = JsonUtility.FromJson<BFIHistoryWrapper>(json);
|
|||
|
|
if (wrapper == null)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool recognizedIndex = false;
|
|||
|
|
if (wrapper.Records != null)
|
|||
|
|
{
|
|||
|
|
recognizedIndex = true;
|
|||
|
|
foreach (var record in wrapper.Records.Where(record => record != null))
|
|||
|
|
{
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
records.Add(record);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (wrapper.RecordFiles != null)
|
|||
|
|
{
|
|||
|
|
recognizedIndex = true;
|
|||
|
|
foreach (var entry in wrapper.RecordFiles.Where(entry => entry != null))
|
|||
|
|
{
|
|||
|
|
string recordPath = ResolveIndexedRecordPath(filePath, entry);
|
|||
|
|
if (TryReadHistoryRecord(recordPath, out var record))
|
|||
|
|
{
|
|||
|
|
AddOrReplaceRecoveredRecordToList(records, record);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!recognizedIndex)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Debug.Log($"读取BFI历史索引成功: {filePath}, 记录数: {records.Count}");
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"读取BFI历史索引失败: {filePath}, {ex.Message}");
|
|||
|
|
records = new List<BFITestRecord>();
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool TryReadHistoryRecordSummaries(string filePath, out List<BFITestRecord> records, out bool repairedSummaries)
|
|||
|
|
{
|
|||
|
|
records = new List<BFITestRecord>();
|
|||
|
|
repairedSummaries = false;
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string json = File.ReadAllText(filePath);
|
|||
|
|
if (string.IsNullOrWhiteSpace(json))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var wrapper = JsonUtility.FromJson<BFIHistoryWrapper>(json);
|
|||
|
|
if (wrapper == null)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (wrapper.RecordFiles != null && wrapper.RecordFiles.Count > 0)
|
|||
|
|
{
|
|||
|
|
foreach (var entry in wrapper.RecordFiles.Where(entry => entry != null))
|
|||
|
|
{
|
|||
|
|
var summary = CreateSummaryRecordFromIndexEntry(entry, filePath);
|
|||
|
|
if (summary != null)
|
|||
|
|
{
|
|||
|
|
if (NeedsSummaryRepair(summary) && TryRepairSummaryFromRecordFile(summary, out var repairedSummary))
|
|||
|
|
{
|
|||
|
|
summary = repairedSummary;
|
|||
|
|
repairedSummaries = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
AddOrReplaceRecoveredRecordToList(records, summary);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Debug.Log($"读取BFI历史摘要成功: {filePath}, 记录数: {records.Count}");
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (wrapper.Records != null)
|
|||
|
|
{
|
|||
|
|
foreach (var record in wrapper.Records.Where(record => record != null))
|
|||
|
|
{
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
records.Add(record);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Debug.Log($"读取旧版BFI历史摘要成功: {filePath}, 记录数: {records.Count}");
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"读取BFI历史摘要失败: {filePath}, {ex.Message}");
|
|||
|
|
records = new List<BFITestRecord>();
|
|||
|
|
repairedSummaries = false;
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool NeedsSummaryRepair(BFITestRecord summary)
|
|||
|
|
{
|
|||
|
|
if (summary == null)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (summary.DataPoints != null && summary.DataPoints.Count > 0)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var stats = summary.Statistics;
|
|||
|
|
if (stats == null || stats.Count <= 0)
|
|||
|
|
{
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Mathf.Approximately(stats.MinValue, 0f) &&
|
|||
|
|
Mathf.Approximately(stats.MaxValue, 0f) &&
|
|||
|
|
Mathf.Approximately(stats.Average, 0f);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool TryRepairSummaryFromRecordFile(BFITestRecord summary, out BFITestRecord repairedSummary)
|
|||
|
|
{
|
|||
|
|
repairedSummary = null;
|
|||
|
|
string recordPath = summary?.FilePath;
|
|||
|
|
if (string.IsNullOrEmpty(recordPath))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!TryReadHistoryRecord(recordPath, out var fullRecord) ||
|
|||
|
|
fullRecord?.DataPoints == null ||
|
|||
|
|
fullRecord.DataPoints.Count == 0)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fullRecord.FilePath = recordPath;
|
|||
|
|
repairedSummary = CreateSummaryRecordFromFullRecord(fullRecord);
|
|||
|
|
Debug.Log($"已从实体文件修复BFI历史摘要: {fullRecord.TestName}, 点数: {fullRecord.DataPoints.Count}");
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private BFITestRecord CreateSummaryRecordFromFullRecord(BFITestRecord fullRecord)
|
|||
|
|
{
|
|||
|
|
if (fullRecord == null)
|
|||
|
|
{
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(fullRecord);
|
|||
|
|
return new BFITestRecord
|
|||
|
|
{
|
|||
|
|
TestId = fullRecord.TestId,
|
|||
|
|
TestName = fullRecord.TestName,
|
|||
|
|
PatientId = fullRecord.PatientId ?? string.Empty,
|
|||
|
|
PatientName = fullRecord.PatientName ?? string.Empty,
|
|||
|
|
PatientGender = fullRecord.PatientGender ?? string.Empty,
|
|||
|
|
PatientAge = fullRecord.PatientAge,
|
|||
|
|
PatientHeight = fullRecord.PatientHeight,
|
|||
|
|
PatientWeight = fullRecord.PatientWeight,
|
|||
|
|
StartTime = fullRecord.StartTime,
|
|||
|
|
StartTimeTicks = fullRecord.StartTimeTicks,
|
|||
|
|
EndTime = fullRecord.EndTime,
|
|||
|
|
EndTimeTicks = fullRecord.EndTimeTicks,
|
|||
|
|
FilePath = fullRecord.FilePath,
|
|||
|
|
DataPoints = new List<BFIDataPoint>(),
|
|||
|
|
Statistics = CloneBFIStatistics(fullRecord.Statistics)
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private BFITestRecord CreateSummaryRecordFromIndexEntry(BFIHistoryRecordIndexEntry entry, string indexFilePath)
|
|||
|
|
{
|
|||
|
|
if (entry == null)
|
|||
|
|
{
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
long startTicks = entry.StartTimeTicks > 0 ? entry.StartTimeTicks : DateTime.Now.Ticks;
|
|||
|
|
long endTicks = entry.EndTimeTicks > 0 ? entry.EndTimeTicks : 0;
|
|||
|
|
var startTime = new DateTime(startTicks, DateTimeKind.Local);
|
|||
|
|
var endTime = endTicks > 0 ? new DateTime(endTicks, DateTimeKind.Local) : DateTime.MinValue;
|
|||
|
|
|
|||
|
|
var record = new BFITestRecord
|
|||
|
|
{
|
|||
|
|
TestId = string.IsNullOrEmpty(entry.TestId) ? Guid.NewGuid().ToString() : entry.TestId,
|
|||
|
|
TestName = string.IsNullOrEmpty(entry.TestName) ? $"BFI测试_{startTime:yyyyMMdd_HHmmss}" : entry.TestName,
|
|||
|
|
PatientId = entry.PatientId ?? string.Empty,
|
|||
|
|
PatientName = entry.PatientName ?? string.Empty,
|
|||
|
|
StartTime = startTime,
|
|||
|
|
StartTimeTicks = startTicks,
|
|||
|
|
EndTime = endTime,
|
|||
|
|
EndTimeTicks = endTicks,
|
|||
|
|
FilePath = ResolveIndexedRecordPath(indexFilePath, entry),
|
|||
|
|
DataPoints = new List<BFIDataPoint>(),
|
|||
|
|
Statistics = new BFIStatistics
|
|||
|
|
{
|
|||
|
|
Count = entry.PointCount,
|
|||
|
|
MinValue = entry.MinValue,
|
|||
|
|
MaxValue = entry.MaxValue,
|
|||
|
|
Average = entry.Average,
|
|||
|
|
Duration = endTime > startTime ? (endTime - startTime).TotalMinutes : 0,
|
|||
|
|
LastUpdated = DateTime.Now
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return record;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddOrReplaceRecoveredRecordToList(List<BFITestRecord> records, BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
if (records == null || record == null)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
var existingRecord = records.FirstOrDefault(r => r != null && r.TestId == record.TestId);
|
|||
|
|
if (existingRecord == null)
|
|||
|
|
{
|
|||
|
|
records.Add(record);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (ShouldReplaceRecord(existingRecord, record))
|
|||
|
|
{
|
|||
|
|
int index = records.IndexOf(existingRecord);
|
|||
|
|
records[index] = record;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
MergeRecordMetadata(existingRecord, record);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void MergeRecordMetadata(BFITestRecord target, BFITestRecord source)
|
|||
|
|
{
|
|||
|
|
if (target == null || source == null)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (string.IsNullOrEmpty(target.FilePath) && !string.IsNullOrEmpty(source.FilePath))
|
|||
|
|
{
|
|||
|
|
target.FilePath = source.FilePath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool targetHasFullData = target.DataPoints != null && target.DataPoints.Count > 0;
|
|||
|
|
bool sourceHasSummaryStats = source.Statistics != null && source.Statistics.Count > 0;
|
|||
|
|
if (!targetHasFullData && sourceHasSummaryStats)
|
|||
|
|
{
|
|||
|
|
target.Statistics = CloneBFIStatistics(source.Statistics);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string ResolveIndexedRecordPath(string indexFilePath, BFIHistoryRecordIndexEntry entry)
|
|||
|
|
{
|
|||
|
|
if (entry == null)
|
|||
|
|
{
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!string.IsNullOrEmpty(entry.FileName) && Path.IsPathRooted(entry.FileName))
|
|||
|
|
{
|
|||
|
|
return entry.FileName;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string recordsDirectory = GetRecordFilesDirectory(indexFilePath);
|
|||
|
|
if (string.IsNullOrEmpty(recordsDirectory))
|
|||
|
|
{
|
|||
|
|
recordsDirectory = historyRecordsDirectoryPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string fileName = entry.FileName;
|
|||
|
|
if (string.IsNullOrEmpty(fileName))
|
|||
|
|
{
|
|||
|
|
fileName = $"{SanitizeFileComponent(entry.TestId)}.json";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Path.Combine(recordsDirectory, fileName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool TryReadHistoryRecord(string filePath, out BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
if (TryReadHistoryRecordFile(filePath, out record))
|
|||
|
|
{
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!string.IsNullOrEmpty(filePath) &&
|
|||
|
|
!filePath.EndsWith(RECORD_BACKUP_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
|||
|
|
{
|
|||
|
|
string backupPath = filePath + RECORD_BACKUP_EXTENSION;
|
|||
|
|
if (TryReadHistoryRecordFile(backupPath, out record))
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"使用BFI单条记录备份恢复: {backupPath}");
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool TryReadHistoryRecordFile(string filePath, out BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
record = null;
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string json = File.ReadAllText(filePath);
|
|||
|
|
if (string.IsNullOrWhiteSpace(json))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
record = JsonUtility.FromJson<BFITestRecord>(json);
|
|||
|
|
if (record == null)
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
record.RestoreAfterDeserialization();
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"读取BFI单条记录失败: {filePath}, {ex.Message}");
|
|||
|
|
record = null;
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddOrReplaceRecoveredRecord(Dictionary<string, BFITestRecord> recordsById, BFITestRecord record)
|
|||
|
|
{
|
|||
|
|
if (record == null)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
NormalizeRecordForStorage(record);
|
|||
|
|
|
|||
|
|
if (!recordsById.TryGetValue(record.TestId, out var existingRecord))
|
|||
|
|
{
|
|||
|
|
recordsById[record.TestId] = record;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (ShouldReplaceRecord(existingRecord, record))
|
|||
|
|
{
|
|||
|
|
recordsById[record.TestId] = record;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
MergeRecordMetadata(existingRecord, record);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private List<string> FindHistoryIndexFiles(bool includeSearchRoots)
|
|||
|
|
{
|
|||
|
|
var result = new List<string>();
|
|||
|
|
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|||
|
|
|
|||
|
|
AddHistoryIndexFilesFromRoot(Path.GetDirectoryName(historyFilePath), 0, result, visited);
|
|||
|
|
|
|||
|
|
if (includeSearchRoots)
|
|||
|
|
{
|
|||
|
|
foreach (var root in GetHistoryIndexSearchRoots())
|
|||
|
|
{
|
|||
|
|
AddHistoryIndexFilesFromRoot(root, 2, result, visited);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Debug.Log($"BFI历史索引搜索完成,找到 {result.Count} 个候选文件");
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private List<string> FindHistoryRecordFiles(bool includeSearchRoots)
|
|||
|
|
{
|
|||
|
|
var result = new List<string>();
|
|||
|
|
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|||
|
|
|
|||
|
|
AddHistoryRecordFilesFromDirectory(historyRecordsDirectoryPath, result, visited);
|
|||
|
|
|
|||
|
|
if (includeSearchRoots)
|
|||
|
|
{
|
|||
|
|
foreach (var root in GetHistoryIndexSearchRoots())
|
|||
|
|
{
|
|||
|
|
AddHistoryRecordFilesFromDirectory(Path.Combine(root, HISTORY_RECORDS_DIR_NAME), result, visited);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Debug.Log($"BFI单条历史记录搜索完成,找到 {result.Count} 个候选文件");
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddHistoryRecordFilesFromDirectory(string directory, List<string> result, HashSet<string> visited)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(directory))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (!Directory.Exists(directory))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string fullDirectory = Path.GetFullPath(directory);
|
|||
|
|
if (!visited.Add(fullDirectory))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
AddHistoryRecordFilesByPattern(fullDirectory, "*.json", result);
|
|||
|
|
AddHistoryRecordFilesByPattern(fullDirectory, "*.json" + RECORD_BACKUP_EXTENSION, result);
|
|||
|
|
AddHistoryRecordFilesByPattern(fullDirectory, "*.json.tmp", result);
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"搜索BFI单条历史记录目录失败: {directory}, {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddHistoryRecordFilesByPattern(string directory, string pattern, List<string> result)
|
|||
|
|
{
|
|||
|
|
foreach (var filePath in Directory.GetFiles(directory, pattern))
|
|||
|
|
{
|
|||
|
|
if (!result.Any(path => string.Equals(path, filePath, StringComparison.OrdinalIgnoreCase)))
|
|||
|
|
{
|
|||
|
|
result.Add(filePath);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public List<BFITestRecord> LoadHistoryRecordSummaries(bool recoverMissingIndex = false)
|
|||
|
|
{
|
|||
|
|
maxHistoryRecords = Mathf.Max(maxHistoryRecords, MIN_HISTORY_RECORD_LIMIT);
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (TryReadHistoryRecordSummaries(historyFilePath, out var summaries, out bool repairedSummaries))
|
|||
|
|
{
|
|||
|
|
historyRecords = GetRecordsSortedByRecentFirst(summaries)
|
|||
|
|
.Take(maxHistoryRecords)
|
|||
|
|
.ToList();
|
|||
|
|
historyFileLoadFailed = false;
|
|||
|
|
if (repairedSummaries)
|
|||
|
|
{
|
|||
|
|
Debug.Log("检测到BFI历史摘要缺少明细统计,已从实体文件修复并刷新索引");
|
|||
|
|
SaveHistoryIndex();
|
|||
|
|
}
|
|||
|
|
return GetAllRecords();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
LoadHistoryRecords(false, recoverMissingIndex);
|
|||
|
|
return GetAllRecords();
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"加载BFI历史摘要失败,回退完整加载: {ex.Message}");
|
|||
|
|
LoadHistoryRecords(false, recoverMissingIndex);
|
|||
|
|
return GetAllRecords();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private IEnumerable<string> GetHistoryIndexSearchRoots()
|
|||
|
|
{
|
|||
|
|
var roots = new List<string>();
|
|||
|
|
AddSearchRoot(roots, Path.GetDirectoryName(historyFilePath));
|
|||
|
|
if (!string.IsNullOrEmpty(historyMirrorFilePath))
|
|||
|
|
{
|
|||
|
|
AddSearchRoot(roots, Path.GetDirectoryName(historyMirrorFilePath));
|
|||
|
|
}
|
|||
|
|
AddSearchRoot(roots, Application.persistentDataPath);
|
|||
|
|
AddSearchRoot(roots, Path.Combine(Application.dataPath, "..", "Exports"));
|
|||
|
|
AddSearchRoot(roots, Path.Combine(Application.dataPath, "Exports"));
|
|||
|
|
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
AddSearchRoot(roots, AndroidExportPermissionManager.GetStableAppInternalDirectory());
|
|||
|
|
AddSearchRoot(roots, GetAndroidFilesDirectory());
|
|||
|
|
AddSearchRoot(roots, GetAndroidExternalFilesDirectory(null));
|
|||
|
|
AddSearchRoot(roots, GetAndroidExternalFilesDirectory("Documents"));
|
|||
|
|
string downloadDirectory = GetAndroidDownloadDirectory();
|
|||
|
|
AddSearchRoot(roots, downloadDirectory);
|
|||
|
|
if (!string.IsNullOrEmpty(downloadDirectory))
|
|||
|
|
{
|
|||
|
|
AddSearchRoot(roots, Path.Combine(downloadDirectory, "DCX_Export"));
|
|||
|
|
}
|
|||
|
|
#endif
|
|||
|
|
|
|||
|
|
return roots;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddSearchRoot(List<string> roots, string root)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(root))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
string fullPath = Path.GetFullPath(root);
|
|||
|
|
if (!roots.Any(existing => string.Equals(existing, fullPath, StringComparison.OrdinalIgnoreCase)))
|
|||
|
|
{
|
|||
|
|
roots.Add(fullPath);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch
|
|||
|
|
{
|
|||
|
|
if (!roots.Contains(root))
|
|||
|
|
{
|
|||
|
|
roots.Add(root);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void AddHistoryIndexFilesFromRoot(string root, int depth, List<string> result, HashSet<string> visited)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(root) || depth < 0)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (!Directory.Exists(root))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string fullRoot = Path.GetFullPath(root);
|
|||
|
|
if (!visited.Add(fullRoot))
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var candidateFileName in HISTORY_CANDIDATE_FILE_NAMES)
|
|||
|
|
{
|
|||
|
|
string directFile = Path.Combine(fullRoot, candidateFileName);
|
|||
|
|
if (File.Exists(directFile) && !result.Any(path => string.Equals(path, directFile, StringComparison.OrdinalIgnoreCase)))
|
|||
|
|
{
|
|||
|
|
result.Add(directFile);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
string backupFile = directFile + RECORD_BACKUP_EXTENSION;
|
|||
|
|
if (File.Exists(backupFile) && !result.Any(path => string.Equals(path, backupFile, StringComparison.OrdinalIgnoreCase)))
|
|||
|
|
{
|
|||
|
|
result.Add(backupFile);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (depth == 0)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var directory in Directory.GetDirectories(fullRoot))
|
|||
|
|
{
|
|||
|
|
AddHistoryIndexFilesFromRoot(directory, depth - 1, result, visited);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"搜索BFI历史索引目录失败: {root}, {ex.Message}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#if UNITY_ANDROID && !UNITY_EDITOR
|
|||
|
|
private string GetAndroidFilesDirectory()
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
|||
|
|
using (var activity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
|||
|
|
using (var context = activity.Call<AndroidJavaObject>("getApplicationContext"))
|
|||
|
|
{
|
|||
|
|
var filesDir = context.Call<AndroidJavaObject>("getFilesDir");
|
|||
|
|
return filesDir?.Call<string>("getAbsolutePath");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"获取Android内部目录失败: {ex.Message}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string GetAndroidExternalFilesDirectory(string type)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
|||
|
|
using (var activity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
|||
|
|
using (var context = activity.Call<AndroidJavaObject>("getApplicationContext"))
|
|||
|
|
{
|
|||
|
|
var externalDir = context.Call<AndroidJavaObject>("getExternalFilesDir", type);
|
|||
|
|
return externalDir?.Call<string>("getAbsolutePath");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"获取Android外部应用目录失败: {ex.Message}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string GetAndroidDownloadDirectory()
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
using (var environment = new AndroidJavaClass("android.os.Environment"))
|
|||
|
|
using (var externalStorage = environment.CallStatic<AndroidJavaObject>("getExternalStorageDirectory"))
|
|||
|
|
{
|
|||
|
|
string storagePath = externalStorage?.Call<string>("getAbsolutePath");
|
|||
|
|
return string.IsNullOrEmpty(storagePath) ? null : Path.Combine(storagePath, "Download");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning($"获取Android Download目录失败: {ex.Message}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
#endif
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 移除最旧的记录
|
|||
|
|
/// </summary>
|
|||
|
|
private void RemoveOldestRecord()
|
|||
|
|
{
|
|||
|
|
if (historyRecords.Count == 0) return;
|
|||
|
|
|
|||
|
|
var oldestRecord = historyRecords.OrderBy(r => r.StartTime).First();
|
|||
|
|
historyRecords.Remove(oldestRecord);
|
|||
|
|
DeleteHistoryRecordFile(oldestRecord);
|
|||
|
|
Debug.Log($"移除最旧的记录: {oldestRecord.TestName}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 获取统计信息
|
|||
|
|
/// </summary>
|
|||
|
|
public BFIHistoryStatistics GetHistoryStatistics()
|
|||
|
|
{
|
|||
|
|
var stats = new BFIHistoryStatistics();
|
|||
|
|
|
|||
|
|
if (historyRecords == null || historyRecords.Count == 0)
|
|||
|
|
return stats;
|
|||
|
|
|
|||
|
|
stats.TotalRecords = historyRecords.Count;
|
|||
|
|
stats.TotalTestTime = historyRecords.Sum(r => r.GetDurationMinutes());
|
|||
|
|
|
|||
|
|
var today = DateTime.Today;
|
|||
|
|
stats.TodayRecords = historyRecords.Count(r => r.StartTime.Date == today);
|
|||
|
|
|
|||
|
|
var thisWeek = today.AddDays(-(int)today.DayOfWeek);
|
|||
|
|
stats.WeekRecords = historyRecords.Count(r => r.StartTime >= thisWeek);
|
|||
|
|
|
|||
|
|
var thisMonth = new DateTime(today.Year, today.Month, 1);
|
|||
|
|
stats.MonthRecords = historyRecords.Count(r => r.StartTime >= thisMonth);
|
|||
|
|
|
|||
|
|
return stats;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 导出历史记录
|
|||
|
|
/// </summary>
|
|||
|
|
public bool ExportHistory(List<string> selectedRecordIds, ExportFormat format)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
var selectedRecords = new List<BFITestRecord>();
|
|||
|
|
|
|||
|
|
if (selectedRecordIds == null || selectedRecordIds.Count == 0)
|
|||
|
|
{
|
|||
|
|
selectedRecords = GetAllRecords();
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
foreach (var id in selectedRecordIds)
|
|||
|
|
{
|
|||
|
|
var record = GetRecord(id);
|
|||
|
|
if (record != null)
|
|||
|
|
selectedRecords.Add(record);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (selectedRecords.Count == 0)
|
|||
|
|
{
|
|||
|
|
Debug.LogWarning("没有可导出的记录");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var exportService = new DataExportService();
|
|||
|
|
return exportService.ExportHistoryRecords(selectedRecords, format);
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Debug.LogError($"导出历史记录失败: {ex.Message}");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void OnDestroy()
|
|||
|
|
{
|
|||
|
|
if (autoSave && historyRecords != null)
|
|||
|
|
{
|
|||
|
|
SaveHistoryIndex();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void OnApplicationPause(bool pauseStatus)
|
|||
|
|
{
|
|||
|
|
if (pauseStatus && autoSave && historyIndexDirty)
|
|||
|
|
{
|
|||
|
|
SaveHistoryIndex();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void OnApplicationFocus(bool hasFocus)
|
|||
|
|
{
|
|||
|
|
if (!hasFocus && autoSave && historyIndexDirty)
|
|||
|
|
{
|
|||
|
|
SaveHistoryIndex();
|
|||
|
|
}
|
|||
|
|
else if (hasFocus && historyRecords != null && historyRecords.Count == 0)
|
|||
|
|
{
|
|||
|
|
LoadHistoryRecords(true, false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 历史记录包装类(用于JSON序列化)
|
|||
|
|
/// </summary>
|
|||
|
|
[System.Serializable]
|
|||
|
|
public class BFIHistoryWrapper
|
|||
|
|
{
|
|||
|
|
public int Version;
|
|||
|
|
public string StorageMode;
|
|||
|
|
public List<BFITestRecord> Records;
|
|||
|
|
public List<BFIHistoryRecordIndexEntry> RecordFiles;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[System.Serializable]
|
|||
|
|
public class BFIHistoryRecordIndexEntry
|
|||
|
|
{
|
|||
|
|
public string TestId;
|
|||
|
|
public string TestName;
|
|||
|
|
public string PatientId;
|
|||
|
|
public string PatientName;
|
|||
|
|
public string FileName;
|
|||
|
|
public long StartTimeTicks;
|
|||
|
|
public long EndTimeTicks;
|
|||
|
|
public int PointCount;
|
|||
|
|
public float MinValue;
|
|||
|
|
public float MaxValue;
|
|||
|
|
public float Average;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 历史记录统计信息
|
|||
|
|
/// </summary>
|
|||
|
|
[System.Serializable]
|
|||
|
|
public class BFIHistoryStatistics
|
|||
|
|
{
|
|||
|
|
public int TotalRecords; // 总记录数
|
|||
|
|
public double TotalTestTime; // 总测试时间(分钟)
|
|||
|
|
public int TodayRecords; // 今日记录数
|
|||
|
|
public int WeekRecords; // 本周记录数
|
|||
|
|
public int MonthRecords; // 本月记录数
|
|||
|
|
}
|