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:
link2026
2026-05-30 10:28:24 +08:00
parent 910ca99f21
commit d2c77d5c51
84 changed files with 15643 additions and 699 deletions

View File

@@ -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(

View File

@@ -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 dismisscancelAll
// 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()
}
}
}