```
docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档 补充了关于导出摘要出现虚构病例问题的详细分析和修复方案, 包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。 ```
This commit is contained in:
@@ -19,6 +19,7 @@ struct ArchiveListView: View {
|
||||
|
||||
@State private var filter: TimelineKind? = nil
|
||||
@State private var endingSymptom: Symptom?
|
||||
@State private var selectedEntry: TimelineEntry?
|
||||
@State private var showExportSheet = false
|
||||
@State private var showExportList = false
|
||||
|
||||
@@ -85,6 +86,11 @@ struct ArchiveListView: View {
|
||||
.sheet(item: $endingSymptom) { sym in
|
||||
SymptomEndSheet(symptom: sym)
|
||||
}
|
||||
.sheet(item: $selectedEntry) { entry in
|
||||
if let d = detail(for: entry) {
|
||||
TimelineEntryDetailView(detail: d)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showExportSheet) {
|
||||
HealthExportSheet()
|
||||
}
|
||||
@@ -94,6 +100,7 @@ struct ArchiveListView: View {
|
||||
private func rowView(for entry: TimelineEntry) -> some View {
|
||||
if entry.kind == .symptom, entry.isOngoing,
|
||||
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
|
||||
// 进行中症状:点 → 标记结束 sheet(沿用原交互)
|
||||
Button {
|
||||
endingSymptom = sym
|
||||
} label: {
|
||||
@@ -101,7 +108,42 @@ struct ArchiveListView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
TimelineRow(entry: entry)
|
||||
// 其余条目(报告/指标/日记/已结束症状):点 → 只读详情
|
||||
Button {
|
||||
if detail(for: entry) != nil { selectedEntry = entry }
|
||||
} label: {
|
||||
TimelineRow(entry: entry)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把时间线条目反查回源记录(id 形如 `<kind>-<persistentModelID>` / `bp-<sys>-<dia>`)。
|
||||
private func detail(for entry: TimelineEntry) -> TimelineDetail? {
|
||||
switch entry.kind {
|
||||
case .report:
|
||||
return reports.first { "report-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.report)
|
||||
case .diary:
|
||||
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.diary)
|
||||
case .symptom:
|
||||
return symptoms.first { "symptom-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.symptom)
|
||||
case .indicator:
|
||||
if let i = indicators.first(where: { "indicator-\($0.persistentModelID)" == entry.id }) {
|
||||
return .indicator(i)
|
||||
}
|
||||
// 合并血压条目:bp-<sysID>-<diaID>
|
||||
if entry.id.hasPrefix("bp-"),
|
||||
let sys = indicators.first(where: { entry.id.hasPrefix("bp-\($0.persistentModelID)-") }) {
|
||||
let dia = indicators.first {
|
||||
$0.seriesKey == "bp.diastolic" &&
|
||||
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
||||
}
|
||||
return .bloodPressure(sys: sys, dia: dia)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ struct DiaryQuickSheet: View {
|
||||
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
||||
@State private var coveredDims: Set<String> = []
|
||||
@State private var suggestTask: Task<Void, Never>?
|
||||
/// 当前正在「就地填空」的 question id;nil = 没有展开的填空面板。
|
||||
@State private var fillingId: UUID?
|
||||
/// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。
|
||||
@State private var fillValues: [String] = []
|
||||
/// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。
|
||||
@State private var exhaustedNote = false
|
||||
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
||||
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
||||
@State private var detent: PresentationDetent = .large
|
||||
@@ -76,6 +82,7 @@ struct DiaryQuickSheet: View {
|
||||
text: $content, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
.focused($contentFocused)
|
||||
.onChange(of: content) { _, _ in exhaustedNote = false }
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
@@ -177,6 +184,19 @@ struct DiaryQuickSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
if exhaustedNote {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
// 底部主操作按钮(状态机驱动)
|
||||
phaseFooter
|
||||
}
|
||||
@@ -318,6 +338,7 @@ struct DiaryQuickSheet: View {
|
||||
|
||||
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
|
||||
let adopted = question.adopted
|
||||
let filling = fillingId == question.id
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(index).")
|
||||
@@ -341,7 +362,7 @@ struct DiaryQuickSheet: View {
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().fill(Tj.Palette.leafSoft))
|
||||
} else {
|
||||
} else if !filling {
|
||||
Button { adopt(question) } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
@@ -357,7 +378,14 @@ struct DiaryQuickSheet: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
if !question.fill.isEmpty && !adopted {
|
||||
if filling {
|
||||
QuestionFillPanel(
|
||||
template: question.fill,
|
||||
values: $fillValues,
|
||||
onCommit: { assembled in commitAdoption(question, text: assembled) },
|
||||
onCancel: { closeFill() }
|
||||
)
|
||||
} else if !question.fill.isEmpty && !adopted {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Text("将追加:")
|
||||
.font(.system(size: 11))
|
||||
@@ -405,6 +433,7 @@ struct DiaryQuickSheet: View {
|
||||
detent = .large
|
||||
}
|
||||
}
|
||||
exhaustedNote = false
|
||||
phase = .loading
|
||||
suggestTask = Task { @MainActor in
|
||||
do {
|
||||
@@ -413,21 +442,34 @@ struct DiaryQuickSheet: View {
|
||||
coveredDimensions: covered
|
||||
)
|
||||
if Task.isCancelled { return }
|
||||
// 客户端字面兜底(防 LLM 不听话);跨轮去重主要靠 prompt 的维度排除。
|
||||
let existing = Set(questions.map { Self.normalize($0.q) })
|
||||
// 客户端硬去重(不依赖 1.7B 听话):
|
||||
// ① 维度已在往轮覆盖 → 丢;② 本轮内维度重复 → 丢;③ 文本与已有近似 → 丢。
|
||||
let coveredSnapshot = coveredDims
|
||||
var acceptedNorms = questions.map { Self.normalize($0.q) }
|
||||
var batchDims = Set<String>()
|
||||
let nextRound = currentRound + 1
|
||||
let fresh = result.questions
|
||||
.filter { !existing.contains(Self.normalize($0.q)) }
|
||||
.map { q -> DiaryAssistService.Question in
|
||||
var stamped = q
|
||||
stamped.round = nextRound
|
||||
return stamped
|
||||
}
|
||||
let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
|
||||
let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let norm = Self.normalize(q.q)
|
||||
if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
|
||||
if !dim.isEmpty, batchDims.contains(dim) { return nil }
|
||||
if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
|
||||
if !dim.isEmpty { batchDims.insert(dim) }
|
||||
acceptedNorms.append(norm)
|
||||
var stamped = q
|
||||
stamped.round = nextRound
|
||||
return stamped
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
questions.append(contentsOf: fresh)
|
||||
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
|
||||
if fresh.isEmpty {
|
||||
exhaustedNote = true // 这轮没问出任何新维度
|
||||
} else {
|
||||
questions.append(contentsOf: fresh)
|
||||
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
|
||||
currentRound = nextRound
|
||||
exhaustedNote = false
|
||||
}
|
||||
lastRate = result.decodeRate
|
||||
currentRound = nextRound
|
||||
phase = .ready
|
||||
}
|
||||
} catch is CancellationError {
|
||||
@@ -449,20 +491,59 @@ struct DiaryQuickSheet: View {
|
||||
.replacingOccurrences(of: "?", with: "?")
|
||||
}
|
||||
|
||||
/// 近似判重:归一化后相等,或字符集 Jaccard ≥ 0.8(抓「会/下」这类换一两字的重复)。
|
||||
private static func isSimilar(_ a: String, _ b: String) -> Bool {
|
||||
if a == b { return true }
|
||||
let sa = Set(a), sb = Set(b)
|
||||
guard !sa.isEmpty, !sb.isEmpty else { return false }
|
||||
let inter = sa.intersection(sb).count
|
||||
let union = sa.union(sb).count
|
||||
return union > 0 && Double(inter) / Double(union) >= 0.8
|
||||
}
|
||||
|
||||
private func cancelSuggestions() {
|
||||
suggestTask?.cancel()
|
||||
phase = hasQuestions ? .ready : .idle
|
||||
}
|
||||
|
||||
/// 把 question.fill 追加到 textfield 末尾,并把该 question 标记为 adopted。
|
||||
/// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。
|
||||
/// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
|
||||
private func adopt(_ question: DiaryAssistService.Question) {
|
||||
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
|
||||
// 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。
|
||||
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
|
||||
return
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
fillingId = question.id
|
||||
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
|
||||
}
|
||||
}
|
||||
|
||||
/// 关闭填空面板(取消)。
|
||||
private func closeFill() {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
fillingId = nil
|
||||
fillValues = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交采纳:把(填好的)整句追加到正文,标记 adopted,收起面板。
|
||||
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
|
||||
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
questions[idx].adopted = true
|
||||
}
|
||||
}
|
||||
let toAppend = question.fill.isEmpty ? question.q : question.fill
|
||||
appendToContent(text)
|
||||
fillingId = nil
|
||||
fillValues = []
|
||||
}
|
||||
|
||||
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
||||
private func appendToContent(_ text: String) {
|
||||
let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !toAppend.isEmpty else { return }
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
content = toAppend
|
||||
|
||||
235
康康/Features/Diary/QuestionFillPanel.swift
Normal file
235
康康/Features/Diary/QuestionFillPanel.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
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..<end]))) }
|
||||
}
|
||||
while i < chars.count {
|
||||
if chars[i] == "[",
|
||||
let close = (i + 1 ..< chars.count).first(where: { chars[$0] == "]" }) {
|
||||
flushLiteral(upTo: i)
|
||||
let inner = String(chars[(i + 1)..<close])
|
||||
segs.append(.slot(label: inner, options: options(from: inner)))
|
||||
i = close + 1
|
||||
literalStart = i
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
flushLiteral(upTo: chars.count)
|
||||
return segs
|
||||
}
|
||||
|
||||
/// 占位内 `/` 分隔、每段都短(≤5 字)、且 ≥2 段时,视为可点选的快填候选。
|
||||
private static func options(from inner: String) -> [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<String> {
|
||||
Binding(
|
||||
get: { i < values.count ? values[i] : "" },
|
||||
set: { if i < values.count { values[i] = $0 } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ struct HomeView: View {
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
TodayRemindersCard()
|
||||
|
||||
OngoingSymptomsCard()
|
||||
.padding(.bottom, 18)
|
||||
|
||||
|
||||
118
康康/Features/Home/TodayRemindersCard.swift
Normal file
118
康康/Features/Home/TodayRemindersCard.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Combine
|
||||
|
||||
/// 主页「今日提醒」卡:汇总今天会触发的自由提醒(CustomReminder)+ 指标提醒(MetricReminder),
|
||||
/// 按时间升序展示;已过点的行淡化(只表示「时间已过」,不代表已完成——本期不追踪打卡)。
|
||||
/// 今天没有任何提醒 → 整卡隐藏(返回 EmptyView,与「持续中症状」卡同款)。
|
||||
/// 卡内只读;点右上「全部 ›」打开提醒中心(RemindersListView)管理。
|
||||
struct TodayRemindersCard: View {
|
||||
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
|
||||
private var customReminders: [CustomReminder]
|
||||
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||
private var metricReminders: [MetricReminder]
|
||||
|
||||
@State private var showingCenter = false
|
||||
/// 每分钟自走一次,用于刷新「今天」判定与「已过点」淡化(与 OngoingSymptomsCard 同款)。
|
||||
@State private var tick: Date = .now
|
||||
private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
|
||||
|
||||
/// 今天会触发的提醒,自由提醒 + 指标提醒合并成统一行模型,按时间升序。
|
||||
private var items: [TodayItem] {
|
||||
let cal = Calendar.current
|
||||
var arr: [TodayItem] = []
|
||||
for r in customReminders where r.occurs(on: tick, calendar: cal) {
|
||||
arr.append(TodayItem(id: "c-\(r.id.uuidString)",
|
||||
hour: r.hour, minute: r.minute, title: r.title))
|
||||
}
|
||||
for r in metricReminders where r.occurs(on: tick, calendar: cal) {
|
||||
arr.append(TodayItem(id: "m-\(r.metricId)",
|
||||
hour: r.hour, minute: r.minute, title: r.displayName))
|
||||
}
|
||||
return arr.sorted { ($0.hour, $0.minute) < ($1.hour, $1.minute) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let rows = items
|
||||
if rows.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header(count: rows.count)
|
||||
VStack(spacing: 8) {
|
||||
ForEach(rows) { row($0) }
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 18)
|
||||
.onReceive(timer) { now in tick = now }
|
||||
.sheet(isPresented: $showingCenter) {
|
||||
// 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。
|
||||
NavigationStack { RemindersListView(presentedAsSheet: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func header(count: Int) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Tj.Palette.amber)
|
||||
.frame(width: 7, height: 7)
|
||||
Text("今日提醒")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(count) 项")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Button { showingCenter = true } label: {
|
||||
Text("全部 ›")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ item: TodayItem) -> some View {
|
||||
let isPast = item.isPast(now: tick)
|
||||
return HStack(spacing: 12) {
|
||||
Text(item.timeLabel)
|
||||
.font(.system(size: 14, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||
.frame(width: 46, alignment: .leading)
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
|
||||
radius: 2, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 「今日提醒」行的统一展示模型(自由提醒与指标提醒共用)。
|
||||
private struct TodayItem: Identifiable {
|
||||
let id: String
|
||||
let hour: Int
|
||||
let minute: Int
|
||||
let title: String
|
||||
|
||||
var timeLabel: String { String(format: "%02d:%02d", hour, minute) }
|
||||
|
||||
/// 该提醒的时分是否早于此刻(同一天内「已过点」)。
|
||||
func isPast(now: Date) -> Bool {
|
||||
let c = Calendar.current.dateComponents([.hour, .minute], from: now)
|
||||
let nowMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0)
|
||||
return hour * 60 + minute < nowMinutes
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,16 @@ struct CustomReminderEditSheet: View {
|
||||
@State private var title = ""
|
||||
@State private var note = ""
|
||||
@State private var pickedTime: Date = .now
|
||||
@State private var frequency: CustomReminder.Frequency = .daily
|
||||
@State private var weekdays: Set<Int> = Set(1...7)
|
||||
@State private var dayOfMonth = 1
|
||||
@State private var month = 1
|
||||
@State private var hydrated = false
|
||||
@State private var showAuthDeniedAlert = false
|
||||
|
||||
/// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。
|
||||
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
|
||||
|
||||
init(reminder: CustomReminder? = nil) {
|
||||
self.reminder = reminder
|
||||
}
|
||||
@@ -26,7 +32,11 @@ struct CustomReminderEditSheet: View {
|
||||
private var trimmedTitle: String {
|
||||
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
private var canSave: Bool { !trimmedTitle.isEmpty && !weekdays.isEmpty }
|
||||
private var canSave: Bool {
|
||||
guard !trimmedTitle.isEmpty else { return false }
|
||||
if frequency == .weekly { return !weekdays.isEmpty }
|
||||
return true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -41,14 +51,26 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
|
||||
displayedComponents: .hourAndMinute)
|
||||
Picker(String(appLoc: "重复"), selection: $frequency) {
|
||||
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
|
||||
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
|
||||
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
|
||||
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
frequencyDetail
|
||||
} header: {
|
||||
Text("重复")
|
||||
}
|
||||
|
||||
Section {
|
||||
weekdayRow
|
||||
timePresetRow
|
||||
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
|
||||
displayedComponents: .hourAndMinute)
|
||||
} header: {
|
||||
Text("重复")
|
||||
Text("时间")
|
||||
}
|
||||
|
||||
if isEditing {
|
||||
@@ -74,6 +96,11 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
}
|
||||
.onAppear(perform: hydrate)
|
||||
.onChange(of: month) { _, newMonth in
|
||||
// 切月份后,把超出该月最大天数的「日」收回(避免「2月31日」这种永不触发的组合)。
|
||||
let maxD = Self.daysInMonth(newMonth)
|
||||
if dayOfMonth > maxD { dayOfMonth = maxD }
|
||||
}
|
||||
.alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) {
|
||||
Button(String(appLoc: "好")) { dismiss() }
|
||||
} message: {
|
||||
@@ -82,6 +109,84 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 频率子控件
|
||||
|
||||
@ViewBuilder
|
||||
private var frequencyDetail: some View {
|
||||
switch frequency {
|
||||
case .daily:
|
||||
EmptyView()
|
||||
case .weekly:
|
||||
weekdayRow
|
||||
case .monthly:
|
||||
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
|
||||
ForEach(1...31, id: \.self) { d in
|
||||
Text(String(appLoc: "\(d)日")).tag(d)
|
||||
}
|
||||
}
|
||||
if dayOfMonth >= 29 { skipHint }
|
||||
case .yearly:
|
||||
Picker(String(appLoc: "月份"), selection: $month) {
|
||||
ForEach(1...12, id: \.self) { mo in
|
||||
Text(String(appLoc: "\(mo)月")).tag(mo)
|
||||
}
|
||||
}
|
||||
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
|
||||
ForEach(1...Self.daysInMonth(month), id: \.self) { d in
|
||||
Text(String(appLoc: "\(d)日")).tag(d)
|
||||
}
|
||||
}
|
||||
if month == 2 && dayOfMonth == 29 { skipHint } // 仅闰年的 2/29
|
||||
}
|
||||
}
|
||||
|
||||
private var skipHint: some View {
|
||||
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
/// 某月最大天数(2 月取 29,允许设闰年 2/29)。
|
||||
private static func daysInMonth(_ month: Int) -> Int {
|
||||
switch month {
|
||||
case 2: return 29
|
||||
case 4, 6, 9, 11: return 30
|
||||
default: return 31
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 时间快捷预设
|
||||
|
||||
private var timePresetRow: some View {
|
||||
let cal = Calendar.current
|
||||
let curH = cal.component(.hour, from: pickedTime)
|
||||
let curM = cal.component(.minute, from: pickedTime)
|
||||
return HStack(spacing: 8) {
|
||||
ForEach(Array(timePresets.enumerated()), id: \.offset) { _, preset in
|
||||
let on = curH == preset.h && curM == preset.m
|
||||
Button {
|
||||
pickedTime = cal.date(bySettingHour: preset.h, minute: preset.m,
|
||||
second: 0, of: pickedTime) ?? pickedTime
|
||||
} label: {
|
||||
Text(String(format: "%d:%02d", preset.h, preset.m))
|
||||
.font(.system(size: 13, weight: on ? .semibold : .regular))
|
||||
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 30)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// MARK: - 周几选择(与 RemindersListView 同款)
|
||||
|
||||
private var weekdayRow: some View {
|
||||
@@ -124,7 +229,10 @@ struct CustomReminderEditSheet: View {
|
||||
if let r = reminder {
|
||||
title = r.title
|
||||
note = r.note
|
||||
frequency = r.frequency
|
||||
weekdays = Set(r.weekdays)
|
||||
dayOfMonth = r.dayOfMonth
|
||||
month = r.month
|
||||
pickedTime = Calendar.current.date(
|
||||
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
||||
) ?? .now
|
||||
@@ -145,6 +253,9 @@ struct CustomReminderEditSheet: View {
|
||||
r.hour = hour
|
||||
r.minute = minute
|
||||
r.weekdays = sortedDays
|
||||
r.frequency = frequency
|
||||
r.dayOfMonth = dayOfMonth
|
||||
r.month = month
|
||||
r.updatedAt = .now
|
||||
target = r
|
||||
} else {
|
||||
@@ -153,7 +264,10 @@ struct CustomReminderEditSheet: View {
|
||||
note: note.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
hour: hour,
|
||||
minute: minute,
|
||||
weekdays: sortedDays
|
||||
weekdays: sortedDays,
|
||||
frequency: frequency,
|
||||
dayOfMonth: dayOfMonth,
|
||||
month: month
|
||||
)
|
||||
ctx.insert(new)
|
||||
target = new
|
||||
|
||||
295
康康/Features/Timeline/TimelineEntryDetailView.swift
Normal file
295
康康/Features/Timeline/TimelineEntryDetailView.swift
Normal file
@@ -0,0 +1,295 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 时间线条目反查到的源记录,驱动只读详情 sheet。
|
||||
/// 注:报告详情这里是 W2 轻量只读版;W4 的 C2 `ReportDetailView`(三 Tab + 对比上次)另建,
|
||||
/// 届时把时间线报告行改路由到 C2 即可,本类型不与之冲突。
|
||||
enum TimelineDetail {
|
||||
case indicator(Indicator)
|
||||
case bloodPressure(sys: Indicator, dia: Indicator?)
|
||||
case report(Report)
|
||||
case diary(DiaryEntry)
|
||||
case symptom(Symptom)
|
||||
}
|
||||
|
||||
/// 时间线条目的只读详情:展示该记录的完整字段。各类型一屏看完,不可编辑。
|
||||
struct TimelineEntryDetailView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let detail: TimelineDetail
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
bodyContent
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
Text(titleText)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(Tj.Palette.sand)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
switch detail {
|
||||
case .indicator: return String(appLoc: "指标详情")
|
||||
case .bloodPressure: return String(appLoc: "血压详情")
|
||||
case .report: return String(appLoc: "报告详情")
|
||||
case .diary: return String(appLoc: "日记详情")
|
||||
case .symptom: return String(appLoc: "症状详情")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bodyContent: some View {
|
||||
switch detail {
|
||||
case .indicator(let i): indicatorBody(i)
|
||||
case .bloodPressure(let s, let d): bpBody(sys: s, dia: d)
|
||||
case .report(let r): reportBody(r)
|
||||
case .diary(let d): diaryBody(d)
|
||||
case .symptom(let s): symptomBody(s)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 指标
|
||||
|
||||
private func indicatorBody(_ i: Indicator) -> some View {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(i.status)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(i.value)
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
if !i.unit.isEmpty {
|
||||
Text(i.unit).font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
divider
|
||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? String(appLoc: "异常项快拍"))
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 血压(合并条目)
|
||||
|
||||
private func bpBody(sys: Indicator, dia: Indicator?) -> some View {
|
||||
let combined: IndicatorStatus = sys.status != .normal
|
||||
? sys.status
|
||||
: (dia?.status ?? .normal)
|
||||
return card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(combined)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
divider
|
||||
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 报告
|
||||
|
||||
private func reportBody(_ r: Report) -> some View {
|
||||
let sorted = r.indicators.sorted {
|
||||
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
|
||||
}
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(r.title).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
HStack(spacing: 8) {
|
||||
TjBadge(text: r.type.label, style: .neutral)
|
||||
Text(Self.dateText(r.reportDate))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
if !r.assets.isEmpty {
|
||||
Text(String(appLoc: "原图\(r.assets.count)张"))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
if let inst = r.institution, !inst.isEmpty {
|
||||
field(String(appLoc: "机构"), inst)
|
||||
}
|
||||
}
|
||||
|
||||
if let sum = r.summary, !sum.isEmpty {
|
||||
card {
|
||||
Text(String(appLoc: "摘要"))
|
||||
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if !r.indicators.isEmpty {
|
||||
card {
|
||||
Text(String(appLoc: "指标"))
|
||||
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
ForEach(sorted) { ind in
|
||||
HStack {
|
||||
Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
|
||||
Spacer(minLength: 8)
|
||||
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||
statusChip(ind.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let note = r.note, !note.isEmpty {
|
||||
card { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 日记
|
||||
|
||||
private func diaryBody(_ d: DiaryEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(d.content)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !d.tags.isEmpty {
|
||||
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 症状
|
||||
|
||||
private func symptomBody(_ s: Symptom) -> some View {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(s.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
if s.isOngoing {
|
||||
Text(String(appLoc: "进行中"))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
|
||||
}
|
||||
}
|
||||
divider
|
||||
field(String(appLoc: "程度"), "\(s.severity) / 5")
|
||||
field(String(appLoc: "开始"), Self.dateTimeText(s.startedAt))
|
||||
field(String(appLoc: "结束"), s.endedAt.map(Self.dateTimeText) ?? String(appLoc: "进行中"))
|
||||
field(String(appLoc: "持续"), formatDuration(s.duration))
|
||||
if let note = s.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
if !s.tags.isEmpty {
|
||||
field(String(appLoc: "标签"), s.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 复用件
|
||||
|
||||
@ViewBuilder
|
||||
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) { content() }
|
||||
.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 func field(_ label: String, _ value: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
|
||||
private func statusChip(_ s: IndicatorStatus) -> some View {
|
||||
let text: String
|
||||
let color: Color
|
||||
let arrow: String
|
||||
switch s {
|
||||
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = "↑"
|
||||
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = "↓"
|
||||
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
|
||||
}
|
||||
return HStack(spacing: 3) {
|
||||
if !arrow.isEmpty { Text(arrow).font(.system(size: 11, weight: .bold)) }
|
||||
Text(text).font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(color.opacity(0.14)))
|
||||
}
|
||||
|
||||
private nonisolated static func dateTimeText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day().hour().minute())
|
||||
}
|
||||
|
||||
private nonisolated static func dateText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user