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>
This commit is contained in:
@@ -10,6 +10,8 @@ struct CaptureReviewForm: View {
|
||||
let warning: String?
|
||||
let onSave: (ParsedReport) -> Void
|
||||
let onCancel: () -> Void
|
||||
/// 「重新识别」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
||||
var onReanalyze: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -36,10 +38,22 @@ struct CaptureReviewForm: View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
Text(text)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
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)
|
||||
@@ -53,7 +67,7 @@ struct CaptureReviewForm: View {
|
||||
|
||||
private var pageThumbnails: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel("已保存 \(assets.count) 页(端侧加密)")
|
||||
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
||||
@@ -78,13 +92,13 @@ struct CaptureReviewForm: View {
|
||||
|
||||
private var metaSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionLabel("基本信息")
|
||||
sectionLabel(String(appLoc: "基本信息"))
|
||||
VStack(spacing: 10) {
|
||||
labeledField("标题") {
|
||||
labeledField(String(appLoc: "标题")) {
|
||||
TextField("如:春季年度体检", text: $parsed.title)
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
labeledField("类型") {
|
||||
labeledField(String(appLoc: "类型")) {
|
||||
Picker("", selection: $parsed.typeRaw) {
|
||||
ForEach(ReportType.allCases, id: \.rawValue) { t in
|
||||
Text(t.label).tag(t.rawValue)
|
||||
@@ -92,18 +106,18 @@ struct CaptureReviewForm: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
labeledField("报告日期") {
|
||||
labeledField(String(appLoc: "报告日期")) {
|
||||
DatePicker("", selection: $parsed.reportDate,
|
||||
in: ...Date.now,
|
||||
displayedComponents: .date)
|
||||
.datePickerStyle(.compact)
|
||||
.labelsHidden()
|
||||
.environment(\.locale, Locale(identifier: "zh_CN"))
|
||||
.environment(\.locale, Locale.current)
|
||||
}
|
||||
labeledField("机构(可选)") {
|
||||
labeledField(String(appLoc: "机构(可选)")) {
|
||||
TextField("如:协和医院", text: $parsed.institution)
|
||||
}
|
||||
labeledField("摘要(可选)") {
|
||||
labeledField(String(appLoc: "摘要(可选)")) {
|
||||
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
}
|
||||
@@ -128,7 +142,7 @@ struct CaptureReviewForm: View {
|
||||
private var indicatorSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
sectionLabel("指标(\(parsed.indicators.count) 项)")
|
||||
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
|
||||
Spacer()
|
||||
Button {
|
||||
parsed.indicators.append(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本)
|
||||
/// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。
|
||||
@@ -16,11 +17,17 @@ struct UnifiedCaptureFlow: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
let onClose: () -> Void
|
||||
|
||||
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
|
||||
@State private var phase: Phase = .idle
|
||||
@State private var analyzeTask: Task<Void, Never>? = nil
|
||||
@State private var showTip: Bool = false
|
||||
|
||||
/// VL 单次推理超时(防止卡死);超时后 cancel 子任务,UI 走手动录入回退。
|
||||
private let analyzeTimeoutSeconds: Int = 30
|
||||
|
||||
enum Phase {
|
||||
case idle
|
||||
case analyzing(images: [UIImage])
|
||||
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
|
||||
case editing(parsed: ParsedReport,
|
||||
assets: [FileVault.SavedAsset],
|
||||
warning: String?)
|
||||
@@ -32,20 +39,30 @@ struct UnifiedCaptureFlow: View {
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("取消") { onClose() }
|
||||
Button("取消") { cancelAll() }
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
.navigationTitle(phaseTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.onAppear {
|
||||
if !hasSeenCaptureTip { showTip = true }
|
||||
}
|
||||
.sheet(isPresented: $showTip) {
|
||||
CaptureTipSheet(onDismiss: {
|
||||
hasSeenCaptureTip = true
|
||||
showTip = false
|
||||
})
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
private var phaseTitle: String {
|
||||
switch phase {
|
||||
case .idle: return "拍摄报告"
|
||||
case .analyzing: return "本地识别中…"
|
||||
case .editing: return "核对识别结果"
|
||||
case .idle: return String(appLoc: "拍摄报告")
|
||||
case .analyzing: return String(appLoc: "本地识别中…")
|
||||
case .editing: return String(appLoc: "核对识别结果")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,21 +71,57 @@ struct UnifiedCaptureFlow: View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
captureEntry
|
||||
case .analyzing(let images):
|
||||
AnalyzingView(images: images)
|
||||
case .analyzing(let images, _):
|
||||
AnalyzingView(
|
||||
images: images,
|
||||
timeoutSeconds: analyzeTimeoutSeconds,
|
||||
onCancel: {
|
||||
analyzeTask?.cancel()
|
||||
analyzeTask = nil
|
||||
phase = .idle
|
||||
}
|
||||
)
|
||||
case .editing(let parsed, let assets, let warning):
|
||||
CaptureReviewForm(
|
||||
parsed: parsed,
|
||||
assets: assets,
|
||||
warning: warning,
|
||||
onSave: { final in saveAll(parsed: final, assets: assets) },
|
||||
onCancel: onClose
|
||||
onCancel: cancelAll,
|
||||
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 取消统一入口
|
||||
|
||||
/// 取消推理 + 清理未保存到 SwiftData 的 Vault 孤儿图片,再关闭 sheet。
|
||||
/// 工具栏「取消」与编辑表单底部「取消(图片不保留)」都走这里,
|
||||
/// 保证「图片不保留」的隐私承诺(§6)真的成立,且 Vault 不被孤儿图片堆爆。
|
||||
/// 仅清理 .analyzing/.editing 阶段的 assets;.idle 时还没写图,无需清理。
|
||||
private func cancelAll() {
|
||||
analyzeTask?.cancel()
|
||||
analyzeTask = nil
|
||||
switch phase {
|
||||
case .idle:
|
||||
break
|
||||
case .analyzing(_, let maybeAssets):
|
||||
if let assets = maybeAssets { removeOrphans(assets) }
|
||||
case .editing(_, let assets, _):
|
||||
removeOrphans(assets)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
private func removeOrphans(_ assets: [FileVault.SavedAsset]) {
|
||||
for a in assets {
|
||||
try? FileVault.shared.remove(relativePath: a.relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 入口:相机 / 相册
|
||||
|
||||
@ViewBuilder
|
||||
private var captureEntry: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
@@ -95,54 +148,124 @@ struct UnifiedCaptureFlow: View {
|
||||
|
||||
private func startAnalyze(images: [UIImage]) {
|
||||
guard !images.isEmpty else { onClose(); return }
|
||||
phase = .analyzing(images: images)
|
||||
Task {
|
||||
do {
|
||||
let result = try await CaptureService.shared.analyze(images: images)
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: result.parsed,
|
||||
assets: result.assets,
|
||||
warning: result.parsed.isEmpty
|
||||
? "识别没有读出指标,请手动补充"
|
||||
: nil
|
||||
)
|
||||
}
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
// 解析失败:仍然展示编辑表单,只是 indicators 为空,assets 已保存
|
||||
await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)")
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
await fallbackToManual(images: images, msg: "推理失败:\(msg)")
|
||||
} catch let CaptureError.modelNotReady {
|
||||
await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入")
|
||||
} catch CaptureError.writeAssetFailed {
|
||||
analyzeTask?.cancel()
|
||||
phase = .analyzing(images: images, assets: nil)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
analyzeTask = Task {
|
||||
// Step 1: 先把图写进 Vault。
|
||||
// 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时,
|
||||
// assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。
|
||||
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
|
||||
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
||||
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
||||
if Task.isCancelled {
|
||||
for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) }
|
||||
return
|
||||
}
|
||||
guard !assets.isEmpty else {
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: .empty(),
|
||||
assets: [],
|
||||
warning: "图片保存失败,手动录入并保留文本"
|
||||
warning: String(appLoc: "图片保存失败,手动录入并保留文本")
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 把 assets 暴露给 phase,使工具栏「取消」也能找到孤儿清理。
|
||||
await MainActor.run {
|
||||
if case .analyzing(let imgs, _) = phase {
|
||||
phase = .analyzing(images: imgs, assets: assets)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: VL 推理(timeout 哨兵到点 cancel 父任务,VLSession 在下一个 token break)。
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
analyzeTask?.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||
if Task.isCancelled {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: parsed,
|
||||
assets: assets,
|
||||
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
|
||||
)
|
||||
}
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
await editingFallback(assets: assets,
|
||||
msg: Task.isCancelled
|
||||
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
|
||||
: String(appLoc: "推理失败:\(msg)"))
|
||||
} catch CaptureError.modelNotReady {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
|
||||
} catch {
|
||||
await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)")
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fallbackToManual(images: [UIImage], msg: String) async {
|
||||
// 即便 VL 失败,图片应当已经写入了 Vault(在 CaptureService.analyze 第 1 步)。
|
||||
// 但若是 writeAsset 之前的失败(modelNotReady / inferenceFailed),
|
||||
// 这里再补一次写,保证图不丢。
|
||||
var assets: [FileVault.SavedAsset] = []
|
||||
for img in images {
|
||||
if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) }
|
||||
/// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。
|
||||
private func reanalyze(assets: [FileVault.SavedAsset]) {
|
||||
analyzeTask?.cancel()
|
||||
// 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可
|
||||
let thumbnails: [UIImage] = assets.compactMap {
|
||||
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
|
||||
}
|
||||
phase = .analyzing(images: thumbnails, assets: assets)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
analyzeTask = Task {
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
analyzeTask?.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||
if Task.isCancelled {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: parsed,
|
||||
assets: assets,
|
||||
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
|
||||
)
|
||||
}
|
||||
} catch CaptureError.modelNotReady {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
await editingFallback(assets: assets,
|
||||
msg: Task.isCancelled
|
||||
? String(appLoc: "识别超时(>\(timeout)s)")
|
||||
: String(appLoc: "推理失败:\(msg)"))
|
||||
} catch {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// reanalyze 失败时回到 editing,保留 assets 但清空 parsed。
|
||||
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: .empty(),
|
||||
assets: assets,
|
||||
warning: msg
|
||||
)
|
||||
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +274,7 @@ struct UnifiedCaptureFlow: View {
|
||||
private func saveAll(parsed final: ParsedReport,
|
||||
assets: [FileVault.SavedAsset]) {
|
||||
let report = Report(
|
||||
title: final.title.isEmpty ? "拍摄识别" : final.title,
|
||||
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
|
||||
type: ReportType(rawValue: final.typeRaw) ?? .other,
|
||||
reportDate: final.reportDate,
|
||||
institution: final.institution.isEmpty ? nil : final.institution,
|
||||
@@ -190,6 +313,11 @@ struct UnifiedCaptureFlow: View {
|
||||
|
||||
private struct AnalyzingView: View {
|
||||
let images: [UIImage]
|
||||
let timeoutSeconds: Int
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var elapsed: Int = 0
|
||||
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
@@ -216,13 +344,72 @@ private struct AnalyzingView: View {
|
||||
Text("本地识别中")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(images.count) 页 · 100% 本地推理")
|
||||
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if elapsed >= timeoutSeconds - 5 {
|
||||
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onReceive(tick) { _ in elapsed += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 一次性使用提示
|
||||
|
||||
private struct CaptureTipSheet: View {
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "doc.viewfinder")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
Text("拍报告的小贴士")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
tip(String(appLoc: "纸张铺平,避免反光、阴影"))
|
||||
tip(String(appLoc: "整页入框,避免裁切到指标"))
|
||||
tip(String(appLoc: "多页报告可连拍,系统自动透视校正"))
|
||||
tip(String(appLoc: "识别全程在本地,图片不会上传"))
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Text("我知道了,开始拍")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func tip(_ text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
.padding(.top, 2)
|
||||
Text(text)
|
||||
.font(.tjSerifBody())
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user