1818 lines
61 KiB
C#
1818 lines
61 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Text;
|
||
using UnityEngine;
|
||
using System.Threading.Tasks;
|
||
using System.Linq;
|
||
using System.Globalization;
|
||
|
||
/// <summary>
|
||
/// 跨平台数据导出服务
|
||
/// 支持BFI测试记录、折线图和历史数据的导出
|
||
/// </summary>
|
||
public class DataExportService : IDataExportService
|
||
{
|
||
private const string EXPORT_FOLDER = "DCX_Export";
|
||
private const string HISTORY_FOLDER = "History";
|
||
private const float ANDROID_USB_CACHE_SECONDS = 2f;
|
||
private string _lastExportedFilePath;
|
||
private string _lastErrorMessage;
|
||
private string _cachedAndroidUsbDirectory;
|
||
private bool _cachedAndroidUsbDirectoryWritable;
|
||
private float _cachedAndroidUsbDirectoryTime;
|
||
|
||
public string GetLastErrorMessage()
|
||
{
|
||
return _lastErrorMessage;
|
||
}
|
||
|
||
private void ClearLastError()
|
||
{
|
||
_lastErrorMessage = null;
|
||
}
|
||
|
||
private void SetLastError(string message)
|
||
{
|
||
_lastErrorMessage = message;
|
||
if (!string.IsNullOrEmpty(message))
|
||
{
|
||
Debug.LogWarning(message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 导出数据到USB(跨平台实现),content为要写入的文件内容
|
||
/// </summary>
|
||
public bool ExportToUsb(string suggestedFilename, string content = null)
|
||
{
|
||
try
|
||
{
|
||
ClearLastError();
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
return ExportToAndroidStorage(suggestedFilename, content);
|
||
#else
|
||
return ExportToEditorPath(suggestedFilename, content);
|
||
#endif
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetLastError($"数据导出失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 导出多个历史记录
|
||
/// </summary>
|
||
public bool ExportHistoryRecords(List<BFITestRecord> records, ExportFormat format = ExportFormat.TXT)
|
||
{
|
||
try
|
||
{
|
||
ClearLastError();
|
||
if (records == null || records.Count == 0)
|
||
{
|
||
SetLastError("没有可导出的历史记录");
|
||
return false;
|
||
}
|
||
|
||
var emptyDetailRecords = records
|
||
.Where(record => record == null || record.DataPoints == null || record.DataPoints.Count == 0)
|
||
.ToList();
|
||
if (emptyDetailRecords.Count > 0)
|
||
{
|
||
string names = string.Join("、", emptyDetailRecords
|
||
.Select(GetRecordDisplayName)
|
||
.Take(3));
|
||
if (emptyDetailRecords.Count > 3)
|
||
{
|
||
names += "等";
|
||
}
|
||
|
||
SetLastError($"所选BFI记录缺少明细数据,无法导出:{names}");
|
||
return false;
|
||
}
|
||
|
||
if (records.Sum(record => GetValidDataPoints(record).Count) == 0)
|
||
{
|
||
SetLastError("所选BFI记录没有有效数据点,无法导出");
|
||
return false;
|
||
}
|
||
|
||
if (!CheckExportPermissions())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
if (HasAndroidSafUsbDirectoryPermission())
|
||
{
|
||
if (TryExportHistoryRecordsWithAndroidSaf(records, format))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(_lastErrorMessage))
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
#endif
|
||
|
||
string filename = $"BFI_History_{format}_{DateTime.Now:yyyyMMdd_HHmmss_fff}";
|
||
string exportPath = GetExportPath(filename);
|
||
if (string.IsNullOrEmpty(exportPath))
|
||
{
|
||
SetLastError("未找到可写入的U盘导出目录");
|
||
return false;
|
||
}
|
||
|
||
switch (format)
|
||
{
|
||
case ExportFormat.TXT:
|
||
return ExportHistoryToTXT(records, exportPath);
|
||
case ExportFormat.CSV:
|
||
return ExportHistoryToCSV(records, exportPath);
|
||
case ExportFormat.PDF:
|
||
return ExportHistoryToPDF(records, exportPath);
|
||
default:
|
||
return ExportHistoryToTXT(records, exportPath);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetLastError($"历史记录导出失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
private bool TryExportHistoryRecordsWithAndroidSaf(List<BFITestRecord> records, ExportFormat format)
|
||
{
|
||
try
|
||
{
|
||
if (!HasAndroidSafUsbDirectoryPermission())
|
||
{
|
||
if (RequestAndroidSafUsbDirectoryPermission())
|
||
{
|
||
SetLastError("请在弹出的文件选择器中选择 U S B 根目录并授权,然后重新点击导出");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
string fileName = $"BFI_History_{format}_{DateTime.Now:yyyyMMdd_HHmmss_fff}{GetExportFileExtension(format)}";
|
||
bool success;
|
||
if (format == ExportFormat.PDF)
|
||
{
|
||
byte[] pdfBytes = BuildSimplePdfBytes(BuildPdfReportLines(records));
|
||
success = AndroidSafWriteBytesFile(fileName, pdfBytes);
|
||
}
|
||
else
|
||
{
|
||
string content = BuildHistoryExportContent(records, format);
|
||
if (string.IsNullOrEmpty(content))
|
||
{
|
||
SetLastError("导出内容为空");
|
||
return false;
|
||
}
|
||
|
||
success = AndroidSafWriteTextFile(fileName, content);
|
||
}
|
||
|
||
if (success)
|
||
{
|
||
_lastExportedFilePath = $"U S B/DCX_Export/{fileName}";
|
||
Debug.Log($"历史记录已通过U盘目录授权导出: {_lastExportedFilePath}");
|
||
return true;
|
||
}
|
||
|
||
ClearAndroidSafUsbDirectoryPermission();
|
||
if (RequestAndroidSafUsbDirectoryPermission())
|
||
{
|
||
SetLastError("U盘目录授权已失效或当前U盘不可写,已重新打开授权窗口。请选择 U S B 根目录并授权后再次导出");
|
||
}
|
||
return false;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetLastError($"U盘授权写入失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private string GetExportFileExtension(ExportFormat format)
|
||
{
|
||
switch (format)
|
||
{
|
||
case ExportFormat.CSV:
|
||
return "_history.csv";
|
||
case ExportFormat.PDF:
|
||
return "_history.pdf";
|
||
case ExportFormat.TXT:
|
||
default:
|
||
return "_history.txt";
|
||
}
|
||
}
|
||
|
||
private string BuildHistoryExportContent(List<BFITestRecord> records, ExportFormat format)
|
||
{
|
||
switch (format)
|
||
{
|
||
case ExportFormat.CSV:
|
||
return BuildHistoryCsvContent(records);
|
||
case ExportFormat.TXT:
|
||
default:
|
||
return BuildHistoryTxtContent(records);
|
||
}
|
||
}
|
||
|
||
private string BuildHistoryTxtContent(List<BFITestRecord> records)
|
||
{
|
||
var reportBuilder = new StringBuilder();
|
||
reportBuilder.AppendLine("BFI测试历史报告");
|
||
reportBuilder.AppendLine($"生成时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||
reportBuilder.AppendLine($"记录数量: {records.Count}");
|
||
reportBuilder.AppendLine();
|
||
|
||
foreach (var record in records)
|
||
{
|
||
reportBuilder.AppendLine(GenerateBFIReportContent(record));
|
||
reportBuilder.AppendLine("" + new string('=', 50));
|
||
}
|
||
|
||
return reportBuilder.ToString();
|
||
}
|
||
|
||
private string BuildHistoryCsvContent(List<BFITestRecord> records)
|
||
{
|
||
var csvBuilder = new StringBuilder();
|
||
csvBuilder.AppendLine("测试名称,患者姓名,住院号,性别,年龄,身高(cm),体重(kg),开始时间,结束时间,时间戳,BFI值,状态,备注");
|
||
|
||
foreach (var record in records)
|
||
{
|
||
DateTime startTime = GetEffectiveStartTime(record);
|
||
DateTime endTime = GetEffectiveEndTime(record, startTime);
|
||
var validPoints = GetValidDataPoints(record);
|
||
|
||
string patientName = EscapeCsv(GetPatientName(record));
|
||
string patientId = EscapeCsv(GetPatientId(record));
|
||
string patientGender = EscapeCsv(GetPatientGender(record));
|
||
string patientAge = FormatPatientAge(record.PatientAge);
|
||
string patientHeight = FormatPatientHeight(record.PatientHeight);
|
||
string patientWeight = FormatPatientWeight(record.PatientWeight);
|
||
|
||
if (validPoints.Count == 0)
|
||
{
|
||
csvBuilder.AppendLine($"{EscapeCsv(record.TestName)},{patientName},{patientId},{patientGender},{patientAge},{patientHeight},{patientWeight},{startTime:yyyy-MM-dd HH:mm:ss},{endTime:yyyy-MM-dd HH:mm:ss},,,无有效数据点,");
|
||
continue;
|
||
}
|
||
|
||
foreach (var dataPoint in validPoints)
|
||
{
|
||
csvBuilder.AppendLine($"{EscapeCsv(record.TestName)},{patientName},{patientId},{patientGender},{patientAge},{patientHeight},{patientWeight},{startTime:yyyy-MM-dd HH:mm:ss},{endTime:yyyy-MM-dd HH:mm:ss},{dataPoint.Timestamp:yyyy-MM-dd HH:mm:ss},{dataPoint.BFI:F2},{EscapeCsv(dataPoint.Status)},{EscapeCsv(dataPoint.Notes)}");
|
||
}
|
||
}
|
||
|
||
return csvBuilder.ToString();
|
||
}
|
||
|
||
private bool HasAndroidSafUsbDirectoryPermission()
|
||
{
|
||
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||
using (var currentActivity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
||
using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess"))
|
||
{
|
||
return usbAccess.CallStatic<bool>("hasPersistedUsbDirectory", currentActivity);
|
||
}
|
||
}
|
||
|
||
private bool RequestAndroidSafUsbDirectoryPermission()
|
||
{
|
||
try
|
||
{
|
||
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||
using (var currentActivity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
||
using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess"))
|
||
{
|
||
usbAccess.CallStatic("requestUsbDirectoryPermission", currentActivity);
|
||
return true;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetLastError($"无法打开U盘目录授权窗口: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void ClearAndroidSafUsbDirectoryPermission()
|
||
{
|
||
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||
using (var currentActivity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
||
using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess"))
|
||
{
|
||
usbAccess.CallStatic("clearPersistedUsbDirectory", currentActivity);
|
||
}
|
||
}
|
||
|
||
private bool AndroidSafWriteTextFile(string fileName, string content)
|
||
{
|
||
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||
using (var currentActivity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
||
using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess"))
|
||
{
|
||
return usbAccess.CallStatic<bool>("writeTextFile", currentActivity, fileName, content);
|
||
}
|
||
}
|
||
|
||
private bool AndroidSafWriteBytesFile(string fileName, byte[] bytes)
|
||
{
|
||
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||
using (var currentActivity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
||
using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess"))
|
||
{
|
||
return usbAccess.CallStatic<bool>("writeBytesFile", currentActivity, fileName, bytes);
|
||
}
|
||
}
|
||
|
||
private int GetAndroidSdkInt()
|
||
{
|
||
using (var buildVersion = new AndroidJavaClass("android.os.Build$VERSION"))
|
||
{
|
||
return buildVersion.GetStatic<int>("SDK_INT");
|
||
}
|
||
}
|
||
#endif
|
||
|
||
#region 平台特定实现
|
||
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
|
||
/// <summary>
|
||
/// Android平台导出到存储
|
||
/// 导出路径:/sdcard/Download/DCX_Export/
|
||
/// </summary>
|
||
private bool ExportToAndroidStorage(string filename, string content = null)
|
||
{
|
||
try
|
||
{
|
||
if (GetAndroidSdkInt() >= 29)
|
||
{
|
||
if (HasAndroidSafUsbDirectoryPermission())
|
||
{
|
||
bool safSuccess = AndroidSafWriteTextFile(filename, content ?? string.Empty);
|
||
if (safSuccess)
|
||
{
|
||
_lastExportedFilePath = $"U S B/DCX_Export/{filename}";
|
||
Debug.Log($"Android: 文件已通过U盘目录授权写入 {_lastExportedFilePath}");
|
||
return true;
|
||
}
|
||
|
||
ClearAndroidSafUsbDirectoryPermission();
|
||
if (RequestAndroidSafUsbDirectoryPermission())
|
||
{
|
||
SetLastError("U盘目录授权已失效或当前U盘不可写,已重新打开授权窗口。请选择 U S B 根目录并授权后再次导出");
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
string exportDir = GetExportDirectory();
|
||
if (string.IsNullOrEmpty(exportDir))
|
||
{
|
||
if (GetAndroidSdkInt() >= 29 && RequestAndroidSafUsbDirectoryPermission())
|
||
{
|
||
SetLastError("未检测到可直接写入的U盘路径。请在弹出的文件选择器中选择 U S B 根目录并授权,然后重新点击导出");
|
||
}
|
||
else
|
||
{
|
||
SetLastError("未检测到可写入的U盘,请插入U盘后重试");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
exportDir = EnsureExportDirectoryWithRootFolder(exportDir);
|
||
if (!Directory.Exists(exportDir))
|
||
{
|
||
Directory.CreateDirectory(exportDir);
|
||
}
|
||
|
||
string fullPath = Path.Combine(exportDir, filename);
|
||
|
||
// 写入文件内容
|
||
if (content != null)
|
||
{
|
||
File.WriteAllText(fullPath, content, Encoding.UTF8);
|
||
Debug.Log($"Android: 文件已写入 {fullPath}");
|
||
_lastExportedFilePath = fullPath;
|
||
}
|
||
else
|
||
{
|
||
Debug.Log($"Android: 导出目录已准备 {fullPath}(无内容写入)");
|
||
}
|
||
|
||
// 通知媒体扫描器更新文件,使其在文件管理器中可见
|
||
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||
using (var currentActivity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
||
{
|
||
NotifyMediaScanner(currentActivity, fullPath);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetLastError($"Android导出失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 通知Android媒体扫描器更新文件
|
||
/// </summary>
|
||
private void NotifyMediaScanner(AndroidJavaObject activity, string filePath)
|
||
{
|
||
try
|
||
{
|
||
var intent = new AndroidJavaObject("android.content.Intent");
|
||
intent.Call<AndroidJavaObject>("setAction", "android.intent.action.MEDIA_SCANNER_SCAN_FILE");
|
||
|
||
var uri = new AndroidJavaClass("android.net.Uri");
|
||
var fileUri = uri.CallStatic<AndroidJavaObject>("parse", $"file://{filePath}");
|
||
intent.Call<AndroidJavaObject>("setData", fileUri);
|
||
|
||
activity.Call("sendBroadcast", intent);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"媒体扫描器通知失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
#else
|
||
|
||
/// <summary>
|
||
/// 编辑器/Windows模式导出
|
||
/// </summary>
|
||
private bool ExportToEditorPath(string filename, string content = null)
|
||
{
|
||
try
|
||
{
|
||
string exportDir = EnsureExportDirectoryWithRootFolder(GetExportDirectory());
|
||
|
||
if (!Directory.Exists(exportDir))
|
||
{
|
||
Directory.CreateDirectory(exportDir);
|
||
}
|
||
|
||
string fullPath = Path.Combine(exportDir, filename);
|
||
|
||
// 写入文件内容
|
||
if (content != null)
|
||
{
|
||
File.WriteAllText(fullPath, content, Encoding.UTF8);
|
||
Debug.Log($"编辑器: 文件已写入 {fullPath}");
|
||
_lastExportedFilePath = fullPath;
|
||
}
|
||
else
|
||
{
|
||
Debug.Log($"编辑器: 导出目录已准备 {fullPath}(无内容写入)");
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"编辑器导出失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取Windows平台USB设备驱动器列表
|
||
/// </summary>
|
||
private List<string> GetUsbDrives()
|
||
{
|
||
var usbDrives = new List<string>();
|
||
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||
try
|
||
{
|
||
foreach (var drive in System.IO.DriveInfo.GetDrives())
|
||
{
|
||
if (!drive.IsReady)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string root = drive.RootDirectory.FullName;
|
||
bool isRemovable = drive.DriveType == System.IO.DriveType.Removable;
|
||
bool hasDcxExportFolder = Directory.Exists(Path.Combine(root, EXPORT_FOLDER));
|
||
bool isSystemDrive = string.Equals(
|
||
Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows)),
|
||
root,
|
||
StringComparison.OrdinalIgnoreCase);
|
||
|
||
if (isRemovable || (!isSystemDrive && hasDcxExportFolder))
|
||
{
|
||
usbDrives.Add(root);
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"获取USB驱动器失败: {ex.Message}");
|
||
}
|
||
#endif
|
||
return usbDrives;
|
||
}
|
||
|
||
#endif
|
||
|
||
#endregion
|
||
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
private string GetAndroidUsbExportDirectory(bool requireWritable)
|
||
{
|
||
if (Application.platform == RuntimePlatform.Android &&
|
||
Time.unscaledTime - _cachedAndroidUsbDirectoryTime <= ANDROID_USB_CACHE_SECONDS &&
|
||
!string.IsNullOrEmpty(_cachedAndroidUsbDirectory))
|
||
{
|
||
if (!requireWritable || _cachedAndroidUsbDirectoryWritable)
|
||
{
|
||
return _cachedAndroidUsbDirectory;
|
||
}
|
||
}
|
||
|
||
string firstCandidate = null;
|
||
|
||
foreach (var root in GetAndroidUsbCandidateRoots())
|
||
{
|
||
if (string.IsNullOrEmpty(root))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string exportDir = EnsureExportDirectoryWithRootFolder(root);
|
||
if (string.IsNullOrEmpty(firstCandidate))
|
||
{
|
||
firstCandidate = exportDir;
|
||
}
|
||
|
||
if (IsWritableDirectory(exportDir))
|
||
{
|
||
Debug.Log($"检测到可写U盘导出目录: {exportDir}");
|
||
_cachedAndroidUsbDirectory = exportDir;
|
||
_cachedAndroidUsbDirectoryWritable = true;
|
||
_cachedAndroidUsbDirectoryTime = Time.unscaledTime;
|
||
return exportDir;
|
||
}
|
||
}
|
||
|
||
_cachedAndroidUsbDirectory = firstCandidate;
|
||
_cachedAndroidUsbDirectoryWritable = false;
|
||
_cachedAndroidUsbDirectoryTime = Time.unscaledTime;
|
||
return requireWritable ? null : firstCandidate;
|
||
}
|
||
|
||
private IEnumerable<string> GetAndroidUsbCandidateRoots()
|
||
{
|
||
var roots = new List<string>();
|
||
AddAndroidUsbRootsFromStorageManager(roots);
|
||
AddAndroidUsbRootsFromEnvironment(roots);
|
||
|
||
AddAndroidUsbRoot(roots, "/storage/USB");
|
||
AddAndroidUsbRoot(roots, "/storage/U S B");
|
||
AddAndroidUsbRoot(roots, "/storage/usbotg");
|
||
AddAndroidUsbRoot(roots, "/storage/usb");
|
||
AddAndroidUsbRoot(roots, "/storage/udisk");
|
||
AddAndroidUsbRoot(roots, "/storage/udisk1");
|
||
AddAndroidUsbRoot(roots, "/mnt/usb");
|
||
AddAndroidUsbRoot(roots, "/mnt/udisk");
|
||
AddAndroidUsbRoot(roots, "/mnt/usb_storage/USB_DISK0");
|
||
AddAndroidUsbRoot(roots, "/mnt/usb_storage/USB_DISK1");
|
||
|
||
return roots;
|
||
}
|
||
|
||
private void AddAndroidUsbRootsFromStorageManager(List<string> roots)
|
||
{
|
||
try
|
||
{
|
||
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||
using (var currentActivity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
||
using (var context = currentActivity.Call<AndroidJavaObject>("getApplicationContext"))
|
||
{
|
||
var storageManager = context.Call<AndroidJavaObject>("getSystemService", "storage");
|
||
if (storageManager == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var volumes = storageManager.Call<AndroidJavaObject>("getStorageVolumes");
|
||
if (volumes == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
int count = volumes.Call<int>("size");
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
using (var volume = volumes.Call<AndroidJavaObject>("get", i))
|
||
{
|
||
if (volume == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
bool isPrimary = SafeCallBool(volume, "isPrimary");
|
||
bool isRemovable = SafeCallBool(volume, "isRemovable");
|
||
if (isPrimary || !isRemovable)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string description = TryGetStorageVolumeDescription(volume, context);
|
||
string path = TryGetStorageVolumeDirectory(volume);
|
||
if (!string.IsNullOrEmpty(path) &&
|
||
IsLikelyAndroidUsbRoot(description, path))
|
||
{
|
||
Debug.Log($"检测到可移动存储卷: {description}, 路径: {path}");
|
||
AddAndroidUsbRoot(roots, path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"通过StorageManager扫描U盘失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private bool SafeCallBool(AndroidJavaObject target, string methodName)
|
||
{
|
||
try
|
||
{
|
||
return target != null && target.Call<bool>(methodName);
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private string TryGetStorageVolumeDescription(AndroidJavaObject volume, AndroidJavaObject context)
|
||
{
|
||
try
|
||
{
|
||
return volume.Call<string>("getDescription", context);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private string TryGetStorageVolumeDirectory(AndroidJavaObject volume)
|
||
{
|
||
try
|
||
{
|
||
using (var directory = volume.Call<AndroidJavaObject>("getDirectory"))
|
||
{
|
||
string path = directory?.Call<string>("getAbsolutePath");
|
||
if (!string.IsNullOrEmpty(path))
|
||
{
|
||
return path;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// Android 10及以下没有公开getDirectory,继续尝试厂商/旧系统常见方法。
|
||
}
|
||
|
||
try
|
||
{
|
||
string path = volume.Call<string>("getPath");
|
||
if (!string.IsNullOrEmpty(path))
|
||
{
|
||
return path;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private void AddAndroidUsbRootsFromEnvironment(List<string> roots)
|
||
{
|
||
AddAndroidUsbRootsFromEnvironmentValue(roots, Environment.GetEnvironmentVariable("SECONDARY_STORAGE"));
|
||
AddAndroidUsbRootsFromEnvironmentValue(roots, Environment.GetEnvironmentVariable("EXTERNAL_STORAGE"));
|
||
}
|
||
|
||
private void AddAndroidUsbRootsFromEnvironmentValue(List<string> roots, string value)
|
||
{
|
||
if (string.IsNullOrEmpty(value))
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var path in value.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries))
|
||
{
|
||
AddAndroidUsbRoot(roots, path);
|
||
}
|
||
}
|
||
|
||
private bool IsLikelyAndroidUsbRoot(string name, string path)
|
||
{
|
||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
string lowerName = name.ToLowerInvariant();
|
||
string lowerPath = path.ToLowerInvariant();
|
||
string normalizedName = lowerName.Replace(" ", string.Empty)
|
||
.Replace("_", string.Empty)
|
||
.Replace("-", string.Empty);
|
||
|
||
if (lowerName == "emulated" || lowerName == "self" || lowerName == "sdcard0" || lowerName == "sdcard")
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (lowerPath.Contains("/android/data") || lowerPath.Contains("/obb"))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return lowerName.Contains("usb") ||
|
||
normalizedName.Contains("usb") ||
|
||
lowerName.Contains("udisk") ||
|
||
lowerName.Contains("otg") ||
|
||
lowerName.Contains("disk") ||
|
||
System.Text.RegularExpressions.Regex.IsMatch(name, "^[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$");
|
||
}
|
||
|
||
private void AddAndroidUsbRoot(List<string> roots, string root)
|
||
{
|
||
if (string.IsNullOrEmpty(root))
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (IsAndroidInternalStorageRoot(root))
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
string fullPath = Path.GetFullPath(root);
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
if (!roots.Any(existing => string.Equals(existing, fullPath, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
roots.Add(fullPath);
|
||
}
|
||
#else
|
||
if (Directory.Exists(fullPath) && !roots.Any(existing => string.Equals(existing, fullPath, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
roots.Add(fullPath);
|
||
}
|
||
#endif
|
||
}
|
||
catch
|
||
{
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
if (!roots.Contains(root))
|
||
{
|
||
roots.Add(root);
|
||
}
|
||
#else
|
||
if (Directory.Exists(root) && !roots.Contains(root))
|
||
{
|
||
roots.Add(root);
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
|
||
private bool IsAndroidInternalStorageRoot(string root)
|
||
{
|
||
if (string.IsNullOrEmpty(root))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
string normalized = root.Replace('\\', '/').TrimEnd('/').ToLowerInvariant();
|
||
return normalized == "/sdcard" ||
|
||
normalized == "/storage/emulated" ||
|
||
normalized.StartsWith("/storage/emulated/", StringComparison.OrdinalIgnoreCase) ||
|
||
normalized == "/storage/self" ||
|
||
normalized.StartsWith("/storage/self/", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
#endif
|
||
|
||
#region BFI数据导出实现
|
||
|
||
/// <summary>
|
||
/// 导出BFI折线图为图片
|
||
/// </summary>
|
||
private bool ExportHistoryToImage(List<BFITestRecord> records, string exportPath)
|
||
{
|
||
try
|
||
{
|
||
// 这里需要图表生成库
|
||
// 临时实现:生成数据点描述
|
||
foreach (var record in records)
|
||
{
|
||
DateTime startTime = GetEffectiveStartTime(record);
|
||
DateTime endTime = GetEffectiveEndTime(record, startTime);
|
||
var validPoints = GetValidDataPoints(record);
|
||
|
||
var imageDataBuilder = new StringBuilder();
|
||
imageDataBuilder.AppendLine($"BFI折线图数据 - {record.TestName}");
|
||
imageDataBuilder.AppendLine($"患者姓名: {GetPatientName(record)}");
|
||
imageDataBuilder.AppendLine($"住院号: {GetPatientId(record)}");
|
||
imageDataBuilder.AppendLine($"性别: {GetPatientGender(record)}");
|
||
imageDataBuilder.AppendLine($"年龄: {FormatPatientAge(record.PatientAge)}");
|
||
imageDataBuilder.AppendLine($"身高(cm): {FormatPatientHeight(record.PatientHeight)}");
|
||
imageDataBuilder.AppendLine($"体重(kg): {FormatPatientWeight(record.PatientWeight)}");
|
||
imageDataBuilder.AppendLine($"开始时间: {startTime:yyyy-MM-dd HH:mm:ss}");
|
||
imageDataBuilder.AppendLine($"结束时间: {endTime:yyyy-MM-dd HH:mm:ss}");
|
||
imageDataBuilder.AppendLine($"测试时长: {GetEffectiveDurationMinutes(startTime, endTime):F1} 分钟");
|
||
imageDataBuilder.AppendLine($"有效数据点: {validPoints.Count}/{record?.DataPoints?.Count ?? 0}");
|
||
imageDataBuilder.AppendLine("数据点:");
|
||
|
||
foreach (var point in validPoints)
|
||
{
|
||
imageDataBuilder.AppendLine($" {FormatPointTime(point.Timestamp)} - BFI: {point.BFI:F2}");
|
||
}
|
||
|
||
string txtPath = exportPath + "_chart.txt";
|
||
File.WriteAllText(txtPath, imageDataBuilder.ToString(), Encoding.UTF8);
|
||
|
||
Debug.Log($"BFI图表数据已导出: {txtPath}");
|
||
}
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"图表导出失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 历史记录导出
|
||
|
||
/// <summary>
|
||
/// 导出历史记录为TXT
|
||
/// </summary>
|
||
private bool ExportHistoryToTXT(List<BFITestRecord> records, string exportPath)
|
||
{
|
||
try
|
||
{
|
||
var reportBuilder = new StringBuilder();
|
||
reportBuilder.AppendLine("BFI测试历史报告");
|
||
reportBuilder.AppendLine($"生成时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||
reportBuilder.AppendLine($"记录数量: {records.Count}");
|
||
reportBuilder.AppendLine();
|
||
|
||
foreach (var record in records)
|
||
{
|
||
reportBuilder.AppendLine(GenerateBFIReportContent(record));
|
||
reportBuilder.AppendLine("" + new string('=', 50));
|
||
}
|
||
|
||
string txtPath = GetUniqueFilePath(exportPath + "_history.txt");
|
||
string content = reportBuilder.ToString();
|
||
if (!WriteTextFileVerified(txtPath, content))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
_lastExportedFilePath = txtPath;
|
||
NotifyExportedFile(txtPath);
|
||
Debug.Log($"历史记录报告已生成: {txtPath}");
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"历史记录TXT导出失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 导出历史记录为CSV
|
||
/// </summary>
|
||
private bool ExportHistoryToCSV(List<BFITestRecord> records, string exportPath)
|
||
{
|
||
try
|
||
{
|
||
var csvBuilder = new StringBuilder();
|
||
csvBuilder.AppendLine("测试名称,患者姓名,住院号,性别,年龄,身高(cm),体重(kg),开始时间,结束时间,时间戳,BFI值,状态,备注");
|
||
|
||
foreach (var record in records)
|
||
{
|
||
DateTime startTime = GetEffectiveStartTime(record);
|
||
DateTime endTime = GetEffectiveEndTime(record, startTime);
|
||
var validPoints = GetValidDataPoints(record);
|
||
|
||
string patientName = EscapeCsv(GetPatientName(record));
|
||
string patientId = EscapeCsv(GetPatientId(record));
|
||
string patientGender = EscapeCsv(GetPatientGender(record));
|
||
string patientAge = FormatPatientAge(record.PatientAge);
|
||
string patientHeight = FormatPatientHeight(record.PatientHeight);
|
||
string patientWeight = FormatPatientWeight(record.PatientWeight);
|
||
|
||
if (validPoints.Count == 0)
|
||
{
|
||
csvBuilder.AppendLine($"{EscapeCsv(record.TestName)},{patientName},{patientId},{patientGender},{patientAge},{patientHeight},{patientWeight},{startTime:yyyy-MM-dd HH:mm:ss},{endTime:yyyy-MM-dd HH:mm:ss},,,无有效数据点,");
|
||
continue;
|
||
}
|
||
|
||
foreach (var dataPoint in validPoints)
|
||
{
|
||
csvBuilder.AppendLine($"{EscapeCsv(record.TestName)},{patientName},{patientId},{patientGender},{patientAge},{patientHeight},{patientWeight},{startTime:yyyy-MM-dd HH:mm:ss},{endTime:yyyy-MM-dd HH:mm:ss},{dataPoint.Timestamp:yyyy-MM-dd HH:mm:ss},{dataPoint.BFI:F2},{EscapeCsv(dataPoint.Status)},{EscapeCsv(dataPoint.Notes)}");
|
||
}
|
||
}
|
||
|
||
string csvPath = GetUniqueFilePath(exportPath + "_history.csv");
|
||
if (!WriteTextFileVerified(csvPath, csvBuilder.ToString()))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
_lastExportedFilePath = csvPath;
|
||
NotifyExportedFile(csvPath);
|
||
|
||
Debug.Log($"历史记录CSV已导出: {csvPath}");
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"历史记录CSV导出失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 导出历史记录为PDF
|
||
/// </summary>
|
||
private bool ExportHistoryToPDF(List<BFITestRecord> records, string exportPath)
|
||
{
|
||
try
|
||
{
|
||
var lines = BuildPdfReportLines(records);
|
||
string pdfPath = GetUniqueFilePath(exportPath + "_history.pdf");
|
||
byte[] pdfBytes = BuildSimplePdfBytes(lines);
|
||
if (!WriteBytesFileVerified(pdfPath, pdfBytes))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
_lastExportedFilePath = pdfPath;
|
||
NotifyExportedFile(pdfPath);
|
||
|
||
Debug.Log($"历史记录PDF已导出: {pdfPath}");
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"历史记录PDF导出失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 辅助方法
|
||
|
||
/// <summary>
|
||
/// 生成BFI文件名
|
||
/// </summary>
|
||
private string GenerateBFIFilename(BFITestRecord record)
|
||
{
|
||
return $"BFI_{record.TestName}_{record.StartTime:yyyyMMdd_HHmmss}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取导出路径
|
||
/// </summary>
|
||
private string GetExportPath(string filename)
|
||
{
|
||
string exportDirectory = EnsureExportDirectoryWithRootFolder(GetExportDirectory());
|
||
if (!Directory.Exists(exportDirectory))
|
||
{
|
||
Directory.CreateDirectory(exportDirectory);
|
||
}
|
||
return Path.Combine(exportDirectory, filename);
|
||
}
|
||
|
||
private string GetUniqueFilePath(string filePath)
|
||
{
|
||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||
{
|
||
return filePath;
|
||
}
|
||
|
||
string directory = Path.GetDirectoryName(filePath);
|
||
string fileName = Path.GetFileNameWithoutExtension(filePath);
|
||
string extension = Path.GetExtension(filePath);
|
||
|
||
for (int i = 1; i < 10000; i++)
|
||
{
|
||
string candidate = Path.Combine(directory, $"{fileName}_{i}{extension}");
|
||
if (!File.Exists(candidate))
|
||
{
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return Path.Combine(directory, $"{fileName}_{Guid.NewGuid():N}{extension}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成BFI报告内容
|
||
/// </summary>
|
||
private string GenerateBFIReportContent(BFITestRecord record)
|
||
{
|
||
var builder = new StringBuilder();
|
||
DateTime startTime = GetEffectiveStartTime(record);
|
||
DateTime endTime = GetEffectiveEndTime(record, startTime);
|
||
var validPoints = GetValidDataPoints(record);
|
||
int totalPoints = record?.DataPoints?.Count ?? 0;
|
||
int ignoredPoints = totalPoints - validPoints.Count;
|
||
|
||
builder.AppendLine($"BFI测试报告 - {record.TestName}");
|
||
builder.AppendLine($"患者姓名: {GetPatientName(record)}");
|
||
builder.AppendLine($"住院号: {GetPatientId(record)}");
|
||
builder.AppendLine($"性别: {GetPatientGender(record)}");
|
||
builder.AppendLine($"年龄: {FormatPatientAge(record.PatientAge)}");
|
||
builder.AppendLine($"身高(cm): {FormatPatientHeight(record.PatientHeight)}");
|
||
builder.AppendLine($"体重(kg): {FormatPatientWeight(record.PatientWeight)}");
|
||
builder.AppendLine($"开始时间: {startTime:yyyy-MM-dd HH:mm:ss}");
|
||
builder.AppendLine($"结束时间: {endTime:yyyy-MM-dd HH:mm:ss}");
|
||
builder.AppendLine($"测试时长: {GetEffectiveDurationMinutes(startTime, endTime):F1} 分钟");
|
||
builder.AppendLine($"数据点数量: {totalPoints}(有效: {validPoints.Count})");
|
||
|
||
if (record.EndTime <= DateTime.MinValue || record.EndTime < startTime)
|
||
{
|
||
builder.AppendLine("说明: 结束时间未正常记录,已按最后有效数据点时间进行估算。");
|
||
}
|
||
|
||
if (ignoredPoints > 0)
|
||
{
|
||
builder.AppendLine($"说明: 已过滤 {ignoredPoints} 条异常BFI数据点(超范围或非数值)。");
|
||
}
|
||
|
||
builder.AppendLine();
|
||
|
||
if (validPoints.Count > 0)
|
||
{
|
||
builder.AppendLine("BFI数据统计:");
|
||
var bfiValues = validPoints.ConvertAll(dp => dp.BFI);
|
||
builder.AppendLine($" 最大值: {bfiValues.Max():F2}");
|
||
builder.AppendLine($" 最小值: {bfiValues.Min():F2}");
|
||
builder.AppendLine($" 平均值: {bfiValues.Average():F2}");
|
||
builder.AppendLine();
|
||
|
||
builder.AppendLine("详细数据:");
|
||
foreach (var point in validPoints)
|
||
{
|
||
builder.AppendLine($" {FormatPointTime(point.Timestamp)} | BFI: {point.BFI:F2} | 状态: {point.Status}");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
builder.AppendLine("无有效BFI数据点");
|
||
}
|
||
|
||
return builder.ToString();
|
||
}
|
||
|
||
private bool IsValidBfiValue(float value)
|
||
{
|
||
return !float.IsNaN(value) && !float.IsInfinity(value) && value >= 0f && value <= 1000f;
|
||
}
|
||
|
||
private List<BFIDataPoint> GetValidDataPoints(BFITestRecord record)
|
||
{
|
||
var validPoints = new List<BFIDataPoint>();
|
||
if (record?.DataPoints == null)
|
||
{
|
||
return validPoints;
|
||
}
|
||
|
||
foreach (var point in record.DataPoints)
|
||
{
|
||
if (point == null || !IsValidBfiValue(point.BFI))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
validPoints.Add(point);
|
||
}
|
||
|
||
return validPoints;
|
||
}
|
||
|
||
private string GetRecordDisplayName(BFITestRecord record)
|
||
{
|
||
if (!string.IsNullOrEmpty(record?.TestName))
|
||
{
|
||
return record.TestName;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(record?.TestId))
|
||
{
|
||
return record.TestId;
|
||
}
|
||
|
||
return "未知记录";
|
||
}
|
||
|
||
private DateTime GetEffectiveStartTime(BFITestRecord record)
|
||
{
|
||
if (record.StartTime > DateTime.MinValue)
|
||
{
|
||
return record.StartTime;
|
||
}
|
||
|
||
if (record?.DataPoints != null)
|
||
{
|
||
foreach (var point in record.DataPoints)
|
||
{
|
||
if (point != null && point.Timestamp > DateTime.MinValue)
|
||
{
|
||
return point.Timestamp;
|
||
}
|
||
}
|
||
}
|
||
|
||
return DateTime.Now;
|
||
}
|
||
|
||
private DateTime GetEffectiveEndTime(BFITestRecord record, DateTime startTime)
|
||
{
|
||
if (record.EndTime > startTime)
|
||
{
|
||
return record.EndTime;
|
||
}
|
||
|
||
DateTime lastPointTime = DateTime.MinValue;
|
||
if (record?.DataPoints != null)
|
||
{
|
||
foreach (var point in record.DataPoints)
|
||
{
|
||
if (point != null && point.Timestamp > lastPointTime)
|
||
{
|
||
lastPointTime = point.Timestamp;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (lastPointTime > startTime)
|
||
{
|
||
return lastPointTime;
|
||
}
|
||
|
||
return startTime;
|
||
}
|
||
|
||
private double GetEffectiveDurationMinutes(DateTime startTime, DateTime endTime)
|
||
{
|
||
double duration = (endTime - startTime).TotalMinutes;
|
||
return duration < 0 ? 0 : duration;
|
||
}
|
||
|
||
private string FormatPointTime(DateTime timestamp)
|
||
{
|
||
return timestamp > DateTime.MinValue ? timestamp.ToString("HH:mm:ss") : "--:--:--";
|
||
}
|
||
|
||
private string GetPatientName(BFITestRecord record)
|
||
{
|
||
return string.IsNullOrWhiteSpace(record?.PatientName) ? "未知患者" : record.PatientName;
|
||
}
|
||
|
||
private string GetPatientId(BFITestRecord record)
|
||
{
|
||
return string.IsNullOrWhiteSpace(record?.PatientId) ? "-" : record.PatientId;
|
||
}
|
||
|
||
private string GetPatientGender(BFITestRecord record)
|
||
{
|
||
return string.IsNullOrWhiteSpace(record?.PatientGender) ? "-" : record.PatientGender;
|
||
}
|
||
|
||
private string FormatPatientAge(int age)
|
||
{
|
||
return age > 0 ? age.ToString() : "-";
|
||
}
|
||
|
||
private string FormatPatientHeight(float height)
|
||
{
|
||
return height > 0f ? height.ToString("F1") : "-";
|
||
}
|
||
|
||
private string FormatPatientWeight(float weight)
|
||
{
|
||
return weight > 0f ? weight.ToString("F1") : "-";
|
||
}
|
||
|
||
private string EscapeCsv(string value)
|
||
{
|
||
if (string.IsNullOrEmpty(value))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
string escaped = value.Replace("\"", "\"\"");
|
||
if (escaped.Contains(",") || escaped.Contains("\n") || escaped.Contains("\r") || escaped.Contains("\""))
|
||
{
|
||
return $"\"{escaped}\"";
|
||
}
|
||
|
||
return escaped;
|
||
}
|
||
|
||
private List<string> BuildPdfReportLines(List<BFITestRecord> records)
|
||
{
|
||
var lines = new List<string>
|
||
{
|
||
"BFI测试历史报告",
|
||
$"生成时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}",
|
||
$"记录数量: {records.Count}",
|
||
string.Empty
|
||
};
|
||
|
||
foreach (var record in records)
|
||
{
|
||
DateTime startTime = GetEffectiveStartTime(record);
|
||
DateTime endTime = GetEffectiveEndTime(record, startTime);
|
||
var validPoints = GetValidDataPoints(record);
|
||
int totalPoints = record?.DataPoints?.Count ?? 0;
|
||
|
||
lines.Add($"测试名称: {record.TestName}");
|
||
lines.Add($"患者姓名: {GetPatientName(record)} 住院号: {GetPatientId(record)}");
|
||
lines.Add($"性别: {GetPatientGender(record)} 年龄: {FormatPatientAge(record.PatientAge)} 身高(cm): {FormatPatientHeight(record.PatientHeight)} 体重(kg): {FormatPatientWeight(record.PatientWeight)}");
|
||
lines.Add($"开始时间: {startTime:yyyy-MM-dd HH:mm:ss}");
|
||
lines.Add($"结束时间: {endTime:yyyy-MM-dd HH:mm:ss}");
|
||
lines.Add($"测试时长: {GetEffectiveDurationMinutes(startTime, endTime):F1} 分钟 数据点: {totalPoints}(有效: {validPoints.Count})");
|
||
|
||
if (validPoints.Count > 0)
|
||
{
|
||
var bfiValues = validPoints.ConvertAll(dp => dp.BFI);
|
||
lines.Add($"统计: 最大值 {bfiValues.Max():F2} 最小值 {bfiValues.Min():F2} 平均值 {bfiValues.Average():F2}");
|
||
lines.Add("详细数据(时间 / BFI / 状态):");
|
||
|
||
foreach (var point in validPoints.Take(120))
|
||
{
|
||
lines.Add($" {FormatPointTime(point.Timestamp)} {point.BFI:F2} {point.Status}");
|
||
}
|
||
|
||
if (validPoints.Count > 120)
|
||
{
|
||
lines.Add($" …… 其余 {validPoints.Count - 120} 个数据点请导出CSV查看。");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
lines.Add("无有效BFI数据点");
|
||
}
|
||
|
||
lines.Add(new string('-', 48));
|
||
lines.Add(string.Empty);
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
private void WriteSimplePdf(string filePath, List<string> lines)
|
||
{
|
||
if (!WriteBytesFileVerified(filePath, BuildSimplePdfBytes(lines)))
|
||
{
|
||
throw new IOException(_lastErrorMessage ?? "PDF文件写入失败");
|
||
}
|
||
}
|
||
|
||
private bool WriteTextFileVerified(string filePath, string content)
|
||
{
|
||
byte[] bytes = Encoding.UTF8.GetBytes(content ?? string.Empty);
|
||
return WriteBytesFileVerified(filePath, bytes);
|
||
}
|
||
|
||
private bool WriteBytesFileVerified(string filePath, byte[] bytes)
|
||
{
|
||
try
|
||
{
|
||
if (string.IsNullOrEmpty(filePath))
|
||
{
|
||
SetLastError("导出路径为空");
|
||
return false;
|
||
}
|
||
|
||
bytes = bytes ?? new byte[0];
|
||
string directory = Path.GetDirectoryName(filePath);
|
||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||
{
|
||
Directory.CreateDirectory(directory);
|
||
}
|
||
|
||
using (var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read, 8192))
|
||
{
|
||
stream.Write(bytes, 0, bytes.Length);
|
||
FlushFileStream(stream);
|
||
}
|
||
|
||
var info = new FileInfo(filePath);
|
||
if (!info.Exists || info.Length < bytes.Length)
|
||
{
|
||
SetLastError($"导出文件写入不完整: {filePath}, 已写入 {info.Length} / {bytes.Length} 字节");
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetLastError($"写入导出文件失败: {filePath}, {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void FlushFileStream(FileStream stream)
|
||
{
|
||
try
|
||
{
|
||
stream.Flush(true);
|
||
}
|
||
catch
|
||
{
|
||
stream.Flush();
|
||
}
|
||
}
|
||
|
||
private byte[] BuildSimplePdfBytes(List<string> lines)
|
||
{
|
||
const float pageWidth = 595f;
|
||
const float pageHeight = 842f;
|
||
const float marginLeft = 48f;
|
||
const float marginTop = 54f;
|
||
const float lineHeight = 17f;
|
||
const int maxCharsPerLine = 46;
|
||
|
||
var wrappedLines = new List<string>();
|
||
foreach (var line in lines)
|
||
{
|
||
wrappedLines.AddRange(WrapTextForPdf(line, maxCharsPerLine));
|
||
}
|
||
|
||
int linesPerPage = Mathf.Max(1, Mathf.FloorToInt((pageHeight - marginTop - 48f) / lineHeight));
|
||
var pages = new List<List<string>>();
|
||
for (int i = 0; i < wrappedLines.Count; i += linesPerPage)
|
||
{
|
||
pages.Add(wrappedLines.Skip(i).Take(linesPerPage).ToList());
|
||
}
|
||
|
||
if (pages.Count == 0)
|
||
{
|
||
pages.Add(new List<string> { "无可导出内容" });
|
||
}
|
||
|
||
var objects = new List<byte[]>();
|
||
objects.Add(PdfAscii("<< /Type /Catalog /Pages 2 0 R >>"));
|
||
|
||
var kids = new StringBuilder();
|
||
for (int i = 0; i < pages.Count; i++)
|
||
{
|
||
int pageObjectNumber = 5 + i * 2;
|
||
kids.Append($"{pageObjectNumber} 0 R ");
|
||
}
|
||
objects.Add(PdfAscii($"<< /Type /Pages /Kids [{kids}] /Count {pages.Count} >>"));
|
||
|
||
objects.Add(PdfAscii("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [4 0 R] >>"));
|
||
objects.Add(PdfAscii("<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >>"));
|
||
|
||
for (int i = 0; i < pages.Count; i++)
|
||
{
|
||
int pageObjectNumber = 5 + i * 2;
|
||
int contentObjectNumber = pageObjectNumber + 1;
|
||
objects.Add(PdfAscii($"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pageWidth.ToString(CultureInfo.InvariantCulture)} {pageHeight.ToString(CultureInfo.InvariantCulture)}] /Resources << /Font << /F1 3 0 R >> >> /Contents {contentObjectNumber} 0 R >>"));
|
||
|
||
byte[] content = BuildPdfPageContent(pages[i], pageHeight, marginLeft, marginTop, lineHeight);
|
||
objects.Add(PdfStream(content));
|
||
}
|
||
|
||
using (var stream = new MemoryStream())
|
||
{
|
||
WriteAscii(stream, "%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n");
|
||
var offsets = new List<long> { 0 };
|
||
|
||
for (int i = 0; i < objects.Count; i++)
|
||
{
|
||
offsets.Add(stream.Position);
|
||
WriteAscii(stream, $"{i + 1} 0 obj\n");
|
||
stream.Write(objects[i], 0, objects[i].Length);
|
||
WriteAscii(stream, "\nendobj\n");
|
||
}
|
||
|
||
long xrefOffset = stream.Position;
|
||
WriteAscii(stream, $"xref\n0 {objects.Count + 1}\n");
|
||
WriteAscii(stream, "0000000000 65535 f \n");
|
||
for (int i = 1; i < offsets.Count; i++)
|
||
{
|
||
WriteAscii(stream, $"{offsets[i]:D10} 00000 n \n");
|
||
}
|
||
WriteAscii(stream, $"trailer\n<< /Size {objects.Count + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
|
||
return stream.ToArray();
|
||
}
|
||
}
|
||
|
||
private IEnumerable<string> WrapTextForPdf(string text, int maxChars)
|
||
{
|
||
if (string.IsNullOrEmpty(text))
|
||
{
|
||
yield return string.Empty;
|
||
yield break;
|
||
}
|
||
|
||
for (int index = 0; index < text.Length; index += maxChars)
|
||
{
|
||
int length = Math.Min(maxChars, text.Length - index);
|
||
yield return text.Substring(index, length);
|
||
}
|
||
}
|
||
|
||
private byte[] BuildPdfPageContent(List<string> lines, float pageHeight, float marginLeft, float marginTop, float lineHeight)
|
||
{
|
||
var builder = new StringBuilder();
|
||
builder.AppendLine("BT");
|
||
builder.AppendLine("/F1 11 Tf");
|
||
builder.AppendLine($"{marginLeft.ToString(CultureInfo.InvariantCulture)} {(pageHeight - marginTop).ToString(CultureInfo.InvariantCulture)} Td");
|
||
|
||
bool firstLine = true;
|
||
foreach (var line in lines)
|
||
{
|
||
if (!firstLine)
|
||
{
|
||
builder.AppendLine($"0 -{lineHeight.ToString(CultureInfo.InvariantCulture)} Td");
|
||
}
|
||
builder.AppendLine($"{PdfUnicodeHexString(line)} Tj");
|
||
firstLine = false;
|
||
}
|
||
|
||
builder.AppendLine("ET");
|
||
return Encoding.ASCII.GetBytes(builder.ToString());
|
||
}
|
||
|
||
private string PdfUnicodeHexString(string value)
|
||
{
|
||
byte[] bytes = Encoding.BigEndianUnicode.GetBytes(value ?? string.Empty);
|
||
var builder = new StringBuilder(bytes.Length * 2 + 2);
|
||
builder.Append('<');
|
||
foreach (byte b in bytes)
|
||
{
|
||
builder.Append(b.ToString("X2"));
|
||
}
|
||
builder.Append('>');
|
||
return builder.ToString();
|
||
}
|
||
|
||
private byte[] PdfStream(byte[] content)
|
||
{
|
||
using (var memory = new MemoryStream())
|
||
{
|
||
WriteAscii(memory, $"<< /Length {content.Length} >>\nstream\n");
|
||
memory.Write(content, 0, content.Length);
|
||
WriteAscii(memory, "\nendstream");
|
||
return memory.ToArray();
|
||
}
|
||
}
|
||
|
||
private byte[] PdfAscii(string value)
|
||
{
|
||
return Encoding.ASCII.GetBytes(value);
|
||
}
|
||
|
||
private void WriteAscii(Stream stream, string value)
|
||
{
|
||
byte[] bytes = Encoding.ASCII.GetBytes(value);
|
||
stream.Write(bytes, 0, bytes.Length);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 规范化导出目录,保证最终目录以 DCX_Export 结尾
|
||
/// </summary>
|
||
private string EnsureExportDirectoryWithRootFolder(string baseDirectory)
|
||
{
|
||
if (string.IsNullOrEmpty(baseDirectory))
|
||
{
|
||
return Path.Combine(Application.persistentDataPath, EXPORT_FOLDER);
|
||
}
|
||
|
||
string normalizedPath = baseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||
string folderName = Path.GetFileName(normalizedPath);
|
||
|
||
if (string.Equals(folderName, EXPORT_FOLDER, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return normalizedPath;
|
||
}
|
||
|
||
return Path.Combine(normalizedPath, EXPORT_FOLDER);
|
||
}
|
||
|
||
private bool IsWritableDirectory(string directory)
|
||
{
|
||
try
|
||
{
|
||
if (string.IsNullOrEmpty(directory))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (!Directory.Exists(directory))
|
||
{
|
||
Directory.CreateDirectory(directory);
|
||
}
|
||
|
||
string testFile = Path.Combine(directory, ".dcx_write_test");
|
||
File.WriteAllText(testFile, DateTime.Now.ToString("O"), Encoding.UTF8);
|
||
File.Delete(testFile);
|
||
|
||
#if !UNITY_ANDROID || UNITY_EDITOR
|
||
if (!HasEnoughFreeSpace(directory, 10L * 1024L * 1024L))
|
||
{
|
||
return false;
|
||
}
|
||
#endif
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"目录不可写: {directory}, {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private bool HasEnoughFreeSpace(string directory, long requiredBytes)
|
||
{
|
||
try
|
||
{
|
||
string root = Path.GetPathRoot(Path.GetFullPath(directory));
|
||
if (string.IsNullOrEmpty(root))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var drive = new DriveInfo(root);
|
||
if (!drive.IsReady)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if (drive.AvailableFreeSpace < requiredBytes)
|
||
{
|
||
Debug.LogWarning($"导出目录空间不足: {directory}, 剩余 {drive.AvailableFreeSpace / 1024 / 1024}MB");
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"检查导出目录剩余空间失败: {directory}, {ex.Message}");
|
||
return true;
|
||
}
|
||
}
|
||
|
||
private void NotifyExportedFile(string filePath)
|
||
{
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
try
|
||
{
|
||
using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
|
||
using (var currentActivity = unityClass.GetStatic<AndroidJavaObject>("currentActivity"))
|
||
{
|
||
NotifyMediaScanner(currentActivity, filePath);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"导出文件媒体扫描通知失败: {ex.Message}");
|
||
}
|
||
#endif
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region IDataExportService接口实现
|
||
|
||
/// <summary>
|
||
/// 检查是否可以导出
|
||
/// </summary>
|
||
public bool CanExport()
|
||
{
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
int sdkInt = GetAndroidSdkInt();
|
||
if (sdkInt >= 29)
|
||
{
|
||
if (HasAndroidSafUsbDirectoryPermission())
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return !string.IsNullOrEmpty(GetAndroidUsbExportDirectory(requireWritable: true));
|
||
}
|
||
|
||
if (!HasAndroidUsbWritePermission(sdkInt))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return !string.IsNullOrEmpty(GetAndroidUsbExportDirectory(requireWritable: true));
|
||
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||
string exportDir = EnsureExportDirectoryWithRootFolder(GetExportDirectory());
|
||
return IsWritableDirectory(exportDir);
|
||
#else
|
||
return true; // 编辑器模式总是可以导出
|
||
#endif
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取导出目录
|
||
/// </summary>
|
||
public string GetExportDirectory()
|
||
{
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
if (GetAndroidSdkInt() >= 29)
|
||
{
|
||
string directUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: true);
|
||
if (!string.IsNullOrEmpty(directUsbDirectory))
|
||
{
|
||
return directUsbDirectory;
|
||
}
|
||
|
||
return HasAndroidSafUsbDirectoryPermission() ? "U S B/DCX_Export" : null;
|
||
}
|
||
|
||
return GetAndroidUsbExportDirectory(requireWritable: true);
|
||
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||
var usbDrives = GetUsbDrives();
|
||
if (usbDrives.Count > 0)
|
||
{
|
||
return Path.Combine(usbDrives[0], EXPORT_FOLDER);
|
||
}
|
||
else
|
||
{
|
||
string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
|
||
return Path.Combine(desktopPath, EXPORT_FOLDER);
|
||
}
|
||
#else
|
||
return Path.Combine(Application.dataPath, "..", "Exports");
|
||
#endif
|
||
}
|
||
|
||
public string GetLastExportedFilePath()
|
||
{
|
||
return _lastExportedFilePath;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查导出权限
|
||
/// </summary>
|
||
public bool CheckExportPermissions()
|
||
{
|
||
try
|
||
{
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
int sdkInt = GetAndroidSdkInt();
|
||
|
||
if (sdkInt >= 29)
|
||
{
|
||
if (HasAndroidSafUsbDirectoryPermission())
|
||
{
|
||
return true;
|
||
}
|
||
|
||
string androidScopedWritableUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: true);
|
||
if (!string.IsNullOrEmpty(androidScopedWritableUsbDirectory))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
string androidScopedDetectedUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: false);
|
||
if (!string.IsNullOrEmpty(androidScopedDetectedUsbDirectory))
|
||
{
|
||
if (RequestAndroidSafUsbDirectoryPermission())
|
||
{
|
||
SetLastError($"检测到U盘路径 {androidScopedDetectedUsbDirectory},但系统不允许直接写入。请在弹出的文件选择器中选择 U S B 根目录并授权,然后重新点击导出");
|
||
}
|
||
}
|
||
else if (RequestAndroidSafUsbDirectoryPermission())
|
||
{
|
||
SetLastError("未检测到可直接写入的U盘路径。请在弹出的文件选择器中选择 U S B 根目录并授权,然后重新点击导出");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
if (!HasAndroidUsbWritePermission(sdkInt))
|
||
{
|
||
RequestAndroidUsbPermission(sdkInt);
|
||
return false;
|
||
}
|
||
|
||
string writableUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: true);
|
||
if (!string.IsNullOrEmpty(writableUsbDirectory))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
string detectedUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: false);
|
||
if (!string.IsNullOrEmpty(detectedUsbDirectory))
|
||
{
|
||
SetLastError($"检测到U盘但无法写入: {detectedUsbDirectory}。请检查U盘格式、只读状态或系统存储权限");
|
||
}
|
||
else
|
||
{
|
||
SetLastError("未检测到可写入的U盘,请确认U盘已插入并重新导出");
|
||
}
|
||
|
||
return false;
|
||
|
||
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||
|
||
string exportDir = GetExportDirectory();
|
||
string finalExportDir = EnsureExportDirectoryWithRootFolder(exportDir);
|
||
if (!IsWritableDirectory(finalExportDir))
|
||
{
|
||
Debug.LogWarning($"导出目录不可访问: {finalExportDir}");
|
||
return false;
|
||
}
|
||
return true;
|
||
#endif
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"权限检查失败: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private bool HasAndroidUsbWritePermission()
|
||
{
|
||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||
return HasAndroidUsbWritePermission(new AndroidJavaClass("android.os.Build$VERSION").GetStatic<int>("SDK_INT"));
|
||
#else
|
||
return true;
|
||
#endif
|
||
}
|
||
|
||
private bool HasAndroidUsbWritePermission(int sdkInt)
|
||
{
|
||
if (sdkInt <= 28)
|
||
{
|
||
return AndroidExportPermissionManager.HasWriteExternalStoragePermission();
|
||
}
|
||
|
||
return AndroidExportPermissionManager.HasManageExternalStoragePermission();
|
||
}
|
||
|
||
private void RequestAndroidUsbPermission(int sdkInt)
|
||
{
|
||
if (sdkInt <= 28)
|
||
{
|
||
SetLastError("缺少存储权限,已请求授权,请允许后重新导出");
|
||
AndroidExportPermissionManager.RequestWriteExternalStoragePermission();
|
||
}
|
||
else
|
||
{
|
||
SetLastError("缺少所有文件访问权限,已打开权限设置,请授权后重新导出");
|
||
AndroidExportPermissionManager.RequestManageExternalStoragePermission();
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
|
||
/// <summary>
|
||
/// 导出格式枚举
|
||
/// </summary>
|
||
public enum ExportFormat
|
||
{
|
||
TXT, // TXT报告
|
||
CSV, // CSV数据
|
||
PDF, // PDF报告
|
||
}
|
||
|
||
|