From 7f49809beee94042afde0153aa868c56e5914d13 Mon Sep 17 00:00:00 2001 From: mu9 <1457993902@qq.com> Date: Mon, 25 May 2026 01:40:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BD=9C=E4=B8=9A?= =?UTF-8?q?=E9=9B=86=EF=BC=8C=E6=89=B9=E9=87=8F=E4=BD=9C=E6=88=98=EF=BC=88?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8=E7=BC=96=E9=98=9F=E5=92=8C?= =?UTF-8?q?=E4=BF=A1=E8=B5=96=E5=B9=B2=E5=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MeoAsstMac/Model/MAACopilot.swift | 5 + MeoAsstMac/Model/MAAViewModel.swift | 11 +- MeoAsstMac/Navigation/CopilotContent.swift | 333 +++++++++++++++++- MeoAsstMac/Navigation/CopilotDetail.swift | 31 +- .../CopilotConfiguration.swift | 31 ++ MeoAsstMac/Views/CopilotView.swift | 12 +- 6 files changed, 393 insertions(+), 30 deletions(-) diff --git a/MeoAsstMac/Model/MAACopilot.swift b/MeoAsstMac/Model/MAACopilot.swift index 8899e8693..815ee15ac 100644 --- a/MeoAsstMac/Model/MAACopilot.swift +++ b/MeoAsstMac/Model/MAACopilot.swift @@ -13,6 +13,7 @@ struct MAACopilot: Codable, Equatable { let groups: [Group]? let minimum_required: String let doc: Documentation? + let difficulty: Int? // MARK: SSS @@ -37,6 +38,10 @@ struct MAACopilot: Codable, Equatable { let details: String? let details_color: String? } + + var isRaid: Bool { + difficulty == 2 + } } extension MAACopilot.Operator: CustomStringConvertible { diff --git a/MeoAsstMac/Model/MAAViewModel.swift b/MeoAsstMac/Model/MAAViewModel.swift index a47c21cfa..1f9259717 100644 --- a/MeoAsstMac/Model/MAAViewModel.swift +++ b/MeoAsstMac/Model/MAAViewModel.swift @@ -92,7 +92,12 @@ import SwiftUI } @Published var copilot: CopilotConfiguration? + @Published var copilotFormation = false + @Published var copilotAddTrust = false + @Published var useCopilotSet = false + @Published var copilotSetEntries = [CopilotSetTaskEntry]() @Published var downloadCopilot: String? + @Published var downloadCopilotSet: String? @Published var showImportCopilot = false @Published var copilotDetailMode: CopilotDetailMode = .log @@ -518,7 +523,9 @@ extension MAAViewModel { defer { handleEarlyReturn(backTo: .idle) } guard let copilot, - let params = copilot.params + let params = copilot + .applyingCommonOptions(formation: copilotFormation, addTrust: copilotAddTrust) + .params else { return } @@ -526,7 +533,7 @@ extension MAAViewModel { try await ensureHandle() switch copilot { - case .regular: + case .regular, .regularList: _ = try await handle?.appendTask(type: .Copilot, params: params) case .sss: _ = try await handle?.appendTask(type: .SSSCopilot, params: params) diff --git a/MeoAsstMac/Navigation/CopilotContent.swift b/MeoAsstMac/Navigation/CopilotContent.swift index f65545737..5e22315d4 100644 --- a/MeoAsstMac/Navigation/CopilotContent.swift +++ b/MeoAsstMac/Navigation/CopilotContent.swift @@ -14,9 +14,32 @@ struct CopilotContent: View { @State private var copilots = Set() @State private var downloading = false @State private var expanded = false + @State private var copilotSetExpanded = true var body: some View { List(selection: $selection) { + DisclosureGroup(isExpanded: $copilotSetExpanded) { + ForEach(viewModel.copilotSetEntries) { entry in + CopilotListRow( + title: entry.displayName, + onSelect: { selectCopilot(url: URL(fileURLWithPath: entry.filename)) }, + removalTitle: "移出作业集", + onRemove: { removeCopilotSetEntry(entry) }) + } + } label: { + Text("作业集") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + copilotSetExpanded.toggle() + } + } + } + DisclosureGroup(isExpanded: $expanded) { ForEach(bundledCopilots, id: \.self) { url in Text(url.lastPathComponent) @@ -51,11 +74,14 @@ struct CopilotContent: View { } .toolbar(content: listToolbar) .animation(.default, value: copilots) + .animation(.default, value: viewModel.copilotSetEntries) + .animation(.default, value: viewModel.useCopilotSet) .animation(.default, value: downloading) .onAppear(perform: loadUserCopilots) .onDrop(of: [.fileURL], isTargeted: .none, perform: addCopilots) .onReceive(viewModel.$copilotDetailMode, perform: deselectCopilot) .onReceive(viewModel.$downloadCopilot, perform: downloadCopilot) + .onReceive(viewModel.$downloadCopilotSet, perform: downloadCopilotSet) .onReceive(viewModel.$videoRecoginition, perform: selectNewCopilot) .fileImporter( isPresented: $viewModel.showImportCopilot, @@ -108,6 +134,10 @@ struct CopilotContent: View { private func start() { Task { viewModel.copilotDetailMode = .log + if viewModel.useCopilotSet { + guard !viewModel.copilotSetEntries.isEmpty else { return } + viewModel.copilot = .regularList(makeCopilotListConfiguration()) + } try await viewModel.startCopilot() } } @@ -145,22 +175,74 @@ struct CopilotContent: View { private func downloadCopilot(id: String?) { guard let id else { return } - let file = - externalDirectory - .appendingPathComponent(id) - .appendingPathExtension("json") - - let url = URL(string: "https://prts.maa.plus/copilot/get/\(id)")! Task { self.downloading = true do { - let data = try await URLSession.shared.data(from: url).0 - let response = try JSONDecoder().decode(CopilotResponse.self, from: data) - try response.data.content.write(toFile: file.path, atomically: true, encoding: .utf8) + let file = externalDirectory.appendingPathComponent(id).appendingPathExtension("json") + let content = try await requestCopilotContent(id: id) + try content.write(toFile: file.path, atomically: true, encoding: .utf8) copilots.insert(file) self.selection = file } catch { - print(error) + viewModel.logError("下载作业失败: \(id)") + viewModel.logError("\(error.localizedDescription)") + } + self.downloading = false + } + } + + private func downloadCopilotSet(id: String?) { + guard let id else { return } + + Task { + self.downloading = true + do { + let response = try await requestCopilotSet(id: id) + guard response.statusCode == 200 else { + if let message = response.message { + viewModel.logError("\(message)") + } else { + viewModel.logError("作业集不存在: \(id)") + } + self.downloading = false + return + } + + guard let copilotIds = response.data?.copilotIds, !copilotIds.isEmpty else { + viewModel.logError("作业集为空: \(id)") + self.downloading = false + return + } + + var importedFiles = [URL]() + var copilotSetEntries = [CopilotSetTaskEntry]() + let fileNames = copilotSetImportFileNames(copilotIDs: copilotIds) + + for (copilotId, fileName) in zip(copilotIds, fileNames) { + do { + let file = externalDirectory.appendingPathComponent(fileName) + let content = try await requestCopilotContent(id: String(copilotId)) + try content.write(toFile: file.path, atomically: true, encoding: .utf8) + copilots.insert(file) + importedFiles.append(file) + if let entries = makeCopilotSetEntries(file: file, content: content, copilotID: copilotId) { + copilotSetEntries.append(contentsOf: entries) + } + } catch { + viewModel.logError("下载作业集条目失败: \(copilotId)") + } + } + + if importedFiles.isEmpty { + viewModel.logError("作业集导入失败: \(id)") + } else { + viewModel.copilotSetEntries = copilotSetEntries + viewModel.useCopilotSet = !copilotSetEntries.isEmpty + self.selection = importedFiles.first + } + } catch { + viewModel.logError("下载作业集失败: \(id)") + viewModel.logError("\(error.localizedDescription)") } self.downloading = false } @@ -168,6 +250,7 @@ struct CopilotContent: View { private func deleteCopilot(url: URL) { copilots.remove(url) + removeCopilotSetEntries(filename: url.path) guard canDelete(url) else { return } try? FileManager.default.removeItem(at: url) } @@ -176,12 +259,31 @@ struct CopilotContent: View { guard let selection, let index = copilots.urls.firstIndex(of: selection) else { return } deleteCopilot(url: selection) + selectCopilotAfterDeletion(deletedIndex: index) + } + + private func deleteCopilotAndSelectNext(url: URL) { + guard let index = copilots.urls.firstIndex(of: url) else { + deleteCopilot(url: url) + if selection == url { + selectCopilot(url: nil) + } + return + } + + deleteCopilot(url: url) + if selection == url { + selectCopilotAfterDeletion(deletedIndex: index) + } + } + + private func selectCopilotAfterDeletion(deletedIndex index: Int) { let urls = copilots.urls if index < urls.count { - self.selection = urls[index] + selectCopilot(url: urls[index]) } else { - self.selection = urls.last + selectCopilot(url: urls.last) } } @@ -194,10 +296,48 @@ struct CopilotContent: View { private func selectNewCopilot(url: URL?) { if let url { copilots.insert(url) - selection = copilots.urls.last + selectCopilot(url: copilots.urls.last) } } + private func selectCopilot(url: URL?) { + selection = url + if url != nil { + viewModel.copilotDetailMode = .copilotConfig + } + } + + private func removeCopilotSetEntry(_ entry: CopilotSetTaskEntry) { + viewModel.copilotSetEntries.removeAll { $0.id == entry.id } + if viewModel.copilotSetEntries.isEmpty { + viewModel.useCopilotSet = false + } + } + + private func removeCopilotSetEntries(filename: String) { + viewModel.copilotSetEntries.removeAll { $0.filename == filename } + if viewModel.copilotSetEntries.isEmpty { + viewModel.useCopilotSet = false + } + } + + private func makeCopilotListConfiguration() -> RegularCopilotListConfiguration { + let list = viewModel.copilotSetEntries.map { + CopilotListItem(filename: $0.filename, stage_name: $0.stageName, is_raid: $0.isRaid) + } + let regularConfig: RegularCopilotConfiguration? = { + if case .regular(let config) = viewModel.copilot { + return config + } + return nil + }() + + return .init( + copilot_list: list, + formation: regularConfig?.formation ?? false, + add_trust: regularConfig?.add_trust ?? false) + } + // MARK: - State Wrappers private var shouldDisableDeletion: Bool { @@ -246,6 +386,50 @@ struct CopilotContent: View { .appendingPathComponent("cache") .appendingPathComponent("CombatRecord") } + + private func requestCopilotContent(id: String) async throws -> String { + let url = URL(string: "https://prts.maa.plus/copilot/get/\(id)")! + let data = try await URLSession.shared.data(from: url).0 + let response = try JSONDecoder().decode(CopilotResponse.self, from: data) + return response.data.content + } + + private func requestCopilotSet(id: String) async throws -> CopilotSetResponse { + let url = URL(string: "https://prts.maa.plus/set/get?id=\(id)")! + let data = try await URLSession.shared.data(from: url).0 + return try JSONDecoder().decode(CopilotSetResponse.self, from: data) + } + + private func makeCopilotSetEntries(file: URL, content: String, copilotID: Int) -> [CopilotSetTaskEntry]? { + guard + let data = content.data(using: .utf8), + let copilot = try? JSONDecoder().decode(MAACopilot.self, from: data) + else { + viewModel.logError("作业集条目解析失败: \(copilotID)") + return nil + } + + guard copilot.type != "SSS" else { + viewModel.logWarn("作业集列表暂不支持保全作业: \(copilotID)") + return nil + } + + let stageName = StageNavigationNameResolver.resolve( + stageName: copilot.stage_name, + title: copilot.doc?.title) + return copilotSetTaskEntries(filename: file.path, stageName: stageName, difficulty: copilot.difficulty) + } +} + +struct CopilotSetTaskEntry: Equatable, Identifiable { + let id = UUID() + var filename: String + var stageName: String + var isRaid: Bool + + var displayName: String { + isRaid ? "\(stageName)-Adverse" : stageName + } } struct CopilotContent_Previews: PreviewProvider { @@ -255,8 +439,111 @@ struct CopilotContent_Previews: PreviewProvider { } } +private struct CopilotListRow: View { + let title: String + var onSelect: () -> Void + var removalTitle: String? + var onRemove: (() -> Void)? + + var body: some View { + Button(action: onSelect) { + Text(title) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + if let removalTitle, let onRemove { + Button(removalTitle, action: onRemove) + } + } + } +} + // MARK: - Value Extensions +private func copilotSetImportFileNames(copilotIDs: [Int]) -> [String] { + let width = max(2, String(copilotIDs.count).count) + return copilotIDs.enumerated().map { index, copilotID in + "\(String(format: "%0*d", width, index + 1))_\(copilotID).json" + } +} + +private func copilotSetTaskEntries(filename: String, stageName: String, difficulty: Int?) -> [CopilotSetTaskEntry] { + let difficulty = difficulty ?? 0 + let supportsNormal = difficulty == 0 || difficulty & 1 != 0 + let supportsRaid = difficulty & 2 != 0 + + var entries = [CopilotSetTaskEntry]() + if supportsNormal { + entries.append(.init(filename: filename, stageName: stageName, isRaid: false)) + } + if supportsRaid { + entries.append(.init(filename: filename, stageName: stageName, isRaid: true)) + } + return entries +} + +private enum StageNavigationNameResolver { + private static let stageCodeByIdentifier: [String: String] = { + let url = Bundle.main.resourceURL? + .appendingPathComponent("resource") + .appendingPathComponent("Arknights-Tile-Pos") + .appendingPathComponent("overview") + .appendingPathExtension("json") + + guard + let url, + let data = try? Data(contentsOf: url), + let overview = try? JSONDecoder().decode([String: StageOverview].self, from: data) + else { return [:] } + + var result = [String: String]() + for (_, stage) in overview { + guard let code = stage.code else { continue } + for identifier in [stage.code, stage.name, stage.stageId, stage.levelId].compactMap({ $0 }) { + result[identifier] = code + } + } + return result + }() + + static func resolve(stageName: String, title: String?) -> String { + if let code = stageCodeByIdentifier[stageName] { + return code + } + + if let title, let code = extractStageCode(from: title) { + return code + } + + return stageName + } + + private static func extractStageCode(from title: String) -> String? { + let pattern = #"(?:[A-Za-z]{0,3})(?:\d{0,2})-(?:(?:A|B|C|D|EX|S|TR|MO)-?)?(?:\d{1,2})"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + + let range = NSRange(title.startIndex.. some View { + HStack { + Toggle("自动编队", isOn: $viewModel.copilotFormation) + Toggle("信赖干员", isOn: $viewModel.copilotAddTrust) + Toggle("使用作业集", isOn: $viewModel.useCopilotSet) + .disabled(viewModel.copilotSetEntries.isEmpty) + } + } + @ToolbarContentBuilder private func detailToolbar() -> some ToolbarContent { ToolbarItemGroup { Button { @@ -73,13 +86,21 @@ struct CopilotDetail: View { .buttonStyle(.borderedProminent) .disabled(prtsCode.parsedID == nil) + Button { + viewModel.downloadCopilotSet = prtsCode.parsedID + showAdd = false + } label: { + Label("添加作业集", systemImage: "square.stack.3d.down.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(prtsCode.parsedID == nil) + Button { if let clipboardString = NSPasteboard.general.string(forType: .string), - let parsedID = clipboardString.parsedID + clipboardString.parsedID != nil { prtsCode = clipboardString - viewModel.downloadCopilot = parsedID - showAdd = false } } label: { Label("从剪贴板读取", systemImage: "doc.on.clipboard") diff --git a/MeoAsstMac/Task Configurations/CopilotConfiguration.swift b/MeoAsstMac/Task Configurations/CopilotConfiguration.swift index 6221ceb88..4bf273250 100644 --- a/MeoAsstMac/Task Configurations/CopilotConfiguration.swift +++ b/MeoAsstMac/Task Configurations/CopilotConfiguration.swift @@ -14,6 +14,19 @@ struct RegularCopilotConfiguration: Codable { var add_trust = false } +struct RegularCopilotListConfiguration: Codable { + var enable = true + var copilot_list: [CopilotListItem] + var formation = false + var add_trust = false +} + +struct CopilotListItem: Codable, Equatable { + var filename: String + var stage_name: String + var is_raid = false +} + struct SSSCopilotConfiguration: Codable { var enable = true var filename: String @@ -31,14 +44,32 @@ struct VideoRecognitionConfiguration: Codable { enum CopilotConfiguration { case regular(RegularCopilotConfiguration) + case regularList(RegularCopilotListConfiguration) case sss(SSSCopilotConfiguration) var params: String? { switch self { case .regular(let config): return try? config.jsonString() + case .regularList(let config): + return try? config.jsonString() case .sss(let config): return try? config.jsonString() } } + + func applyingCommonOptions(formation: Bool, addTrust: Bool) -> Self { + switch self { + case .regular(var config): + config.formation = formation + config.add_trust = addTrust + return .regular(config) + case .regularList(var config): + config.formation = formation + config.add_trust = addTrust + return .regularList(config) + case .sss: + return self + } + } } diff --git a/MeoAsstMac/Views/CopilotView.swift b/MeoAsstMac/Views/CopilotView.swift index b00662e38..323ccdaa5 100644 --- a/MeoAsstMac/Views/CopilotView.swift +++ b/MeoAsstMac/Views/CopilotView.swift @@ -45,16 +45,8 @@ struct CopilotView: View { @ViewBuilder private func pilotConfiguration() -> some View { switch viewModel.copilot { - case .regular(let innerConfig): - let binding = Binding { - innerConfig - } set: { newValue in - viewModel.copilot = .regular(newValue) - } - HStack { - Toggle("自动编队", isOn: binding.formation) - Toggle("信赖干员", isOn: binding.add_trust) - } + case .regular, .regularList: + EmptyView() case .sss(let innerConfig): let binding = Binding {