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: "待下载", style: .neutral) case .downloading: return TjBadge(text: "下载中", style: .amber) case .verifying: return TjBadge(text: "校验中", style: .amber) case .ready: return TjBadge(text: "已就绪", style: .leaf) case .failed: return TjBadge(text: "失败 · 重试", 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) { 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 { importError = "请选择名为 Qwen3-1.7B-4bit 或 Qwen2.5-VL-3B-Instruct-4bit 的文件夹" return } try service.importModel(kind, from: folder) importError = nil } catch { importError = "导入失败:\(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 "文本解读 · 趋势 / 问答" case .vl: return "拍照识别报告 → 结构化指标" } } 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() } }