import SwiftUI /// AI 补充句模板(如「症状从 [时间] 开始,」)的一个片段:字面文本或待填占位槽。 enum FillSegment: Equatable { case literal(String) /// `label` 为方括号内原文(如 "时间" / "活动/休息"); /// `options` 为可一键填充的短词候选(`/` 分隔且都短时才有,否则空)。 case slot(label: String, options: [String]) } /// 把 `fill` 模板解析成有序片段、组装回填好的句子。纯值逻辑,便于复用与单测。 enum DiaryFillTemplate { /// 解析模板为有序片段。无方括号时返回单个 `.literal`。 static func parse(_ template: String) -> [FillSegment] { let chars = Array(template) var segs: [FillSegment] = [] var i = 0 var literalStart = 0 func flushLiteral(upTo end: Int) { if end > literalStart { segs.append(.literal(String(chars[literalStart.. [String] { let tokens = inner.split(separator: "/") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } guard tokens.count >= 2, tokens.allSatisfy({ $0.count <= 5 }) else { return [] } return tokens } /// 模板里的占位槽数量。 static func slotCount(_ template: String) -> Int { parse(template).reduce(0) { acc, seg in if case .slot = seg { return acc + 1 } return acc } } /// 用 `values` 填充各槽组装成句:已填用输入值,留空回退为方括号内原文(去方括号,读起来仍自然)。 static func assemble(_ template: String, values: [String]) -> String { var out = "" var idx = 0 for seg in parse(template) { switch seg { case .literal(let t): out += t case .slot(let label, _): let v = idx < values.count ? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : "" out += v.isEmpty ? label : v idx += 1 } } return out } } /// 「采纳即就地填空」面板:每个 `[占位]` 一个输入框 + 快填 chip,顶部实时预览整句, /// 底部「加入记录 / 取消」。确认时回传**填好的、无方括号**的整句。 struct QuestionFillPanel: View { let template: String @Binding var values: [String] let onCommit: (String) -> Void let onCancel: () -> Void private var segments: [FillSegment] { DiaryFillTemplate.parse(template) } /// 抽出占位槽 + 其在 values 里的下标。 private var slots: [(index: Int, label: String, options: [String])] { var result: [(Int, String, [String])] = [] var i = 0 for seg in segments { if case let .slot(label, options) = seg { result.append((i, label, options)) i += 1 } } return result } var body: some View { VStack(alignment: .leading, spacing: 10) { // 实时预览:已填值高亮,未填槽浅色下划线提示。 previewText .font(.system(size: 13)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.sand2) ) ForEach(slots, id: \.index) { slot in slotEditor(index: slot.index, label: slot.label, options: slot.options) } HStack(spacing: 8) { Button(action: onCancel) { Text("取消") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) .frame(maxWidth: .infinity) .padding(.vertical, 9) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) // 背景仅描边、内部透明:.plain 按钮的命中区会只剩文字本身, // 中间透明区点不到。补 contentShape 让整框可点。 .contentShape(Rectangle()) } .buttonStyle(.plain) Button { onCommit(DiaryFillTemplate.assemble(template, values: values)) } label: { HStack(spacing: 5) { Image(systemName: "text.append") .font(.system(size: 12, weight: .semibold)) Text("加入记录") .font(.system(size: 13, weight: .semibold)) } .foregroundStyle(Tj.Palette.paper) .frame(maxWidth: .infinity) .padding(.vertical, 9) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.ink) ) } .buttonStyle(.plain) } } .padding(.leading, 22) .padding(.top, 2) } // MARK: - 子部件 /// 预览整句:literal 用正文色,已填值用 brick 加粗,未填槽用浅色下划线。 private var previewText: Text { var result = Text("") var idx = 0 for seg in segments { switch seg { case .literal(let t): result = result + Text(t).foregroundStyle(Tj.Palette.text) case .slot(let label, _): let v = idx < values.count ? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : "" if v.isEmpty { result = result + Text(label).foregroundStyle(Tj.Palette.text3).underline() } else { result = result + Text(v).foregroundStyle(Tj.Palette.brick).fontWeight(.semibold) } idx += 1 } } return result } private func slotEditor(index: Int, label: String, options: [String]) -> some View { VStack(alignment: .leading, spacing: 6) { Text(label) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) if !options.isEmpty { HStack(spacing: 6) { ForEach(options, id: \.self) { opt in let picked = bindingValue(index) == opt Button { values[index] = opt } label: { Text(opt) .font(.system(size: 12, weight: picked ? .semibold : .regular)) .foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text) .padding(.horizontal, 10) .padding(.vertical, 5) .background( Capsule().fill(picked ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( Capsule().strokeBorder(Tj.Palette.line, lineWidth: picked ? 0 : 1) ) } .buttonStyle(.plain) } Spacer(minLength: 0) } } TextField(String(appLoc: "填写\(label)"), text: binding(index)) .font(.system(size: 13)) .padding(.horizontal, 12) .padding(.vertical, 9) .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) ) } } private func bindingValue(_ i: Int) -> String { i < values.count ? values[i] : "" } private func binding(_ i: Int) -> Binding { Binding( get: { i < values.count ? values[i] : "" }, set: { if i < values.count { values[i] = $0 } } ) } }