``` docs(readme): 更新文档说明 - 添加了项目使用说明 - 完善了配置项描述 - 修正了示例代码格式 ``` 注意:由于您提供的code differences部分为空,无法获取具体的代码变更信息来生成准确的commit message。请提供实际的代码差异信息以便生成更精确的提交消息。
195 lines
7.9 KiB
Swift
195 lines
7.9 KiB
Swift
import SwiftUI
|
|
import ObjectiveC
|
|
|
|
/// App 支持的界面语言。`system` = 跟随系统;其余对应 .lproj / String Catalog 语言。
|
|
enum AppLanguage: String, CaseIterable, Identifiable {
|
|
case system
|
|
case zhHans = "zh-Hans"
|
|
case en
|
|
case ja
|
|
case ko
|
|
|
|
var id: String { rawValue }
|
|
|
|
/// 选择器里展示的名字。各语言用其**本族语**显示(行业惯例,不本地化),仅「跟随系统」随 App 语言。
|
|
var displayName: String {
|
|
switch self {
|
|
case .system: return String(appLoc: "跟随系统")
|
|
case .zhHans: return "简体中文"
|
|
case .en: return "English"
|
|
case .ja: return "日本語"
|
|
case .ko: return "한국어"
|
|
}
|
|
}
|
|
|
|
/// nil = 跟随系统;否则为 .lproj / Locale 标识。
|
|
var localeIdentifier: String? {
|
|
self == .system ? nil : rawValue
|
|
}
|
|
|
|
/// 语言选择器图标。各语言用本族语代表字区分(中 / A / あ / 가),
|
|
/// 「跟随系统」非具体语言,用地球符号。代表字与 `displayName` 一样不本地化。
|
|
enum PickerIcon: Equatable {
|
|
case symbol(String) // SF Symbol 名
|
|
case glyph(String) // 本族语代表字
|
|
}
|
|
|
|
var pickerIcon: PickerIcon {
|
|
switch self {
|
|
case .system: return .symbol("globe")
|
|
case .zhHans: return .glyph("中")
|
|
case .en: return .glyph("A")
|
|
case .ja: return .glyph("あ")
|
|
case .ko: return .glyph("가")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 全 App 单例。负责:持久化选择、维护当前语言的 lproj bundle 与 locale。
|
|
/// - `Text("…")` 走根视图注入的环境 `\.locale`(+ Bundle 重定向)即时切换;
|
|
/// - `String(appLoc:)` 显式绑定本管理器的 bundle/locale,不受 `.current` 限制,同样即时切换。
|
|
/// 切换后由根视图 `.id(current)` 触发整树重建,无需重启。
|
|
@Observable
|
|
final class LanguageManager {
|
|
static let shared = LanguageManager()
|
|
|
|
private let storageKey = "appLanguage"
|
|
|
|
private(set) var current: AppLanguage
|
|
/// 当前语言对应的 .lproj bundle(system 或缺失时为 .main)。缓存,切换时更新。
|
|
private(set) var lprojBundle: Bundle = .main
|
|
/// 当前解析后的 locale(system 时为 .autoupdatingCurrent)。
|
|
private(set) var resolvedLocale: Locale = .autoupdatingCurrent
|
|
|
|
/// 供 SwiftUI 环境使用(日期/数字格式化 + Text 语言)。
|
|
var locale: Locale { resolvedLocale }
|
|
|
|
private init() {
|
|
let saved = UserDefaults.standard.string(forKey: storageKey)
|
|
current = AppLanguage(rawValue: saved ?? "") ?? .system
|
|
apply()
|
|
}
|
|
|
|
func set(_ language: AppLanguage) {
|
|
guard language != current else { return }
|
|
current = language
|
|
UserDefaults.standard.set(language.rawValue, forKey: storageKey)
|
|
// 同步 AppleLanguages:保证下次冷启动解析正确,并与系统「设置 → App → 语言」一致。
|
|
if let id = language.localeIdentifier {
|
|
UserDefaults.standard.set([id], forKey: "AppleLanguages")
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
|
|
}
|
|
apply()
|
|
}
|
|
|
|
private func apply() {
|
|
if let id = current.localeIdentifier {
|
|
resolvedLocale = Locale(identifier: id)
|
|
if let path = Bundle.main.path(forResource: id, ofType: "lproj"),
|
|
let b = Bundle(path: path) {
|
|
lprojBundle = b
|
|
} else {
|
|
lprojBundle = .main
|
|
}
|
|
} else {
|
|
resolvedLocale = .autoupdatingCurrent
|
|
lprojBundle = .main
|
|
}
|
|
Bundle.redirectMain(to: current.localeIdentifier)
|
|
// 同步 nonisolated 快照,供 String(appLoc:) 在非 MainActor 上下文读取。
|
|
appLocBundle = lprojBundle
|
|
appLocLocale = resolvedLocale
|
|
}
|
|
}
|
|
|
|
/// nonisolated 快照:`String(appLoc:)` 可能在非 MainActor 上下文被调用
|
|
/// (LocalizedError.errorDescription、nonisolated 枚举 label、static 解析器…)。
|
|
/// 只由 `LanguageManager.apply()`(MainActor)写入,切换语言时刷新;读为快照,无竞态影响。
|
|
nonisolated(unsafe) private var appLocBundle: Bundle = .main
|
|
nonisolated(unsafe) private var appLocLocale: Locale = .autoupdatingCurrent
|
|
|
|
extension String {
|
|
/// 尊重「我的 · 语言」选择的本地化(可即时切换)。
|
|
/// 等价 `String(localized:)`,但显式绑定当前所选语言的 bundle + locale,
|
|
/// 因此不受 `Locale.current`(系统/启动时语言)限制。
|
|
nonisolated init(appLoc key: String.LocalizationValue) {
|
|
self = String(localized: key, bundle: appLocBundle, locale: appLocLocale)
|
|
}
|
|
}
|
|
|
|
// MARK: - AI 输出语言(供 Service/Prompt 层在非 MainActor 上下文读取当前界面语言)
|
|
|
|
/// 当前界面语言解析出的「输出语言」,给本地 LLM 的 prompt 用:
|
|
/// 让 AI 生成的追问 / 解读 / 摘要跟随用户所选界面语言,而不是永远中文。
|
|
/// 判定只看 `appLocLocale`(LanguageManager 切换时刷新的 nonisolated 快照);
|
|
/// `system` 跟随系统时,按系统首选语言归一到最接近的受支持语言,兜底中文。
|
|
nonisolated enum AIOutputLanguage: String {
|
|
case zhHans, en, ja, ko
|
|
|
|
/// 从当前 appLocLocale 解析。system(autoupdatingCurrent)时读系统语言码。
|
|
static var current: AIOutputLanguage {
|
|
let code = appLocLocale.language.languageCode?.identifier ?? "zh"
|
|
switch code {
|
|
case "en": return .en
|
|
case "ja": return .ja
|
|
case "ko": return .ko
|
|
default: return .zhHans // zh 及其它未支持语言统一回中文(few-shot 也是中文)
|
|
}
|
|
}
|
|
|
|
/// prompt 里对模型自称的目标语言名(用该语言自己的说法,减少模型误判)。
|
|
var promptName: String {
|
|
switch self {
|
|
case .zhHans: return "简体中文"
|
|
case .en: return "English"
|
|
case .ja: return "日本語"
|
|
case .ko: return "한국어"
|
|
}
|
|
}
|
|
|
|
/// 追加到 prompt 末尾的「用某语言输出」硬指令。中文时返回空串(few-shot 已是中文,不必额外声明)。
|
|
/// 用于「生成给用户看的内容」(追问 / 解读 / 摘要),让输出语言=界面语言。
|
|
var outputDirective: String {
|
|
self == .zhHans ? "" : "\n\n重要:请全程用\(promptName)输出所有面向用户的文字(JSON 的字段值也用\(promptName)),不要用中文。"
|
|
}
|
|
}
|
|
|
|
/// 便捷:当前界面语言的「用某语言输出」指令(见 `AIOutputLanguage.outputDirective`)。
|
|
nonisolated func aiOutputLanguageDirective() -> String {
|
|
AIOutputLanguage.current.outputDirective
|
|
}
|
|
|
|
// MARK: - Bundle 运行时重定向(供 Text / NSLocalizedString 双保险)
|
|
|
|
/// 关联对象 key(需稳定地址,文件级全局即可)。
|
|
private var redirectBundleKey: UInt8 = 0
|
|
|
|
/// 把 `Bundle.main` 的字符串查表重定向到指定语言的 .lproj。
|
|
private final class LocalizedMainBundle: Bundle, @unchecked Sendable {
|
|
override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String {
|
|
if let target = objc_getAssociatedObject(self, &redirectBundleKey) as? Bundle {
|
|
return target.localizedString(forKey: key, value: value, table: tableName)
|
|
}
|
|
return super.localizedString(forKey: key, value: value, table: tableName)
|
|
}
|
|
}
|
|
|
|
extension Bundle {
|
|
/// language == nil → 跟随系统(走默认解析)。
|
|
static func redirectMain(to language: String?) {
|
|
if !(Bundle.main is LocalizedMainBundle) {
|
|
object_setClass(Bundle.main, LocalizedMainBundle.self)
|
|
}
|
|
let target: Bundle?
|
|
if let language,
|
|
let path = Bundle.main.path(forResource: language, ofType: "lproj"),
|
|
let bundle = Bundle(path: path) {
|
|
target = bundle
|
|
} else {
|
|
target = nil
|
|
}
|
|
objc_setAssociatedObject(Bundle.main, &redirectBundleKey, target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
}
|
|
}
|