Files
kangkang/康康/Features/Quick/QuickRegionConfirmView.swift
link2026 b79ae54b7b ```
feat(iOS): 更新MNN后端模型配置优化性能

将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本
实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX
兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。
```
2026-06-09 22:20:07 +08:00

306 lines
11 KiB
Swift

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(.tjScaled( 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(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text("仅核对用 · 不保存照片")
.font(.tjScaled( 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(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
.padding(16)
.tjCard()
}
private var timeCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("测量时间")
.font(.tjScaled( 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(.tjScaled( 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(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
if items.isEmpty {
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
.font(.tjScaled( 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<QuickRegionItem>) -> 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(.tjScaled( 20))
.foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3)
}
.buttonStyle(.plain)
TextField(String(appLoc: "指标名"), text: item.name)
.font(.tjScaled( 15, weight: .medium))
if abnormal {
Text(statusLabel(item.wrappedValue.status))
.font(.tjScaled( 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(.tjScaled( 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<String>, width: CGFloat? = nil,
mono: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
TextField("", text: text)
.font(.tjScaled( 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<QuickRegionItem>) -> 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(.tjScaled( 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
}