import SwiftUI import UIKit /// 异常项快拍 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。 /// 与「记录指标」自由输入落库一致 —— 每个勾选项 = 一条独立 Indicator。 struct QuickRegionConfirmView: View { let image: UIImage? let warning: String? let onSave: ([QuickRegionItem], Date) -> Void let onCancel: () -> Void let onRetake: () -> Void @State private var items: [QuickRegionItem] @State private var capturedAt: Date init(image: UIImage?, items: [QuickRegionItem], warning: String?, capturedAt: Date = .now, onSave: @escaping ([QuickRegionItem], Date) -> Void, onCancel: @escaping () -> Void, onRetake: @escaping () -> Void) { self.image = image self.warning = warning self.onSave = onSave self.onCancel = onCancel self.onRetake = onRetake _items = State(initialValue: items) _capturedAt = State(initialValue: capturedAt) } private var selectedCount: Int { items.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty && !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }.count } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { if let warning { warningBanner(warning) } if let image { thumbnailCard(image) } timeCard itemsCard } .padding(20) } .safeAreaInset(edge: .bottom) { bottomBar } .background(Tj.Palette.sand.ignoresSafeArea()) } // MARK: - 区块 private func warningBanner(_ text: String) -> some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.amber) Text(text) .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text2) Spacer() } .padding(12) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.amber.opacity(0.12)) ) } private func thumbnailCard(_ image: UIImage) -> some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("拍到的局部") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) Spacer() Text("仅核对用 · 不保存照片") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) } Image(uiImage: image) .resizable() .scaledToFit() .frame(maxWidth: .infinity) .frame(maxHeight: 180) .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) Button { onRetake() } label: { Label("重拍", systemImage: "camera.rotate") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Tj.Palette.ink) } } .padding(16) .tjCard() } private var timeCard: some View { VStack(alignment: .leading, spacing: 10) { Text("测量时间") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) DatePicker("", selection: $capturedAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() } .padding(16) .tjCard() } private var itemsCard: some View { VStack(alignment: .leading, spacing: 14) { HStack { Text("识别到的指标 (\(items.count))") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) Spacer() Button { items.append(QuickRegionItem(name: "", value: "", unit: "", range: "", status: .high, include: true)) } label: { Label("加一项", systemImage: "plus.circle.fill") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Tj.Palette.ink) } } if items.isEmpty { Text("没有识别到指标,点「加一项」手动补充,或返回重拍") .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 20) } else { ForEach($items) { $item in itemRow($item) } } } .padding(16) .tjCard() } private func itemRow(_ item: Binding) -> some View { let abnormal = item.wrappedValue.status != .normal return VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { Button { item.wrappedValue.include.toggle() } label: { Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle") .font(.system(size: 20)) .foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3) } .buttonStyle(.plain) TextField(String(appLoc: "指标名"), text: item.name) .font(.system(size: 15, weight: .medium)) if abnormal { Text(statusLabel(item.wrappedValue.status)) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(statusColor(item.wrappedValue.status)) .padding(.horizontal, 7).padding(.vertical, 3) .background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16))) } Button { if let idx = items.firstIndex(where: { $0.id == item.wrappedValue.id }) { items.remove(at: idx) } } label: { Image(systemName: "trash") .font(.system(size: 14)) .foregroundStyle(Tj.Palette.brick) } } HStack(spacing: 10) { fieldCol(String(appLoc: "数值"), item.value, width: 80, mono: true) fieldCol(String(appLoc: "单位"), item.unit, width: 80) fieldCol(String(appLoc: "范围"), item.range) } statusPicker(item) } .padding(12) .opacity(item.wrappedValue.include ? 1 : 0.5) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(abnormal ? statusColor(item.wrappedValue.status).opacity(0.6) : Tj.Palette.line, lineWidth: abnormal ? 1.5 : 1) ) } private func fieldCol(_ label: String, _ text: Binding, width: CGFloat? = nil, mono: Bool = false) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) TextField("", text: text) .font(.system(size: 14, weight: mono ? .semibold : .regular, design: mono ? .monospaced : .default)) .keyboardType(mono ? .decimalPad : .default) .textInputAutocapitalization(.never) .autocorrectionDisabled() .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.sand) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) .frame(width: width) } .frame(maxWidth: width == nil ? .infinity : nil, alignment: .leading) } private func statusPicker(_ item: Binding) -> some View { HStack(spacing: 8) { ForEach(IndicatorStatus.allCases, id: \.self) { st in let selected = item.wrappedValue.status == st Button { item.wrappedValue.status = st } label: { Text(statusLabel(st)) .font(.system(size: 12, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Capsule().fill(selected ? statusColor(st) : Tj.Palette.paper)) .overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)) } .buttonStyle(.plain) } Spacer() } } private func statusLabel(_ s: IndicatorStatus) -> String { switch s { case .normal: return String(appLoc: "正常") case .high: return String(appLoc: "偏高 ↑") case .low: return String(appLoc: "偏低 ↓") } } private func statusColor(_ s: IndicatorStatus) -> Color { switch s { case .normal: return Tj.Palette.leaf case .high: return Tj.Palette.brick case .low: return Tj.Palette.amber } } private var bottomBar: some View { HStack(spacing: 12) { Button(action: onCancel) { Text("取消") .frame(maxWidth: .infinity) } .buttonStyle(TjGhostButton()) Button { onSave(items, capturedAt) } label: { Text(selectedCount > 0 ? "\(String(appLoc: "保存到记录"))(\(selectedCount))" : String(appLoc: "保存到记录")) .frame(maxWidth: .infinity) } .buttonStyle(TjPrimaryButton()) .disabled(selectedCount == 0) .opacity(selectedCount == 0 ? 0.4 : 1) } .padding(.horizontal, 20) .padding(.vertical, 14) .background( Tj.Palette.sand .overlay(alignment: .top) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } ) } } /// 确认页可编辑行模型。`include` 控制是否落库(异常项默认勾选,正常项也默认勾选但可取消)。 struct QuickRegionItem: Identifiable { let id = UUID() var name: String var value: String var unit: String var range: String var status: IndicatorStatus var include: Bool }