Files
kangkang/康康/Features/Capture/CaptureReviewForm.swift
link2026 d2c77d5c51 feat: 国际化(i18n) en/ja/ko + App 内语言切换
主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
  + pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
  Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存

注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:28:24 +08:00

265 lines
9.7 KiB
Swift

import SwiftUI
/// VL
/// title / type / reportDate / institution / summary / indicator;
/// indicator,
/// SwiftData + Vault assets
struct CaptureReviewForm: View {
@State var parsed: ParsedReport
let assets: [FileVault.SavedAsset]
let warning: String?
let onSave: (ParsedReport) -> Void
let onCancel: () -> Void
/// assets () nil,banner
var onReanalyze: (() -> Void)? = nil
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if let warning {
warningBanner(warning)
}
if !assets.isEmpty {
pageThumbnails
}
metaSection
indicatorSection
Spacer(minLength: 8)
actions
}
.padding(.horizontal, 18)
.padding(.bottom, 24)
}
}
// MARK: - warning
private func warningBanner(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.amber)
VStack(alignment: .leading, spacing: 8) {
Text(text)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
if let onReanalyze {
Button {
onReanalyze()
} label: {
Label("重新识别", systemImage: "arrow.clockwise")
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
}
Spacer(minLength: 0)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
// MARK: -
private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 84, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
}
}
}
}
// MARK: - meta(title / type / date / institution / summary)
private var metaSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionLabel(String(appLoc: "基本信息"))
VStack(spacing: 10) {
labeledField(String(appLoc: "标题")) {
TextField("如:春季年度体检", text: $parsed.title)
.textFieldStyle(.plain)
}
labeledField(String(appLoc: "类型")) {
Picker("", selection: $parsed.typeRaw) {
ForEach(ReportType.allCases, id: \.rawValue) { t in
Text(t.label).tag(t.rawValue)
}
}
.pickerStyle(.segmented)
}
labeledField(String(appLoc: "报告日期")) {
DatePicker("", selection: $parsed.reportDate,
in: ...Date.now,
displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
.environment(\.locale, Locale.current)
}
labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution)
}
labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
}
.padding(12)
.background(fieldBg)
.overlay(fieldBorder)
}
}
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
content()
}
}
// MARK: - indicators
private var indicatorSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
Spacer()
Button {
parsed.indicators.append(
.init(name: "", value: "", unit: "", range: "", status: .normal)
)
} label: {
Label("加一项", systemImage: "plus.circle")
.font(.system(size: 12, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
if parsed.indicators.isEmpty {
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.padding(.vertical, 8)
} else {
VStack(spacing: 10) {
ForEach(parsed.indicators.indices, id: \.self) { idx in
indicatorRow(idx)
}
}
}
}
}
private func indicatorRow(_ idx: Int) -> some View {
let binding = $parsed.indicators[idx]
return VStack(spacing: 8) {
HStack(spacing: 8) {
TextField("指标名", text: binding.name)
.font(.system(size: 14, weight: .medium))
Button(role: .destructive) {
parsed.indicators.remove(at: idx)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
HStack(spacing: 8) {
TextField("数值", text: binding.value)
.keyboardType(.decimalPad)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.frame(maxWidth: 90)
TextField("单位", text: binding.unit)
.frame(maxWidth: 80)
.autocorrectionDisabled()
TextField("参考", text: binding.range)
.autocorrectionDisabled()
}
Picker("", selection: binding.status) {
Text("正常").tag(IndicatorStatus.normal)
Text("偏高 ↑").tag(IndicatorStatus.high)
Text("偏低 ↓").tag(IndicatorStatus.low)
}
.pickerStyle(.segmented)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(statusColor(binding.status.wrappedValue).opacity(0.4),
lineWidth: 1)
)
}
private func statusColor(_ s: IndicatorStatus) -> Color {
switch s {
case .normal: return Tj.Palette.leaf
case .high: return Tj.Palette.brick
case .low: return Tj.Palette.amber
}
}
// MARK: - actions
private var actions: some View {
VStack(spacing: 10) {
Button {
onSave(parsed)
} label: {
Text("保存到记录")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Button(action: onCancel) {
Text("取消(图片不保留)")
.frame(maxWidth: .infinity)
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
// MARK: - helpers
private func sectionLabel(_ t: String) -> some View {
Text(t)
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
private var fieldBg: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
}
private var fieldBorder: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
}
}