import SwiftUI /// 「健康记录」语音输入面板(spec 2026-06-10-voice-diary)。 /// 两种模式:recording(实时字幕 + 计时 + 停止)/ organizing(AI 整理中,可取消)。 /// 纯展示:状态由 DiaryQuickSheet 持有并传入。 struct DiaryVoicePanel: View { enum Mode: Equatable { case recording(elapsedSeconds: Int) case organizing } let mode: Mode /// recording 时为实时字幕;organizing 时为已定稿的转写稿(置灰展示)。 let transcript: String let onStop: () -> Void let onCancelOrganize: () -> Void /// 录音上限 3 分钟(超时由 DiaryQuickSheet 的看门狗触发 onStop)。 static let maxRecordingSeconds = 180 var body: some View { VStack(alignment: .leading, spacing: 10) { header transcriptArea if case .recording = mode { stopButton } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) .overlay(alignment: .bottom) { if mode == .organizing { AIFlowBar().padding(.horizontal, 1) } } .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) } @ViewBuilder private var header: some View { switch mode { case .recording(let elapsed): HStack(spacing: 8) { Image(systemName: "waveform") .font(.tjScaled(12, 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.format(elapsed)) .font(.tjScaled(12, design: .monospaced)) .foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30 ? Tj.Palette.brick : Tj.Palette.text3) } case .organizing: HStack(spacing: 8) { Image(systemName: "sparkles") .font(.tjScaled(12, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) .symbolEffect(.pulse, options: .repeating) Text("AI 整理中 · 本地推理") .font(.tjScaled(13, weight: .medium)) .foregroundStyle(Tj.Palette.text2) Spacer(minLength: 0) Button("取消") { onCancelOrganize() } .font(.tjScaled(12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } } } @ViewBuilder private var transcriptArea: some View { ScrollViewReader { proxy in ScrollView(showsIndicators: false) { Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript) .font(.tjScaled(14)) .foregroundStyle(transcriptColor) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) Color.clear.frame(height: 1).id("tail") } .frame(maxHeight: 120) .onChange(of: transcript) { _, _ in proxy.scrollTo("tail", anchor: .bottom) } } } private var transcriptColor: Color { if transcript.isEmpty { return Tj.Palette.text3 } return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text } private var stopButton: some View { Button(action: onStop) { HStack(spacing: 8) { Image(systemName: "stop.circle.fill") Text("说完了,整理成日记") } .font(.tjScaled(14, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.brick) ) .contentShape(Rectangle()) } .buttonStyle(.plain) } private static func format(_ seconds: Int) -> String { String(format: "%d:%02d", seconds / 60, seconds % 60) } } #Preview("录音中") { DiaryVoicePanel(mode: .recording(elapsedSeconds: 23), transcript: "今天早上起来有点头晕,量了血压一百四九十", onStop: {}, onCancelOrganize: {}) .padding() } #Preview("整理中") { DiaryVoicePanel(mode: .organizing, transcript: "今天早上起来有点头晕,量了血压一百四九十", onStop: {}, onCancelOrganize: {}) .padding() }