主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施: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>
241 lines
8.7 KiB
Swift
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()
|
|
}
|
|
}
|