根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
``` chore(config): 更新项目配置文件 - 调整开发环境配置参数 - 优化构建流程设置 - 更新依赖包版本管理 ```
This commit is contained in:
@@ -366,13 +366,21 @@ struct ArchiveListView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// 分类过滤条(2026-06-28 重组):把原先扁平的一排标签按语义归三组——
|
||||
/// 自述(日记/症状/问诊)· 检查(指标/报告)· 用药——组间加竖线分隔、每枚标签带图标 + 类别色,
|
||||
/// 让「一堆标签」读成有结构的三块。过滤逻辑仍是单选某个 TimelineKind(选中再点取消)。
|
||||
private var filterChips: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
|
||||
ForEach(TimelineKind.allCases) { kind in
|
||||
chip(label: kind.label, selected: filter == kind) {
|
||||
filter = filter == kind ? nil : kind
|
||||
allChip
|
||||
ForEach(TimelineKind.Group.allCases) { group in
|
||||
groupSeparator
|
||||
// 单成员组(用药)不另标组名:组名与唯一标签重复,直接出标签即可。
|
||||
if group.kinds.count > 1 {
|
||||
groupCaption(group.caption)
|
||||
}
|
||||
ForEach(group.kinds) { kind in
|
||||
kindChip(kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,23 +388,53 @@ struct ArchiveListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
private var allChip: some View {
|
||||
let selected = filter == nil
|
||||
return Button { filter = nil } label: {
|
||||
Text(String(appLoc: "全部"))
|
||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
|
||||
)
|
||||
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(selected ? Color.clear : Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// 单枚分类标签:图标 + 名称,用该类别的 accent 着色;选中填充 accent。
|
||||
private func kindChip(_ kind: TimelineKind) -> some View {
|
||||
let selected = filter == kind
|
||||
return Button { filter = selected ? nil : kind } label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: kind.icon)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : kind.accent)
|
||||
Text(kind.label)
|
||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Capsule().fill(selected ? kind.accent : Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(selected ? Color.clear : Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var groupSeparator: some View {
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
.fill(Tj.Palette.lineSoft)
|
||||
.frame(width: 1, height: 18)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
|
||||
private func groupCaption(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
||||
HStack {
|
||||
Text(section.label)
|
||||
|
||||
130
康康/Features/Consultation/ConsultationAudioPlayer.swift
Normal file
130
康康/Features/Consultation/ConsultationAudioPlayer.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import Combine
|
||||
|
||||
/// Vault 内一段问诊录音的回放控件(2026-06-28)。播放/暂停 + 进度条 + 时间。
|
||||
/// 录音存在加密 Vault 里,App 前台即解锁可读;读不到(损坏/异常)显示占位,不崩。
|
||||
/// 不做拖拽 seek(降复杂度):进度条只读,demo 够用。
|
||||
struct ConsultationAudioPlayer: View {
|
||||
let asset: Asset
|
||||
|
||||
@State private var player: AVAudioPlayer?
|
||||
@State private var isPlaying = false
|
||||
@State private var currentTime: Double = 0
|
||||
@State private var duration: Double = 0
|
||||
@State private var loadFailed = false
|
||||
|
||||
/// 播放时刷新进度/时间;不播放时空转(开销可忽略)。AVAudioPlayer 播完 isPlaying 自然转 false。
|
||||
private let ticker = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink2)
|
||||
Text(String(appLoc: "问诊录音"))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Text(String(appLoc: "本机存储"))
|
||||
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
if loadFailed {
|
||||
Text("录音无法读取")
|
||||
.font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: togglePlay) {
|
||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.tjScaled( 16, weight: .bold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Circle().fill(Tj.Palette.ink2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Tj.Palette.sand2)
|
||||
Capsule().fill(Tj.Palette.ink2)
|
||||
.frame(width: geo.size.width * progressFraction)
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
HStack {
|
||||
Text(Self.timeText(currentTime))
|
||||
Spacer()
|
||||
Text(Self.timeText(duration))
|
||||
}
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
.onAppear(perform: load)
|
||||
.onDisappear {
|
||||
player?.stop()
|
||||
isPlaying = false
|
||||
}
|
||||
.onReceive(ticker) { _ in
|
||||
guard let p = player, isPlaying else { return }
|
||||
currentTime = p.currentTime
|
||||
if !p.isPlaying { // 播完:复位
|
||||
isPlaying = false
|
||||
currentTime = 0
|
||||
p.currentTime = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressFraction: CGFloat {
|
||||
guard duration > 0 else { return 0 }
|
||||
return CGFloat(min(max(currentTime / duration, 0), 1))
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard player == nil, !loadFailed else { return }
|
||||
do {
|
||||
let url = try FileVault.shared.absoluteURL(forReading: asset.relativePath)
|
||||
let p = try AVAudioPlayer(contentsOf: url)
|
||||
p.prepareToPlay()
|
||||
player = p
|
||||
duration = p.duration
|
||||
} catch {
|
||||
loadFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePlay() {
|
||||
guard let p = player else { return }
|
||||
if isPlaying {
|
||||
p.pause()
|
||||
isPlaying = false
|
||||
} else {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
p.play()
|
||||
isPlaying = true
|
||||
}
|
||||
}
|
||||
|
||||
private static func timeText(_ seconds: Double) -> String {
|
||||
let s = Int(seconds.rounded())
|
||||
return String(format: "%d:%02d", s / 60, s % 60)
|
||||
}
|
||||
}
|
||||
657
康康/Features/Consultation/ConsultationSheet.swift
Normal file
657
康康/Features/Consultation/ConsultationSheet.swift
Normal file
@@ -0,0 +1,657 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 「记录问诊」录入 sheet(2026-06-28)。
|
||||
///
|
||||
/// 流程:开始录音 → 端侧实时转写(`ConsultationRecorder`)→ 停止 → AI 整理成问诊小结
|
||||
/// (`DiaryAssistService.organizeConsultation`)→ 审阅可编辑 → 保存为带「问诊」tag 的 DiaryEntry
|
||||
/// (录音作为 audio/m4a Asset 落加密 Vault)。
|
||||
///
|
||||
/// 守红线:UI 不直接调 AIRuntime(经 DiaryAssistService);识别 + 录音全程本机;
|
||||
/// AI 整理失败回退录音原文(#5);录音落盘尽力而为,失败也照常存文字。
|
||||
/// 本机不支持端侧识别(模拟器/老机型)→ 退化为手动文字录入,功能仍可用。
|
||||
struct ConsultationSheet: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
enum Phase: Equatable { case idle, recording, transcribing, organizing, review }
|
||||
@State private var phase: Phase = .idle
|
||||
|
||||
@State private var liveTranscript = ""
|
||||
@State private var recordingSeconds = 0
|
||||
/// 审阅区可编辑的问诊小结(整理稿;整理失败时是录音原文)。最终落库的就是它。
|
||||
@State private var note = ""
|
||||
/// 最终转写原话:「改用原话」回退用,也作整理失败兜底。
|
||||
@State private var rawTranscript = ""
|
||||
/// 录音临时文件(tmp 里的 m4a)。保存时 importFile 搬进 Vault;未保存则 onDisappear 删掉。
|
||||
@State private var audioTempURL: URL?
|
||||
@State private var createdAt: Date = .now
|
||||
@State private var decodeRate: Double = 0
|
||||
@State private var voiceNote: String?
|
||||
@State private var deniedAlert = false
|
||||
@State private var saved = false
|
||||
|
||||
@State private var recordTask: Task<Void, Never>?
|
||||
@State private var organizeTask: Task<Void, Never>?
|
||||
@State private var watchdog: Task<Void, Never>?
|
||||
/// 必须 @State:struct View 重建时普通 let 会换新实例,导致 stop() 落在没在录音的新实例上、
|
||||
/// 而真正录音的老实例关不掉麦克风悬挂(同 DiaryQuickSheet.dictation 的注释)。
|
||||
@State private var recorder = ConsultationRecorder()
|
||||
@FocusState private var noteFocused: Bool
|
||||
|
||||
/// 录音上限 10 分钟(超时由看门狗触发停止并整理)。
|
||||
private let maxSeconds = 600
|
||||
|
||||
/// 本机不支持端侧识别 → 手动录入模式(进入即可打字,无录音)。
|
||||
private var manualMode: Bool { !ConsultationRecorder.isAvailable }
|
||||
|
||||
private var canSave: Bool {
|
||||
!note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
grabber
|
||||
header
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let note = voiceNote { noteBanner(note) }
|
||||
phaseContent
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
bottomBar
|
||||
}
|
||||
.background(
|
||||
Tj.Palette.sand
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.onDisappear(perform: cleanup)
|
||||
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $deniedAlert) {
|
||||
Button(String(appLoc: "前往设置")) {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("问诊录音全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var grabber: some View {
|
||||
Capsule()
|
||||
.fill(Tj.Palette.line)
|
||||
.frame(width: 40, height: 4)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("记录问诊")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("录音 → 本机转写 → AI 整理成问诊小结")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
// MARK: - Phase content
|
||||
|
||||
@ViewBuilder
|
||||
private var phaseContent: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
if manualMode { manualEntry } else { idleStart }
|
||||
case .recording:
|
||||
recordingCard
|
||||
case .transcribing:
|
||||
transcribingCard
|
||||
case .organizing:
|
||||
organizingCard
|
||||
case .review:
|
||||
reviewArea
|
||||
}
|
||||
}
|
||||
|
||||
/// 待开始:大录音按钮 + 三条说明(本机识别 / 可回放 / 不上传)。
|
||||
private var idleStart: some View {
|
||||
VStack(spacing: 18) {
|
||||
Button(action: startRecording) {
|
||||
VStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.ink2)
|
||||
.frame(width: 76, height: 76)
|
||||
.shadow(color: Tj.Palette.ink2.opacity(0.3), radius: 12, y: 4)
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.tjScaled( 30, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
Text("开始录音")
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 22)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
bullet("stethoscope", String(appLoc: "看医生时录下来,事后不怕记不全"))
|
||||
bullet("waveform", String(appLoc: "录音结束后在本机转写,原声存进加密档案"))
|
||||
bullet("sparkles", String(appLoc: "康康自动整理成主诉/医生建议/复查小节"))
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
|
||||
Text("仅供个人记录,不构成诊断或用药建议。")
|
||||
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
private func bullet(_ icon: String, _ text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink2)
|
||||
.frame(width: 20)
|
||||
Text(text)
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 录音中:计时 + 实时字幕 + 结束按钮。
|
||||
private var recordingCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
Text("正在录音 · 声音不上传")
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Text(Self.timeText(recordingSeconds))
|
||||
.font(.tjScaled( 13, design: .monospaced))
|
||||
.foregroundStyle(recordingSeconds >= maxSeconds - 30 ? Tj.Palette.brick : Tj.Palette.text3)
|
||||
}
|
||||
|
||||
// 离线转写(SenseVoice)无实时字幕:录音中只给声纹动效 + 说明,结束后整段转写。
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.tjScaled( 44, weight: .regular))
|
||||
.foregroundStyle(Tj.Palette.ink2)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
Text("正在录音,结束后会在本机把整段录音转成文字")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 150)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Button(action: stopAndOrganize) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "stop.circle.fill")
|
||||
Text("结束并整理")
|
||||
}
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 13)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.brick)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
/// 录音转写中:整段录音离线转文字(优先本地 SenseVoice,不可用回退本机识别)。无实时字幕,统一转写。
|
||||
private var transcribingCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
Text(SenseVoiceASRService.isAvailable
|
||||
? String(appLoc: "正在转写录音 · 本地 SenseVoice")
|
||||
: String(appLoc: "正在转写录音 · 本机识别"))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Text("录音已结束,正在本机把整段录音转成文字,请稍候…")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
AIFlowBar()
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
/// AI 整理中:呼吸流光 + 置灰原话预览 + 取消(取消即用原话)。
|
||||
private var organizingCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
Text(decodeRate > 0
|
||||
? String(format: String(appLoc: "正在整理问诊小结 · %.1f tok/s"), decodeRate)
|
||||
: String(appLoc: "正在整理问诊小结 · 本地推理"))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Button(String(appLoc: "用原话")) { cancelOrganize() }
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Text(rawTranscript)
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(6)
|
||||
AIFlowBar()
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
/// 审阅:录音已附提示 + 可编辑小结 + (改用原话 / 重新整理) + 时间。
|
||||
private var reviewArea: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if audioTempURL != nil {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.ink2)
|
||||
Text("录音已保存,可在记录详情里回放")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(String(appLoc: "问诊小结"))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
if decodeRate > 0 {
|
||||
Text(String(format: "%.1f tok/s", decodeRate))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
}
|
||||
TextField(String(appLoc: "整理后的问诊小结,可在这里修改…"),
|
||||
text: $note, axis: .vertical)
|
||||
.font(.tjScaled( 15))
|
||||
.lineLimit(6...16)
|
||||
.focused($noteFocused)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if !rawTranscript.isEmpty && note != rawTranscript {
|
||||
reviewChip("arrow.uturn.backward", String(appLoc: "改用原话")) {
|
||||
withAnimation(.snappy(duration: 0.18)) { note = rawTranscript }
|
||||
}
|
||||
}
|
||||
if !rawTranscript.isEmpty {
|
||||
reviewChip("arrow.clockwise", String(appLoc: "重新整理")) { reorganize() }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(appLoc: "时间"))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
DatePicker("", selection: $createdAt, in: ...Date.now)
|
||||
.datePickerStyle(.compact)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewChip(_ icon: String, _ label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon).font(.tjScaled( 10, weight: .semibold))
|
||||
Text(label).font(.tjScaled( 11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// 手动模式(本机不支持识别):直接打字录入问诊小结。
|
||||
private var manualEntry: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("本机暂不支持录音转写,可手动记录这次问诊")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
TextField(String(appLoc: "记录这次问诊:主诉、医生说了什么、用药、复查…"),
|
||||
text: $note, axis: .vertical)
|
||||
.font(.tjScaled( 15))
|
||||
.lineLimit(6...16)
|
||||
.focused($noteFocused)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
DatePicker("", selection: $createdAt, in: ...Date.now)
|
||||
.datePickerStyle(.compact)
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
// MARK: - Bottom bar
|
||||
|
||||
@ViewBuilder
|
||||
private var bottomBar: some View {
|
||||
switch phase {
|
||||
case .idle where manualMode:
|
||||
HStack(spacing: 8) {
|
||||
Button(String(appLoc: "取消")) { dismiss() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||
Button(String(appLoc: "保存")) { save() }
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||
.disabled(!canSave)
|
||||
.opacity(canSave ? 1 : 0.4)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
case .idle:
|
||||
// 非手动模式的待开始页:开始录音是内容区大按钮,关闭走 header 的 ✕,底部不再占一行。
|
||||
EmptyView()
|
||||
case .review:
|
||||
HStack(spacing: 8) {
|
||||
Button(String(appLoc: "重新录音")) { restartRecording() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||
Button(String(appLoc: "保存")) { save() }
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 14))
|
||||
.disabled(!canSave)
|
||||
.opacity(canSave ? 1 : 0.4)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
case .recording, .transcribing, .organizing:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func noteBanner(_ text: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.amber)
|
||||
Text(text).font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func startRecording() {
|
||||
voiceNote = nil
|
||||
noteFocused = false
|
||||
recordTask = Task { @MainActor in
|
||||
guard await recorder.requestAuthorization() else {
|
||||
deniedAlert = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
liveTranscript = ""
|
||||
recordingSeconds = 0
|
||||
try recorder.start { partial in liveTranscript = partial }
|
||||
withAnimation(.snappy(duration: 0.2)) { phase = .recording }
|
||||
watchdog = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled, phase == .recording else { return }
|
||||
recordingSeconds += 1
|
||||
if recordingSeconds >= maxSeconds { stopAndOrganize(); return }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[Consultation] recorder start failed: \(error)")
|
||||
#endif
|
||||
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
|
||||
phase = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAndOrganize() {
|
||||
guard phase == .recording else { return }
|
||||
watchdog?.cancel()
|
||||
organizeTask = Task { @MainActor in
|
||||
let result = await recorder.stop()
|
||||
audioTempURL = result.audioTempURL
|
||||
|
||||
// 系统端侧识别(SFSpeech,录音时顺带跑)作为 SenseVoice 不可用 / 失败时的自动回退稿。
|
||||
var sfFallback = result.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if sfFallback.isEmpty {
|
||||
sfFallback = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// 离线转写整段录音:优先本地 SenseVoice(MNN),不可用 / 失败 / 空 → 回退 SFSpeech(守红线 #5)。
|
||||
withAnimation(.snappy(duration: 0.2)) { phase = .transcribing }
|
||||
var transcript = ""
|
||||
if SenseVoiceASRService.isAvailable, let url = result.audioTempURL {
|
||||
transcript = (try? await SenseVoiceASRService.shared.transcribe(audioFileURL: url)) ?? ""
|
||||
}
|
||||
if Task.isCancelled { return }
|
||||
if transcript.isEmpty { transcript = sfFallback }
|
||||
rawTranscript = transcript
|
||||
|
||||
guard !transcript.isEmpty else {
|
||||
// 没听清:进审阅让用户手动补;录音(若有)仍可保存。
|
||||
voiceNote = String(appLoc: "没听清,可手动补充或重新录音")
|
||||
note = ""
|
||||
withAnimation(.snappy(duration: 0.2)) { phase = .review }
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(.snappy(duration: 0.2)) { phase = .organizing }
|
||||
await runOrganize(transcript: transcript)
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用 LLM 整理;成功填小结,取消/失败回退原话(红线 #5)。供首次整理与「重新整理」复用。
|
||||
private func runOrganize(transcript: String) async {
|
||||
do {
|
||||
let organized = try await DiaryAssistService.shared.organizeConsultation(transcript: transcript)
|
||||
guard !Task.isCancelled else { return }
|
||||
note = organized.text
|
||||
decodeRate = organized.decodeRate
|
||||
} catch is CancellationError {
|
||||
return // cancelOrganize 已处理回退
|
||||
} catch {
|
||||
note = transcript
|
||||
voiceNote = String(appLoc: "AI 整理没成功,已填入录音原文")
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) { phase = .review }
|
||||
}
|
||||
|
||||
private func cancelOrganize() {
|
||||
guard phase == .organizing else { return }
|
||||
organizeTask?.cancel()
|
||||
note = rawTranscript
|
||||
withAnimation(.snappy(duration: 0.2)) { phase = .review }
|
||||
}
|
||||
|
||||
/// 审阅区「重新整理」:从原话再跑一遍 LLM(首次整理结果不满意时)。
|
||||
private func reorganize() {
|
||||
guard !rawTranscript.isEmpty else { return }
|
||||
organizeTask?.cancel()
|
||||
decodeRate = 0
|
||||
withAnimation(.snappy(duration: 0.2)) { phase = .organizing }
|
||||
organizeTask = Task { @MainActor in
|
||||
await runOrganize(transcript: rawTranscript)
|
||||
}
|
||||
}
|
||||
|
||||
/// 审阅区「重新录音」:丢弃当前录音/小结,回到待开始。
|
||||
private func restartRecording() {
|
||||
organizeTask?.cancel()
|
||||
if let url = audioTempURL { try? FileManager.default.removeItem(at: url) }
|
||||
audioTempURL = nil
|
||||
note = ""
|
||||
rawTranscript = ""
|
||||
liveTranscript = ""
|
||||
decodeRate = 0
|
||||
voiceNote = nil
|
||||
withAnimation(.snappy(duration: 0.2)) { phase = .idle }
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let content = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
let entry = DiaryEntry(content: content,
|
||||
createdAt: createdAt,
|
||||
tags: [DiaryEntry.consultationTag])
|
||||
ctx.insert(entry)
|
||||
// 录音搬进加密 Vault 并挂为 Asset(尽力而为:失败也照常存文字)。
|
||||
if let temp = audioTempURL,
|
||||
let savedAsset = try? FileVault.shared.importFile(at: temp, preferredExtension: "m4a") {
|
||||
let asset = Asset(relativePath: savedAsset.relativePath,
|
||||
mimeType: "audio/m4a",
|
||||
bytes: savedAsset.bytes)
|
||||
ctx.insert(asset)
|
||||
entry.assets.append(asset)
|
||||
audioTempURL = nil // 已搬走,cleanup 不再删
|
||||
}
|
||||
try? ctx.save()
|
||||
saved = true
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/// 关闭时收尾:停录音、撤任务、删未保存的录音临时文件。
|
||||
private func cleanup() {
|
||||
recordTask?.cancel()
|
||||
organizeTask?.cancel()
|
||||
watchdog?.cancel()
|
||||
recorder.abort()
|
||||
if !saved, let url = audioTempURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private static func timeText(_ seconds: Int) -> String {
|
||||
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConsultationSheet()
|
||||
.modelContainer(for: [DiaryEntry.self, Asset.self], inMemory: true)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 推理引擎设置:在 MNN(CPU/SME2,考核路径)与 MLX(GPU,兜底)间切换,并展示 SME2 探测状态。
|
||||
/// 推理引擎设置:本项目已切 Gemma-3n(端侧 4bit),主模型只走 MLX/GPU。
|
||||
/// MNN/SME2 路径已停用(Gemma-3n 无 MNN 转换模型),引擎行保留但 MNN 置灰。
|
||||
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
||||
struct InferenceSettingsView: View {
|
||||
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
|
||||
// 云端 AI(Gemini)开关与 key —— 键名与 CloudAI 对齐,@AppStorage 写入即被后端读到。
|
||||
@AppStorage("cloud_ai_gemini_enabled") private var cloudEnabled = false
|
||||
@AppStorage("cloud_ai_gemini_key") private var cloudKey = ""
|
||||
@State private var modelService = ModelDownloadService.shared
|
||||
/// 性能自检改为当前页就地展开,不再 push 新页面。
|
||||
@State private var showSelfTest = false
|
||||
@@ -12,10 +16,9 @@ struct InferenceSettingsView: View {
|
||||
EnginePreference(rawValue: engineRaw) ?? .auto
|
||||
}
|
||||
|
||||
/// 性能自检需要模型就绪(MNN 主或 MLX 兜底任一)。
|
||||
/// 性能自检需要主模型(Gemma-3n,MLX)就绪。
|
||||
private var modelReady: Bool {
|
||||
modelService.states[.mnnLLM]?.phase == .ready
|
||||
|| modelService.states[.llm]?.phase == .ready
|
||||
modelService.states[.llm]?.phase == .ready
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -34,8 +37,8 @@ struct InferenceSettingsView: View {
|
||||
engineRow(engine)
|
||||
}
|
||||
|
||||
sme2Card
|
||||
selfTestSection
|
||||
cloudSection
|
||||
noteCard
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -134,11 +137,11 @@ struct InferenceSettingsView: View {
|
||||
.disabled(!available)
|
||||
}
|
||||
|
||||
/// .auto 永远可用;具体引擎看自身可用性。
|
||||
/// .auto 永远可用;MLX 看自身可用性。MNN 已停用(Gemma-3n 无 MNN 模型),恒置灰。
|
||||
private func isAvailable(_ engine: EnginePreference) -> Bool {
|
||||
switch engine {
|
||||
case .auto: return true
|
||||
case .mnn: return InferenceEngine.mnn.isAvailable
|
||||
case .mnn: return false
|
||||
case .mlx: return InferenceEngine.mlx.isAvailable
|
||||
}
|
||||
}
|
||||
@@ -154,52 +157,74 @@ struct InferenceSettingsView: View {
|
||||
private func subtitle(_ engine: EnginePreference, available: Bool) -> String {
|
||||
switch engine {
|
||||
case .auto:
|
||||
// 显示自动解析后实际命中的引擎,让用户看清「这台机选了什么」。
|
||||
let resolved = engine.resolved
|
||||
if resolved == .mnn {
|
||||
return InferenceEngine.cpuSupportsSME2
|
||||
? String(appLoc: "按本机配置选择 · 当前 MNN + SME2")
|
||||
: String(appLoc: "按本机配置选择 · 当前 MNN(NEON)")
|
||||
} else {
|
||||
return String(appLoc: "按本机配置选择 · 当前 MLX(MNN 不可用)")
|
||||
}
|
||||
// 已切 Gemma-3n,auto 恒解析为 MLX。
|
||||
return String(appLoc: "按本机配置选择 · 当前 MLX · GPU")
|
||||
case .mnn:
|
||||
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
|
||||
return InferenceEngine.cpuSupportsSME2
|
||||
? String(appLoc: "端侧 CPU + SME2 加速 · 挑战赛考核路径")
|
||||
: String(appLoc: "端侧 CPU(本机无 SME2,NEON 回退)")
|
||||
return String(appLoc: "已停用:Gemma-3n 无 MNN 转换模型,统一走 MLX")
|
||||
case .mlx:
|
||||
return String(appLoc: "Metal GPU · 兜底 / 对照")
|
||||
return String(appLoc: "Metal GPU · 端侧推理 Gemma-3n E2B")
|
||||
}
|
||||
}
|
||||
|
||||
private var sme2Card: some View {
|
||||
let sme2 = InferenceEngine.cpuSupportsSME2
|
||||
return HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(sme2 ? Tj.Palette.leafSoft : Tj.Palette.sand2)
|
||||
Image(systemName: sme2 ? "checkmark.seal.fill" : "minus.circle")
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(sme2 ? Tj.Palette.ink : Tj.Palette.text2)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Arm SME2")
|
||||
.font(.tjScaled(15, weight: .medium))
|
||||
private var cloudConfigured: Bool {
|
||||
cloudEnabled && !cloudKey.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
/// 云端 AI(Gemini)开关区。hybrid 的「云端」一侧:默认关(隐私优先),
|
||||
/// 开启并填入 AI Studio 的 key 后,「读报告原图 / 深度解读 / 多语言」走 Google Gemini。
|
||||
private var cloudSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("云端 AI · Gemini")
|
||||
.font(.tjTitle())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(sme2 ? String(appLoc: "本设备支持,MNN 已启用 SME2 加速")
|
||||
: String(appLoc: "本设备不支持(需 A19/iPhone 17+)"))
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
.padding(.top, 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle(isOn: $cloudEnabled) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("启用云端增强")
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("默认端侧 Gemma-3n;开启后「读报告原图 / 深度解读 / 多语言」走 Google Gemini。")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.tint(Tj.Palette.leaf)
|
||||
|
||||
if cloudEnabled {
|
||||
SecureField(String(appLoc: "粘贴 Google AI Studio 的 API Key"), text: $cloudKey)
|
||||
.font(.tjScaled(14))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: Tj.Radius.md).fill(Tj.Palette.sand2))
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: cloudConfigured ? "checkmark.seal.fill" : "exclamationmark.circle")
|
||||
.font(.tjScaled(13))
|
||||
.foregroundStyle(cloudConfigured ? Tj.Palette.leaf : Tj.Palette.text3)
|
||||
Text(cloudConfigured
|
||||
? String(appLoc: "已就绪 · \(AIRuntime.cloudLabel)")
|
||||
: String(appLoc: "请填入 API Key(aistudio.google.com 免费获取)"))
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Text("Key 仅存本机;断网或额度用尽时自动回退端侧 Gemma-3n,功能不中断。")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.tjCard()
|
||||
}
|
||||
.padding(14)
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
private var noteCard: some View {
|
||||
Text("MNN 在端侧 CPU 上以 Arm SME2 指令集加速 Qwen 推理(本地、不上云)。切换后下一次 AI 调用生效。")
|
||||
Text("隐私优先:默认端侧 Gemma-3n(MLX · Metal GPU)推理,数据不出设备;仅在你开启「云端 AI」后,深度任务才走 Google Gemini。切换后下一次 AI 调用生效。")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -141,7 +141,7 @@ struct ModelManagementView: View {
|
||||
} else if allReady {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
Text("Qwen3.5-2B 已就绪")
|
||||
Text("Gemma-3n E2B 已就绪")
|
||||
}
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
@@ -205,9 +205,9 @@ struct ModelManagementView: View {
|
||||
|
||||
private func subtitle(_ kind: ModelKind) -> String {
|
||||
switch kind {
|
||||
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答(MLX 兜底)")
|
||||
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答 / 拍照识别 · MLX 端侧推理")
|
||||
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
|
||||
case .mnnLLM: return String(appLoc: "文本解读 + 拍照识别 · MNN + SME2 端侧加速")
|
||||
case .mnnLLM: return String(appLoc: "已停用 · 旧 MNN 路径")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ struct ModelSelfTestView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("在「我的 · 推理引擎」切换引擎后再跑一次,即可对比 SME2 与 GPU。")
|
||||
Text("Gemma-3n E2B 在端侧 MLX(Metal GPU)推理,100% 本地、不上云。")
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary
|
||||
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary, consultation
|
||||
var id: String { rawValue }
|
||||
|
||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||
@@ -9,7 +9,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
/// `.symptom`(记录症状)与拍药盒一起并入 `.diary`(健康日记)顶部三选一,不再单列;
|
||||
/// `.medicationLibrary`(药品库)是浏览/管理目的地,主入口在「记录」Tab 顶部板卡,
|
||||
/// 这里垫底保留一个快捷方式(创建动作在前,药品库作为「去管理」入口排最后)。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
|
||||
static let displayOrder: [RecordKind] = [.diary, .consultation, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
|
||||
|
||||
/// 健康日记行的功能提示 pill(代替 subtitle,让"症状/药盒在日记里"一眼可见)。
|
||||
/// 计算属性:每次按当前语言解析,语言切换即时更新(同 ProfileEditView 的 presets 约定)。
|
||||
@@ -24,6 +24,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .healthExport: return String(appLoc: "身体档案")
|
||||
case .archive: return String(appLoc: "体检报告归档")
|
||||
case .diary: return String(appLoc: "健康日记")
|
||||
case .consultation: return String(appLoc: "记录问诊")
|
||||
case .symptom: return String(appLoc: "记录症状")
|
||||
case .reminder: return String(appLoc: "开启一个提醒")
|
||||
case .medicationLibrary: return String(appLoc: "药品库")
|
||||
@@ -36,6 +37,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
|
||||
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
|
||||
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
|
||||
case .consultation: return String(appLoc: "看医生时录音,本机转写并整理成小结")
|
||||
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
|
||||
case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加")
|
||||
@@ -48,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .healthExport: return "doc.text.below.ecg"
|
||||
case .archive: return "doc.fill"
|
||||
case .diary: return "heart.text.square"
|
||||
case .consultation: return "stethoscope"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
case .reminder: return "bell.badge"
|
||||
case .medicationLibrary: return "pills.fill"
|
||||
@@ -60,6 +63,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .healthExport: return Tj.Palette.ink
|
||||
case .archive: return Tj.Palette.ink
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .consultation: return Tj.Palette.ink2
|
||||
case .symptom: return Tj.Palette.amber
|
||||
case .reminder: return Tj.Palette.leaf
|
||||
case .medicationLibrary: return Tj.Palette.ink
|
||||
|
||||
@@ -3,36 +3,69 @@ import SwiftData
|
||||
import Foundation
|
||||
|
||||
enum TimelineKind: String, CaseIterable, Identifiable {
|
||||
case diary, symptom, indicator, medication, report
|
||||
case diary, symptom, consultation, indicator, medication, report
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .indicator: return String(appLoc: "指标")
|
||||
case .report: return String(appLoc: "报告")
|
||||
case .symptom: return String(appLoc: "症状")
|
||||
case .diary: return String(appLoc: "日记")
|
||||
case .medication: return String(appLoc: "用药")
|
||||
case .indicator: return String(appLoc: "指标")
|
||||
case .report: return String(appLoc: "报告")
|
||||
case .symptom: return String(appLoc: "症状")
|
||||
case .diary: return String(appLoc: "日记")
|
||||
case .consultation: return String(appLoc: "问诊")
|
||||
case .medication: return String(appLoc: "用药")
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .indicator: return "drop.fill"
|
||||
case .report: return "doc.fill"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
case .diary: return "pencil"
|
||||
case .medication: return "pills.fill"
|
||||
case .indicator: return "drop.fill"
|
||||
case .report: return "doc.fill"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
case .diary: return "pencil"
|
||||
case .consultation: return "stethoscope"
|
||||
case .medication: return "pills.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var accent: Color {
|
||||
switch self {
|
||||
case .indicator: return Tj.Palette.brick
|
||||
case .report: return Tj.Palette.ink2
|
||||
case .symptom: return Tj.Palette.amber
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .medication: return Tj.Palette.ink
|
||||
case .indicator: return Tj.Palette.brick
|
||||
case .report: return Tj.Palette.ink2
|
||||
case .symptom: return Tj.Palette.amber
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .consultation: return Tj.Palette.ink2
|
||||
case .medication: return Tj.Palette.ink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineKind {
|
||||
/// 记录页分类分组(2026-06-28):把扁平的一排标签按语义归三组,过滤条按组排序、组间加分隔,
|
||||
/// 让「一堆标签」读成「自述 / 检查 / 用药」三块。仅影响过滤条的排列与分隔——
|
||||
/// 过滤逻辑仍是单选某个 TimelineKind,不引入多选状态。
|
||||
enum Group: String, CaseIterable, Identifiable {
|
||||
case selfLog // 自述记录:日记、症状、问诊(本人写/说的)
|
||||
case clinical // 检查数据:指标、报告(测量来的客观数据)
|
||||
case medication // 用药
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// 组标题。单成员组(用药)在过滤条里不另外标题(标题与唯一标签重复),见 ArchiveListView。
|
||||
var caption: String {
|
||||
switch self {
|
||||
case .selfLog: return String(appLoc: "自述")
|
||||
case .clinical: return String(appLoc: "检查")
|
||||
case .medication: return String(appLoc: "用药")
|
||||
}
|
||||
}
|
||||
|
||||
var kinds: [TimelineKind] {
|
||||
switch self {
|
||||
case .selfLog: return [.diary, .symptom, .consultation]
|
||||
case .clinical: return [.indicator, .report]
|
||||
case .medication: return [.medication]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,17 +198,28 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 带「用药」tag 的日记(拍药盒入档)归到 .medication 分类,其余是普通文字日记。
|
||||
/// id 统一用 "diary-" 前缀:TimelineDetail.resolve 两个分类都反查 diaries。
|
||||
/// DiaryEntry 据 tag 归类:问诊录音 → .consultation,拍药盒/用药 → .medication,其余普通文字日记。
|
||||
/// id 统一用 "diary-" 前缀:TimelineDetail.resolve 这几个分类都反查 diaries。
|
||||
static func from(diary d: DiaryEntry) -> TimelineEntry {
|
||||
let isMed = d.isMedicationLog
|
||||
let kind: TimelineKind
|
||||
let subtitle: String
|
||||
if d.isConsultation {
|
||||
kind = .consultation
|
||||
subtitle = String(appLoc: "问诊记录")
|
||||
} else if d.isMedicationLog {
|
||||
kind = .medication
|
||||
subtitle = String(appLoc: "用药记录")
|
||||
} else {
|
||||
kind = .diary
|
||||
subtitle = String(appLoc: "文字日记")
|
||||
}
|
||||
return TimelineEntry(
|
||||
id: "diary-\(d.persistentModelID)",
|
||||
kind: isMed ? .medication : .diary,
|
||||
kind: kind,
|
||||
date: d.createdAt,
|
||||
title: d.content.firstLine(),
|
||||
subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"),
|
||||
trailing: nil,
|
||||
subtitle: subtitle,
|
||||
trailing: d.isConsultation && d.audioAsset != nil ? String(appLoc: "录音") : nil,
|
||||
trailingIsAlert: false,
|
||||
isOngoing: false
|
||||
)
|
||||
|
||||
@@ -22,8 +22,8 @@ enum TimelineDetail {
|
||||
case .report:
|
||||
return reports.first { "report-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.report)
|
||||
case .diary, .medication:
|
||||
// 用药记录本质是带「用药」tag 的 DiaryEntry,详情同日记。
|
||||
case .diary, .medication, .consultation:
|
||||
// 用药记录 / 问诊记录本质都是带特定 tag 的 DiaryEntry,详情统一反查 diaries。
|
||||
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.diary)
|
||||
case .symptom:
|
||||
@@ -201,7 +201,9 @@ struct TimelineEntryDetailView: View {
|
||||
case .indicator: return String(appLoc: "指标详情")
|
||||
case .bloodPressure: return String(appLoc: "血压详情")
|
||||
case .report: return String(appLoc: "报告详情")
|
||||
case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
|
||||
case .diary(let d):
|
||||
if d.isConsultation { return String(appLoc: "问诊详情") }
|
||||
return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
|
||||
case .symptom: return String(appLoc: "症状详情")
|
||||
}
|
||||
}
|
||||
@@ -377,7 +379,9 @@ struct TimelineEntryDetailView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func diaryBody(_ d: DiaryEntry) -> some View {
|
||||
if d.isMedicationLog {
|
||||
if d.isConsultation {
|
||||
consultationBody(d)
|
||||
} else if d.isMedicationLog {
|
||||
medicationBody(d)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -398,6 +402,42 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 问诊记录(录音回放 + 结构化小结)
|
||||
|
||||
/// 问诊记录(带「问诊」tag 的日记):顶部录音回放(若有),中间结构化问诊小结。
|
||||
/// 录音 + 文字都在本机(header 的 TjLockChip 已示「本地」);AI 整理稿附免责声明,不构成诊断/用药建议。
|
||||
private func consultationBody(_ d: DiaryEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let audio = d.audioAsset {
|
||||
ConsultationAudioPlayer(asset: audio)
|
||||
}
|
||||
card {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "stethoscope")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink2)
|
||||
Text(String(appLoc: "问诊小结"))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
divider
|
||||
Text(d.content)
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Text("内容由本机语音识别 + AI 整理,仅供个人回看,可能与原话有出入,不构成诊断或用药建议。")
|
||||
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用药使用记录(展示药名/剂量/时间 + 设置提醒)
|
||||
|
||||
/// 用药使用记录(带「用药」tag 的日记):展示「药名 [规格] · 剂量」+ 时间,下方「设置提醒」。
|
||||
|
||||
Reference in New Issue
Block a user