From b7e8ab33ec2f0af32938358edfad0dec540cee8b Mon Sep 17 00:00:00 2001 From: link2026 Date: Wed, 10 Jun 2026 06:13:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AF=AD=E9=9F=B3=E6=97=A5=E8=AE=B0):=20D?= =?UTF-8?q?iaryVoicePanel=20=E5=BD=95=E9=9F=B3/=E6=95=B4=E7=90=86=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- 康康/Features/Diary/DiaryVoicePanel.swift | 141 ++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 康康/Features/Diary/DiaryVoicePanel.swift diff --git a/康康/Features/Diary/DiaryVoicePanel.swift b/康康/Features/Diary/DiaryVoicePanel.swift new file mode 100644 index 0000000..1a84dd1 --- /dev/null +++ b/康康/Features/Diary/DiaryVoicePanel.swift @@ -0,0 +1,141 @@ +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() +}