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

@@ -0,0 +1,101 @@
import Foundation
/// AI : LLM 3-4
///
/// HealthExportService ,(< 400 token),
/// await
///
/// :DiaryQuickSheet
@MainActor
struct DiaryAssistService {
static let shared = DiaryAssistService()
private init() {}
/// fill ,
/// `dim` ( `DiaryAssistPrompts.dimensions`),
/// `adopted` UI ;`round` UI append ,
struct Question: Identifiable, Hashable {
let id: UUID
let q: String
let fill: String
let dim: String
var adopted: Bool
var round: Int
init(id: UUID = UUID(),
q: String,
fill: String,
dim: String = "",
adopted: Bool = false,
round: Int = 0) {
self.id = id
self.q = q
self.fill = fill
self.dim = dim
self.adopted = adopted
self.round = round
}
}
enum AssistError: Error, LocalizedError {
case modelNotReady
case empty
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好")
case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试")
case .parseFailed(let m): return String(appLoc: "结果解析失败:\(m)")
}
}
}
/// 3-4
/// - coveredDimensions: ,( question.dim),
/// prompt
/// : AIRuntime actor , Capture / Export GPU
func suggest(content: String,
coveredDimensions: [String] = []) async throws -> (questions: [Question], decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
var collected = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
// 1. <think>...</think>( HealthExportService )
let stripped = HealthExportService.stripThinkBlocks(collected)
// 2. JSON( CaptureService.extractJSONObject)
let jsonStr = CaptureService.extractJSONObject(from: stripped)
guard let data = jsonStr.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let dict = obj as? [String: Any] else {
throw AssistError.parseFailed("非 JSON 输出")
}
guard let rawQuestions = dict["questions"] as? [[String: Any]] else {
throw AssistError.parseFailed("缺少 questions 字段")
}
let questions = rawQuestions.compactMap { d -> Question? in
guard let q = (d["q"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
return nil
}
let fill = (d["fill"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let dim = (d["dim"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return Question(q: q, fill: fill, dim: dim)
}
guard !questions.isEmpty else { throw AssistError.empty }
return (Array(questions.prefix(4)), lastRate)
}
}