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:
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftData
|
||||
|
||||
/// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。
|
||||
/// 与 Indicator/Report 字段近似但解耦 —— 这样 prompt schema 调整不污染数据层。
|
||||
@@ -40,16 +41,14 @@ struct ParsedReport: Sendable {
|
||||
/// CaptureService 错误 — UI 决定怎么呈现(回退表单 vs 重试)。
|
||||
enum CaptureError: Error, LocalizedError {
|
||||
case modelNotReady
|
||||
case writeAssetFailed
|
||||
case inferenceFailed(String)
|
||||
case parseFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelNotReady: return "VL 模型尚未就绪"
|
||||
case .writeAssetFailed: return "图片保存失败"
|
||||
case .inferenceFailed(let m): return "识别失败:\(m)"
|
||||
case .parseFailed(let m): return "结构化失败:\(m)"
|
||||
case .modelNotReady: return String(appLoc: "VL 模型尚未就绪")
|
||||
case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)")
|
||||
case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,38 +59,36 @@ actor CaptureService {
|
||||
static let shared = CaptureService()
|
||||
private init() {}
|
||||
|
||||
/// 写图 + VL 推理 + 解析 → ParsedReport。
|
||||
/// 任何阶段失败,都抛 CaptureError;UI 接住后切到「手动录入」表单。
|
||||
/// - Returns: (ParsedReport, [FileVault.SavedAsset]) 元组,
|
||||
/// SavedAsset 列表用于后续构造 Asset @Model。
|
||||
func analyze(images: [UIImage]) async throws
|
||||
-> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) {
|
||||
/// 对已写入 Vault 的 Asset 跑 VL,返回结构化 ParsedReport。
|
||||
/// 用于:
|
||||
/// - UnifiedCaptureFlow 的初次识别(UI 先写图、再调本方法,失败/取消都能保留 assets 走手动录入)
|
||||
/// - 录入表单顶部的「重新识别」按钮
|
||||
/// - C2「重新解读」(W5)
|
||||
/// SwiftData 写回由调用方(MainActor)负责,见 `Report.applyReanalyzed(_:in:)`。
|
||||
/// 不直接接 @Model 类型,避免把非 Sendable 引用抛过 actor 边界。
|
||||
func reanalyze(assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||
try await runVL(on: assets)
|
||||
}
|
||||
|
||||
// 1. 写图到 Vault(全程加密目录)
|
||||
let assets: [FileVault.SavedAsset]
|
||||
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
||||
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||
do {
|
||||
assets = try images.map { try FileVault.shared.writeJPEG($0) }
|
||||
try await AIRuntime.shared.prepareVL()
|
||||
} catch {
|
||||
throw CaptureError.writeAssetFailed
|
||||
throw CaptureError.modelNotReady
|
||||
}
|
||||
|
||||
// 2. VL 推理
|
||||
try await AIRuntime.shared.prepareVL()
|
||||
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
|
||||
let raw: String
|
||||
do {
|
||||
raw = try await AIRuntime.shared.analyzeReport(
|
||||
imageURLs: urls,
|
||||
prompt: VLPrompts.reportExtraction
|
||||
prompt: VLPrompts.reportExtraction()
|
||||
)
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
}
|
||||
|
||||
// 3. JSON 解析(带容错:可能包含围栏 / 前后文字)
|
||||
do {
|
||||
let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count)
|
||||
return (parsed, assets)
|
||||
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
throw CaptureError.parseFailed(msg)
|
||||
} catch {
|
||||
@@ -136,7 +133,7 @@ actor CaptureService {
|
||||
}
|
||||
|
||||
return ParsedReport(
|
||||
title: title.isEmpty ? "拍摄识别" : title,
|
||||
title: title.isEmpty ? String(appLoc: "拍摄识别") : title,
|
||||
typeRaw: typeRaw,
|
||||
reportDate: reportDate,
|
||||
institution: institution,
|
||||
@@ -216,3 +213,53 @@ actor CaptureService {
|
||||
return .init(name: name, value: value, unit: unit, range: range, status: status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Report ↔ CaptureService 桥接(MainActor 侧)
|
||||
//
|
||||
// CaptureService 是 actor,不能直接收 Report(@Model 非 Sendable)。
|
||||
// C2「重新解读」UI 走这条路径:
|
||||
// ```
|
||||
// let assets = report.savedAssets
|
||||
// let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||
// report.applyReanalyzed(parsed, in: ctx)
|
||||
// ```
|
||||
|
||||
extension Report {
|
||||
/// 关联 Asset 转 SavedAsset,直接喂 CaptureService.reanalyze。
|
||||
var savedAssets: [FileVault.SavedAsset] {
|
||||
assets.map { .init(relativePath: $0.relativePath, bytes: $0.bytes) }
|
||||
}
|
||||
|
||||
/// 把 VL 重新识别结果写回 Report。
|
||||
/// - indicators:旧的全删,新的整批插入并维持关联(cascade delete 会清缓存)
|
||||
/// - summary / institution:非空才覆盖,避免空摘要把好结果清掉
|
||||
/// 必须在 MainActor / SwiftData 主上下文里调用。
|
||||
@MainActor
|
||||
func applyReanalyzed(_ parsed: ParsedReport, in ctx: ModelContext) {
|
||||
if !parsed.summary.isEmpty {
|
||||
self.summary = parsed.summary
|
||||
}
|
||||
if !parsed.institution.isEmpty {
|
||||
self.institution = parsed.institution
|
||||
}
|
||||
// 旧 indicators 全删(cascade 会一起清)
|
||||
for old in indicators {
|
||||
ctx.delete(old)
|
||||
}
|
||||
indicators.removeAll()
|
||||
// 新 indicators 重新插入
|
||||
for p in parsed.indicators {
|
||||
let i = Indicator(
|
||||
name: p.name,
|
||||
value: p.value,
|
||||
unit: p.unit,
|
||||
range: p.range,
|
||||
status: p.status,
|
||||
capturedAt: reportDate,
|
||||
report: self
|
||||
)
|
||||
ctx.insert(i)
|
||||
}
|
||||
try? ctx.save()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user