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报告
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|