```
feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时 - 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的 SME2和A17的NEON加速 - 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制 - 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构 - 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX - 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理 - 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
This commit is contained in:
@@ -140,6 +140,7 @@ struct IndicatorSeriesDetailView: View {
|
||||
} else {
|
||||
pages
|
||||
pager
|
||||
recordAnotherRow
|
||||
if bucket != nil { trendButton }
|
||||
}
|
||||
}
|
||||
@@ -311,6 +312,30 @@ struct IndicatorSeriesDetailView: View {
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
// MARK: - 再记一条(与指标详情共用 RecordAnotherButton 组件)
|
||||
|
||||
/// 按当前翻到的那一页指标预选「再记一条」:血压走双字段,其余按 name/unit/range/seriesKey。
|
||||
@ViewBuilder
|
||||
private var recordAnotherRow: some View {
|
||||
if records.indices.contains(currentIndex) {
|
||||
switch records[currentIndex] {
|
||||
case .single(let i):
|
||||
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, bucket == nil ? 20 : 10)
|
||||
case .bp(let sys, _):
|
||||
RecordAnotherButton(
|
||||
name: String(appLoc: "血压"),
|
||||
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
|
||||
name: String(appLoc: "血压"),
|
||||
unit: "mmHg", range: sys.range)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, bucket == nil ? 20 : 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 趋势 / 删除
|
||||
|
||||
private var trendButton: some View {
|
||||
|
||||
@@ -42,10 +42,12 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
let kind: TimelineKind
|
||||
let date: Date
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var subtitle: String
|
||||
let trailing: String?
|
||||
let trailingIsAlert: Bool
|
||||
let isOngoing: Bool
|
||||
/// 同名指标聚合后的累计次数(>1 时副标题附「共 N 次」)。非聚合条目恒为 1。
|
||||
var aggregateCount: Int = 1
|
||||
|
||||
static func from(indicator i: Indicator) -> TimelineEntry {
|
||||
TimelineEntry(
|
||||
@@ -87,6 +89,34 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
return entries
|
||||
}
|
||||
|
||||
/// 「记录」列表 / 首页最近记录用:把同名(同类组)指标聚合成一条,代表取最新一次,
|
||||
/// 附带该组累计次数(`aggregateCount`,>1 时副标题缀「共 N 次」)。
|
||||
/// 点代表条目跳 `IndicatorSeriesDetailView` 看历次。分组口径与聚合详情 / 趋势一致
|
||||
/// (`IndicatorGroup`):血压(bp.*)并一组、有 seriesKey 的按 key、无 seriesKey 的按 name+unit 归一。
|
||||
static func aggregatedIndicators(_ indicators: [Indicator]) -> [TimelineEntry] {
|
||||
var order: [String] = []
|
||||
var groups: [String: [Indicator]] = [:]
|
||||
for i in indicators {
|
||||
let key = IndicatorGroup.of(i).id
|
||||
if groups[key] == nil { order.append(key) }
|
||||
groups[key, default: []].append(i)
|
||||
}
|
||||
return order.compactMap { key -> TimelineEntry? in
|
||||
guard let members = groups[key] else { return nil }
|
||||
// 该组逐条条目(血压已合并 sys/dia),取最新一条作代表。
|
||||
guard var rep = from(indicators: members).max(by: { $0.date < $1.date }) else { return nil }
|
||||
// 次数:血压按测量次数(bp.systolic 条数),其余按成员条数。
|
||||
let count = key == IndicatorGroup.bloodPressure.id
|
||||
? members.filter { $0.seriesKey == "bp.systolic" }.count
|
||||
: members.count
|
||||
rep.aggregateCount = count
|
||||
if count > 1 {
|
||||
rep.subtitle += " · " + String(appLoc: "共 \(count) 次")
|
||||
}
|
||||
return rep
|
||||
}
|
||||
}
|
||||
|
||||
private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry {
|
||||
let abnormal = sys.status != .normal || dia.status != .normal
|
||||
// 方向箭头按实际 status 给:两值同向才标 ↑/↓;一高一低只标红不给方向
|
||||
|
||||
@@ -54,6 +54,27 @@ struct TimelineEntryDetailView: View {
|
||||
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var evidenceTarget: Indicator?
|
||||
@State private var reminderPrefill: ReminderPrefill?
|
||||
|
||||
/// 「用药记录」点药 → 预填吃药提醒表单用的载体。
|
||||
private struct ReminderPrefill: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let note: String
|
||||
}
|
||||
|
||||
/// 报告详情「查看原图」起始页载体。
|
||||
@State private var reportPhotoStart: ReportPhotoPage?
|
||||
private struct ReportPhotoPage: Identifiable {
|
||||
let id = UUID()
|
||||
let index: Int
|
||||
}
|
||||
|
||||
/// 当前详情若是报告则取出,供「查看原图」用。
|
||||
private var reportEntry: Report? {
|
||||
if case .report(let r) = detail { return r }
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -84,6 +105,15 @@ struct TimelineEntryDetailView: View {
|
||||
EvidenceImagePreview(report: report, indicator: indicator)
|
||||
}
|
||||
}
|
||||
.sheet(item: $reminderPrefill) { prefill in
|
||||
// 复用自由提醒表单(每天/每周/每月/每年 + 时间点;一日多次就再建一条)。
|
||||
CustomReminderEditSheet(prefillTitle: prefill.title, prefillNote: prefill.note)
|
||||
}
|
||||
.sheet(item: $reportPhotoStart) { start in
|
||||
if let r = reportEntry {
|
||||
ReportImagesViewer(assets: r.assets, startIndex: start.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)
|
||||
@@ -120,6 +150,10 @@ struct TimelineEntryDetailView: View {
|
||||
for p in paths { try? FileVault.shared.remove(relativePath: p) }
|
||||
ctx.delete(r)
|
||||
case .diary(let d):
|
||||
// 拍药盒日记可能挂原图;cascade 删 Asset 记录,Vault 里的 JPEG 要手动 unlink。
|
||||
for p in Set(d.assets.map(\.relativePath)) {
|
||||
try? FileVault.shared.remove(relativePath: p)
|
||||
}
|
||||
ctx.delete(d)
|
||||
case .symptom(let s):
|
||||
ctx.delete(s)
|
||||
@@ -167,7 +201,7 @@ struct TimelineEntryDetailView: View {
|
||||
case .indicator: return String(appLoc: "指标详情")
|
||||
case .bloodPressure: return String(appLoc: "血压详情")
|
||||
case .report: return String(appLoc: "报告详情")
|
||||
case .diary: return String(appLoc: "日记详情")
|
||||
case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
|
||||
case .symptom: return String(appLoc: "症状详情")
|
||||
}
|
||||
}
|
||||
@@ -186,28 +220,31 @@ struct TimelineEntryDetailView: View {
|
||||
// MARK: - 指标
|
||||
|
||||
private func indicatorBody(_ i: Indicator) -> some View {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(i.status)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(i.value)
|
||||
.font(.tjScaled( 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
if !i.unit.isEmpty {
|
||||
Text(i.unit).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(i.status)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(i.value)
|
||||
.font(.tjScaled( 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
if !i.unit.isEmpty {
|
||||
Text(i.unit).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
divider
|
||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
|
||||
if let report = i.report {
|
||||
evidenceButton(for: i, assets: report.assets)
|
||||
}
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
divider
|
||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
|
||||
if let report = i.report {
|
||||
evidenceButton(for: i, assets: report.assets)
|
||||
}
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,21 +254,28 @@ struct TimelineEntryDetailView: View {
|
||||
let combined: IndicatorStatus = sys.status != .normal
|
||||
? sys.status
|
||||
: (dia?.status ?? .normal)
|
||||
return card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(combined)
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(combined)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||
.font(.tjScaled( 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
divider
|
||||
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||
.font(.tjScaled( 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
divider
|
||||
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
|
||||
// 血压走双字段:seriesKey 用 bp.systolic 反查到 MonitorMetric.bloodPressure。
|
||||
RecordAnotherButton(name: String(appLoc: "血压"),
|
||||
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
|
||||
name: String(appLoc: "血压"),
|
||||
unit: "mmHg", range: sys.range))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,16 +292,16 @@ struct TimelineEntryDetailView: View {
|
||||
TjBadge(text: r.type.label, style: .neutral)
|
||||
Text(Self.dateText(r.reportDate))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
if !r.assets.isEmpty {
|
||||
Text(String(appLoc: "原图\(r.assets.count)张"))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
if let inst = r.institution, !inst.isEmpty {
|
||||
field(String(appLoc: "机构"), inst)
|
||||
}
|
||||
}
|
||||
|
||||
if !r.assets.isEmpty {
|
||||
reportPhotosCard(r.assets)
|
||||
}
|
||||
|
||||
ReportSummaryCard(report: r)
|
||||
|
||||
if !r.indicators.isEmpty {
|
||||
@@ -286,26 +330,146 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 日记
|
||||
|
||||
private func diaryBody(_ d: DiaryEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(d.content)
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !d.tags.isEmpty {
|
||||
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
/// 报告原图卡:可点缩略图 → 全屏翻页查看。归档只存图时,这是看原图的唯一入口,必须独立于指标存在。
|
||||
private func reportPhotosCard(_ assets: [Asset]) -> some View {
|
||||
card {
|
||||
HStack {
|
||||
Text(String(appLoc: "原图\(assets.count)张"))
|
||||
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Text(String(appLoc: "点图放大")).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
|
||||
Button {
|
||||
reportPhotoStart = ReportPhotoPage(index: idx)
|
||||
} label: {
|
||||
reportThumb(asset)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reportThumb(_ asset: Asset) -> some View {
|
||||
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
|
||||
Image(uiImage: img).resizable().scaledToFill()
|
||||
} placeholder: { isLoading in
|
||||
if isLoading {
|
||||
Tj.Palette.paper
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
}
|
||||
}
|
||||
.frame(width: 96, height: 120)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 日记
|
||||
|
||||
@ViewBuilder
|
||||
private func diaryBody(_ d: DiaryEntry) -> some View {
|
||||
if d.isMedicationLog {
|
||||
medicationBody(d)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(d.content)
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !d.tags.isEmpty {
|
||||
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用药使用记录(展示药名/剂量/时间 + 设置提醒)
|
||||
|
||||
/// 用药使用记录(带「用药」tag 的日记):展示「药名 [规格] · 剂量」+ 时间,下方「设置提醒」。
|
||||
/// 只到点提示,不做剂量/频次建议(CLAUDE.md §1、§10)。
|
||||
private func medicationBody(_ d: DiaryEntry) -> some View {
|
||||
let lines = Self.medicationLines(d.content)
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
if lines.isEmpty {
|
||||
Text(d.content)
|
||||
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { idx, line in
|
||||
if idx > 0 { divider }
|
||||
Text(line)
|
||||
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
medicationActionRow(d)
|
||||
|
||||
Text("「设置提醒」只到点提示,不提供任何用药或剂量建议。")
|
||||
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 单动作:设置提醒(复用自由提醒表单,预填药名 + 用法)。只到点提示,不做剂量建议。
|
||||
private func medicationActionRow(_ d: DiaryEntry) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
medAction(title: String(appLoc: "设置提醒"), icon: "bell.badge") {
|
||||
let lines = Self.medicationLines(d.content)
|
||||
if lines.count <= 1 {
|
||||
let f = Self.medicationReminderFields(forLine: lines.first ?? d.content)
|
||||
reminderPrefill = ReminderPrefill(title: f.title, note: f.note)
|
||||
} else {
|
||||
// 多种药:一个提醒涵盖,药名清单进备注,用户据此自定时间/频率。
|
||||
reminderPrefill = ReminderPrefill(title: String(appLoc: "服药提醒"),
|
||||
note: lines.joined(separator: "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func medAction(title: String, icon: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon).font(.tjScaled( 18, weight: .medium))
|
||||
Text(title).font(.tjScaled( 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.amber.opacity(0.14))
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - 症状
|
||||
|
||||
private func symptomBody(_ s: Symptom) -> some View {
|
||||
@@ -412,6 +576,76 @@ struct TimelineEntryDetailView: View {
|
||||
private nonisolated static func dateText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
|
||||
// MARK: - 用药行解析(纯函数,便于单测)
|
||||
|
||||
/// 把用药日记 content 按换行拆成单行药品,去掉空白行与首尾空格。
|
||||
nonisolated static func medicationLines(_ content: String) -> [String] {
|
||||
content.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
/// 从一行药品文本(如「缬沙坦胶囊 80mg · 一日一次」)派生吃药提醒预填:
|
||||
/// 标题 =「吃药:<药名+规格>」,备注 = 用法(" · " 之后部分,供用户据此选时间/频率)。
|
||||
nonisolated static func medicationReminderFields(forLine line: String) -> (title: String, note: String) {
|
||||
let parts = line.components(separatedBy: " · ")
|
||||
let head = (parts.first ?? line).trimmingCharacters(in: .whitespaces)
|
||||
let usage = parts.count > 1
|
||||
? parts.dropFirst().joined(separator: " · ").trimmingCharacters(in: .whitespaces)
|
||||
: ""
|
||||
let name = head.isEmpty ? line.trimmingCharacters(in: .whitespaces) : head
|
||||
return (title: String(appLoc: "吃药:") + name, note: usage)
|
||||
}
|
||||
}
|
||||
|
||||
/// 报告原图浏览(纯翻页看图,无指标高亮)。归档只存图的报告也能随时调取查看。
|
||||
private struct ReportImagesViewer: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let assets: [Asset]
|
||||
@State private var selection: Int
|
||||
|
||||
init(assets: [Asset], startIndex: Int) {
|
||||
self.assets = assets
|
||||
_selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
Text("原图 · 第 \(selection + 1)/\(assets.count) 页")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(Tj.Palette.sand)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
|
||||
TabView(selection: $selection) {
|
||||
ForEach(Array(assets.enumerated()), id: \.offset) { index, asset in
|
||||
EvidenceImagePage(asset: asset, highlight: nil)
|
||||
.tag(index)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never))
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
}
|
||||
}
|
||||
|
||||
/// 原图证据预览(翻页 + 高亮框)。指标详情与同类聚合详情共用,故为模块内可见。
|
||||
@@ -479,19 +713,16 @@ private struct EvidenceImagePage: View {
|
||||
let asset: Asset
|
||||
let highlight: CGRect?
|
||||
|
||||
private var image: UIImage? {
|
||||
try? FileVault.shared.loadImage(relativePath: asset.relativePath)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if let image {
|
||||
VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { image in
|
||||
ZStack {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
if let highlight {
|
||||
// 降采样保持原始宽高比,imageSize 仅用于算 letterbox 比例,定位不受影响。
|
||||
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
|
||||
}
|
||||
}
|
||||
@@ -502,9 +733,14 @@ private struct EvidenceImagePage: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
} placeholder: { isLoading in
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user