feat(Trends): AI 趋势解读上线 — 数据指纹缓存,秒开不重算
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,7 @@ struct TrendDetailView: View {
|
||||
}
|
||||
chartCard
|
||||
statsCard
|
||||
aiPlaceholder
|
||||
TrendInsightCard(bucket: bucket)
|
||||
pointsList
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -318,27 +318,6 @@ struct TrendDetailView: View {
|
||||
return ("\(arrow) \(fmt(abs(d)))\(pctStr)", color)
|
||||
}
|
||||
|
||||
// MARK: AI 解读占位
|
||||
|
||||
private var aiPlaceholder: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("AI 趋势解读即将上线")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2.opacity(0.6))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: 数据点列表
|
||||
|
||||
/// 跨线按天合并:每天一行,展示该天各线的值。倒序。
|
||||
@@ -423,6 +402,80 @@ struct TrendDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AI 趋势解读卡
|
||||
|
||||
/// 进入页面先查指纹缓存:命中秒显;未命中本地现算(经 TrendInsightService,§3.1)。
|
||||
private struct TrendInsightCard: View {
|
||||
let bucket: SeriesBucket
|
||||
@State private var text: String?
|
||||
@State private var running = false
|
||||
@State private var failedMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
Text("AI 解读")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
}
|
||||
if let text {
|
||||
Text(text)
|
||||
.font(.tjScaled( 13))
|
||||
.lineSpacing(3)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
AIDisclaimerFooter()
|
||||
} else if running {
|
||||
Text("本地 AI 解读中…")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
AIFlowBar()
|
||||
} else if let failedMessage {
|
||||
HStack {
|
||||
Text(failedMessage)
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Button("重试") { Task { await load(force: true) } }
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
)
|
||||
.task(id: bucket.id) { await load(force: false) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func load(force: Bool) async {
|
||||
if !force, let cached = TrendInsightService.shared.cachedText(for: bucket) {
|
||||
text = cached
|
||||
return
|
||||
}
|
||||
running = true
|
||||
failedMessage = nil
|
||||
do {
|
||||
text = try await TrendInsightService.shared.generate(for: bucket)
|
||||
} catch {
|
||||
failedMessage = String(appLoc: "AI 解读暂不可用(模型未就绪或繁忙)")
|
||||
}
|
||||
running = false
|
||||
}
|
||||
}
|
||||
|
||||
enum TrendRange: String, CaseIterable, Identifiable {
|
||||
case all, year, sixMonths, threeMonths
|
||||
var id: String { rawValue }
|
||||
|
||||
Reference in New Issue
Block a user