Files
kangkang/康康/Features/Me/ModelManagementView.swift
link2026 d2c77d5c51 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>
2026-05-30 10:28:24 +08:00

241 lines
8.7 KiB
Swift

import SwiftUI
import Network
import UniformTypeIdentifiers
/// · :/,/ +
/// ModelDownloadService , URLSession(§3.1)
struct ModelManagementView: View {
@State private var service = ModelDownloadService.shared
@State private var isCellular = false
@State private var showCellularConfirm = false
@State private var showImporter = false
@State private var importError: String?
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
private var allReady: Bool {
ModelKind.allCases.allSatisfy { service.states[$0]?.phase == .ready }
}
var body: some View {
ScrollView {
VStack(spacing: 14) {
ForEach(ModelKind.allCases, id: \.self) { kind in
modelCard(kind)
}
actionButtons
.padding(.top, 4)
if service.states[.llm]?.phase == .ready {
NavigationLink {
ModelSelfTestView()
} label: {
HStack(spacing: 6) {
Image(systemName: "play.circle")
Text("运行推理自检")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
}
if let importError {
Text(importError)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity, alignment: .leading)
}
footer
.padding(.top, 8)
}
.padding(.horizontal, 16)
.padding(.vertical, 18)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("模型管理")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
service.refreshStates()
monitor.pathUpdateHandler = { path in
let cellular = path.status == .satisfied && path.usesInterfaceType(.cellular)
Task { @MainActor in isCellular = cellular }
}
monitor.start(queue: monitorQueue)
}
.onDisappear { monitor.cancel() }
.fileImporter(isPresented: $showImporter,
allowedContentTypes: [.folder]) { handleImport($0) }
.alert("使用蜂窝网络下载?", isPresented: $showCellularConfirm) {
Button("取消", role: .cancel) {}
Button("继续下载") { service.downloadAll() }
} message: {
Text("模型约 \(formatBytes(totalAllBytes)),建议在 Wi-Fi 下下载。")
}
}
// MARK: -
private func modelCard(_ kind: ModelKind) -> some View {
let state = service.states[kind]
?? DownloadState(phase: .idle, receivedBytes: 0,
totalBytes: ModelManifest.totalBytes(for: kind), bytesPerSecond: 0)
return VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
Text(kind.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(subtitle(kind))
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
statusBadge(state.phase)
}
if state.phase == .downloading {
ProgressView(value: min(max(state.fraction, 0), 1))
.tint(Tj.Palette.ink)
HStack {
Text("\(Int(state.fraction * 100))%")
Spacer()
Text(speedText(state))
}
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
} else {
HStack {
Text(formatBytes(ModelManifest.totalBytes(for: kind)))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
Spacer()
if case .failed(let message) = state.phase {
Text(message)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.brick)
.lineLimit(1)
}
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
.contentShape(Rectangle())
.onTapGesture {
if case .failed = state.phase { service.download(kind) }
}
}
private func statusBadge(_ phase: DownloadPhase) -> some View {
switch phase {
case .idle: return TjBadge(text: String(appLoc: "待下载"), style: .neutral)
case .downloading: return TjBadge(text: String(appLoc: "下载中"), style: .amber)
case .verifying: return TjBadge(text: String(appLoc: "校验中"), style: .amber)
case .ready: return TjBadge(text: String(appLoc: "已就绪"), style: .leaf)
case .failed: return TjBadge(text: String(appLoc: "失败 · 重试"), style: .brick)
}
}
// MARK: -
@ViewBuilder
private var actionButtons: some View {
if service.isAnyDownloading {
Button {
for kind in ModelKind.allCases { service.cancel(kind) }
} label: {
Text("暂停下载").frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
} else if allReady {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
Text("两个模型都已就绪")
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.leaf)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
} else {
Button {
if isCellular { showCellularConfirm = true } else { service.downloadAll() }
} label: {
Text("下载全部模型 · \(formatBytes(totalAllBytes))")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
}
Button {
importError = nil
showImporter = true
} label: {
Text("从文件导入(离线)").frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
}
private var footer: some View {
VStack(spacing: 8) {
TjLockChip()
Text("100% 本地推理 · 模型仅需下载一次")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity)
}
// MARK: -
private func handleImport(_ result: Result<URL, Error>) {
do {
let folder = try result.get()
let scoped = folder.startAccessingSecurityScopedResource()
defer { if scoped { folder.stopAccessingSecurityScopedResource() } }
let name = folder.lastPathComponent
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else {
let names = ModelKind.allCases.map(\.rawValue).joined(separator: "")
importError = String(appLoc: "请选择名为 \(names) 的文件夹")
return
}
try service.importModel(kind, from: folder)
importError = nil
} catch {
importError = String(appLoc: "导入失败:\(error.localizedDescription)")
}
}
// MARK: -
private var totalAllBytes: Int {
ModelKind.allCases.reduce(0) { $0 + ModelManifest.totalBytes(for: $1) }
}
private func subtitle(_ kind: ModelKind) -> String {
switch kind {
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
}
}
private func formatBytes(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
private func speedText(_ state: DownloadState) -> String {
guard state.bytesPerSecond > 0 else { return "" }
return formatBytes(Int(state.bytesPerSecond)) + "/s"
}
}
#Preview {
NavigationStack {
ModelManagementView()
}
}