Files
kangkang/康康/Features/Profile/ProfileEditView.swift
link2026 836f3d4234 ```
feat(AI): 统一多模态模型架构,整合文本和视觉推理路径

- 将文本生成和VL(图→文)功能合并到单一的Qwen3.5-4B多模态MNN模型
- 移除独立的Qwen3-VL-4B模型依赖,MLX VL改为使用.llm的多模态模型
- 更新ModelKind枚举,新增userFacing集合用于面向用户展示
- MNN后端现在同时支持文本和视觉任务,模拟器回退到MLX

refactor(models): 模型管理和界面调整以适应新的多模态架构

- 更新模型管理界面,只显示统一的Qwen3.5-4B(MNN)模型给用户
- 修改就绪状态检查逻辑,使用ModelKind.userFacing替代allCases
- 更新模型文件清单,从Qwen3.5-2B升级到Qwen3.5-4B-4bit
- 调整模型管理页面UI,突出MNN+SME2端侧加速功能

feat(camera): 添加拍照识别引擎切换功能

- 实现双路径拍照识别:Apple Vision OCR + 文本模型 和 Qwen3-VL直接识别
- 添加预处理逻辑,优化Qwen3-VL对窄长区域图片的识别效果
- 在模型管理页面添加拍照识别引擎选择组件
- 提供用户界面选项,在两种识别方式间切换

style(ui): 优化输入框样式和颜色主题一致性

- 为指标快速表单添加浅色主题偏好
- 统一所有文本输入框的颜色样式(theme)
- 创建EntryInputField组件,替换原有的单行输入+按钮模式
- 实现聊天框风格的条目输入,支持多行自适应和圆形发送按钮

fix(build): 修正Xcode项目配置中的重复框架搜索路径

- 清理project.pbxproj中重复的FRAMEWORK_SEARCH_PATHS配置
- 重新排列Swift桥接头文件配置确保正确引用
- 修复因路径配置重复导致的编译警告问题

test: 增加区域图片预处理和模型清单测试覆盖

- 添加RegionImageCropper.prepareForQwenVL的单元测试
- 验证宽而矮图片的放大和填充逻辑
- 更新ModelManifestTests中的字节数预期值以匹配新模型
- 修正OCRService中VNRecognizedTextObservation类型的处理
```
2026-06-08 23:25:31 +08:00

541 lines
20 KiB
Swift

import SwiftUI
import SwiftData
/// · Form ,( Save )
/// UserProfile SwiftData : UserProfileStore.loadOrCreate
struct ProfileEditView: View {
@Environment(\.modelContext) private var ctx
@Query private var profiles: [UserProfile]
var body: some View {
if let p = profiles.first {
ProfileEditForm(profile: p)
} else {
ProgressView()
.onAppear { _ = UserProfileStore.loadOrCreate(in: ctx) }
}
}
}
///
///
/// ( Row ):
/// SwiftData `@Model` Observation,
/// `body`,(/,
/// `@State` ) `body`,
/// 126 `Text(year)`
///
/// :
/// - `ProfileEditForm.body` `profile.*` `@State`,
/// ,
/// - Row / Section ,Observation
/// - `@State` Section ,
/// - .wheel , 126 ,
/// UIPickerView ,
private struct ProfileEditForm: View {
@Environment(\.modelContext) private var ctx
@Bindable var profile: UserProfile
@State private var healthImportDraft: HealthProfileImportDraft?
@State private var healthImportError: String?
@State private var isImportingHealthProfile = false
var body: some View {
Form {
Section {
Button {
importHealthProfile()
} label: {
HStack(spacing: 10) {
if isImportingHealthProfile {
ProgressView()
} else {
Image(systemName: "heart.text.square")
.foregroundStyle(Tj.Palette.ink)
}
VStack(alignment: .leading, spacing: 2) {
Text("从 Apple 健康导入")
.foregroundStyle(Tj.Palette.text)
Text("只读取生日、性别、身高、血型")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
}
.disabled(isImportingHealthProfile)
.accessibilityElement(children: .combine)
.accessibilityLabel("从 Apple 健康导入")
.accessibilityHint("读取生日、性别、身高和血型,确认后填入个人资料")
} footer: {
Text("导入前会先显示预览,确认后才覆盖个人资料。")
}
Section {
BirthYearRow(profile: profile)
SexRow(profile: profile)
HeightRow(profile: profile)
WeightRow(profile: profile)
BloodTypeRow(profile: profile)
} header: {
Text("基本")
} footer: {
BMIFooter(profile: profile)
}
ChronicSection(profile: profile)
StringListSection(title: String(appLoc: "过敏史"), placeholder: String(appLoc: "如:青霉素"),
items: $profile.allergies)
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
items: $profile.familyHistory)
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
items: $profile.currentMedications)
}
.navigationTitle("个人资料")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.onDisappear {
profile.updatedAt = .now
try? ctx.save()
}
.sheet(item: $healthImportDraft) { draft in
HealthProfileImportPreviewSheet(
draft: draft,
profile: profile
) {
draft.apply(to: profile)
try? ctx.save()
healthImportDraft = nil
}
}
.alert("无法导入 Apple 健康资料", isPresented: Binding(
get: { healthImportError != nil },
set: { if !$0 { healthImportError = nil } }
)) {
Button("", role: .cancel) { healthImportError = nil }
} message: {
Text(healthImportError ?? "")
}
}
private func importHealthProfile() {
guard !isImportingHealthProfile else { return }
isImportingHealthProfile = true
healthImportError = nil
Task {
do {
healthImportDraft = try await HealthProfileImportService.shared.fetchDraft()
} catch {
healthImportError = error.localizedDescription
}
isImportingHealthProfile = false
}
}
}
private struct HealthProfileImportPreviewSheet: View {
@Environment(\.dismiss) private var dismiss
let draft: HealthProfileImportDraft
let profile: UserProfile
let onApply: () -> Void
private var preview: HealthProfileImportPreview {
HealthProfileImportPreview(draft: draft, current: profile)
}
var body: some View {
NavigationStack {
List {
Section {
ForEach(preview.fields, id: \.title) { field in
HStack(alignment: .firstTextBaseline) {
Text(field.title)
.foregroundStyle(Tj.Palette.text)
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 4) {
Text(field.imported ?? "未读取到")
.foregroundStyle(field.imported == nil ? Tj.Palette.text3 : Tj.Palette.text)
Text("当前: \(field.current)")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
}
} footer: {
Text("未读取到的字段不会修改。")
}
}
.navigationTitle("确认导入")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
onApply()
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}
// MARK: - :(,)
/// : `.wheel` , 126
private struct BirthYearRow: View {
@Bindable var profile: UserProfile
@State private var expanded = false
private var currentYear: Int {
Calendar.current.component(.year, from: .now)
}
/// birthYear / expanded ,;
/// `years` (body )
private var years: [Int] {
Array((1900...currentYear).reversed())
}
private var selectedLabel: String {
if let y = profile.birthYear {
let age = currentYear - y
return age >= 0 ? "\(y)(\(age)\(String(appLoc: "")))" : String(y)
}
return String(appLoc: "未设置")
}
private var yearBinding: Binding<Int> {
Binding(
get: { profile.birthYear ?? 0 },
set: { profile.birthYear = $0 == 0 ? nil : $0 }
)
}
var body: some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
} label: {
HStack {
Text("出生年份").foregroundStyle(Tj.Palette.text)
Spacer()
Text(selectedLabel)
.foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2)
Image(systemName: "chevron.right")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if expanded {
Picker("出生年份", selection: yearBinding) {
Text("未设置").tag(0)
ForEach(years, id: \.self) { year in
Text(String(year)).tag(year)
}
}
.pickerStyle(.wheel)
.frame(maxHeight: 140)
}
}
}
private struct SexRow: View {
@Bindable var profile: UserProfile
var body: some View {
Picker("性别", selection: Binding(
get: { profile.sex },
set: { profile.sex = $0 }
)) {
ForEach(UserProfile.Sex.allCases, id: \.self) { s in
Text(s.label).tag(s)
}
}
.pickerStyle(.segmented)
}
}
/// :, 80pt
/// ,,
private struct HeightRow: View {
@Bindable var profile: UserProfile
@FocusState private var focused: Bool
var body: some View {
HStack {
Text("身高")
Spacer()
TextField("cm", value: $profile.heightCM, format: .number)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
.focused($focused)
Text("cm").foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
private struct WeightRow: View {
@Bindable var profile: UserProfile
@FocusState private var focused: Bool
var body: some View {
HStack {
Text("体重")
Spacer()
TextField("kg", value: $profile.weightKG, format: .number.precision(.fractionLength(0...1)))
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
.focused($focused)
Text("kg").foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
private struct BloodTypeRow: View {
@Bindable var profile: UserProfile
var body: some View {
Picker("血型", selection: $profile.bloodTypeRaw) {
Text("不知道").tag("")
Text("A 型").tag("A")
Text("B 型").tag("B")
Text("AB 型").tag("AB")
Text("O 型").tag("O")
}
}
}
/// BMI : heightCM + weightKG,
private struct BMIFooter: View {
@Bindable var profile: UserProfile
var body: some View {
if let bmi = profile.bmi {
Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))")
.font(.tjScaled( 11))
}
}
private func label(_ bmi: Double) -> String {
switch bmi {
case ..<18.5: return String(appLoc: "(偏瘦)")
case ..<24: return String(appLoc: "(正常)")
case ..<28: return String(appLoc: "(超重)")
default: return String(appLoc: "(肥胖)")
}
}
}
// MARK: -
private struct ChronicSection: View {
@Bindable var profile: UserProfile
@State private var newCustomCondition = ""
/// :,( static/let )
private var presets: [String] {
[String(appLoc: "高血压"), String(appLoc: "糖尿病"), String(appLoc: "冠心病"), String(appLoc: "高血脂"),
String(appLoc: "甲状腺疾病"), String(appLoc: "哮喘"), String(appLoc: "慢性肾病"), String(appLoc: "抑郁/焦虑")]
}
var body: some View {
Section {
FlexibleChipGrid {
ForEach(presets, id: \.self) { name in
chip(label: name,
selected: profile.chronicConditions.contains(name)) {
toggle(name)
}
}
ForEach(profile.chronicConditions.filter { !presets.contains($0) },
id: \.self) { name in
chip(label: name, selected: true) {
profile.chronicConditions.removeAll { $0 == name }
}
}
}
EntryInputField(placeholder: String(appLoc: "自定义慢病"), text: $newCustomCondition) {
let trimmed = newCustomCondition.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty,
!profile.chronicConditions.contains(trimmed) else { return }
profile.chronicConditions.append(trimmed)
newCustomCondition = ""
}
.listRowBackground(Color.clear)
} header: {
Text("慢病(影响参考范围与 AI 解读)")
}
}
private func toggle(_ name: String) {
if profile.chronicConditions.contains(name) {
profile.chronicConditions.removeAll { $0 == name }
} else {
profile.chronicConditions.append(name)
}
}
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
}
.buttonStyle(.plain)
}
}
// MARK: - ( + + )
/// `TextField + ` :(1~4 ),
/// () / / /
private struct EntryInputField: View {
let placeholder: String
@Binding var text: String
var onSubmit: () -> Void
private var canSubmit: Bool {
!text.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
TextField(placeholder, text: $text, axis: .vertical)
.lineLimit(1...4)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
Button {
if canSubmit { onSubmit() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.tjScaled(28))
.foregroundStyle(canSubmit ? Tj.Palette.ink : Tj.Palette.text3)
}
.buttonStyle(.plain)
.disabled(!canSubmit)
}
.padding(.vertical, 2)
}
}
// MARK: - / / ( @State,)
private struct StringListSection: View {
let title: String
let placeholder: String
@Binding var items: [String]
@State private var newInput = ""
var body: some View {
Section(title) {
ForEach(items, id: \.self) { item in
HStack {
Text(item)
Spacer()
Button(role: .destructive) {
items.removeAll { $0 == item }
} label: {
Image(systemName: "minus.circle")
.foregroundStyle(Tj.Palette.brick)
}
.buttonStyle(.borderless)
}
}
EntryInputField(placeholder: placeholder, text: $newInput) {
let trimmed = newInput.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, !items.contains(trimmed) else { return }
items.append(trimmed)
newInput = ""
}
.listRowBackground(Color.clear)
}
}
}
// MARK: - chip (SwiftUI Wrap, Layout )
struct FlexibleChipGrid<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
FlowLayout { content() }
}
}
private struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var rows: [CGFloat] = [0]
var rowMaxHeight: [CGFloat] = [0]
var x: CGFloat = 0
for s in subviews {
let size = s.sizeThatFits(.unspecified)
if x + size.width > maxWidth, x > 0 {
rows.append(0); rowMaxHeight.append(0)
x = 0
}
rows[rows.count - 1] = max(rows[rows.count - 1], x + size.width)
rowMaxHeight[rowMaxHeight.count - 1] = max(rowMaxHeight.last ?? 0, size.height)
x += size.width + spacing
}
let totalHeight = rowMaxHeight.reduce(0, +) + spacing * CGFloat(max(0, rows.count - 1))
let totalWidth = rows.max() ?? 0
return CGSize(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x: CGFloat = bounds.minX
var y: CGFloat = bounds.minY
var rowHeight: CGFloat = 0
for s in subviews {
let size = s.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX, x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
s.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
#Preview {
NavigationStack {
ProfileEditView()
}
.modelContainer(for: [UserProfile.self], inMemory: true)
}