From 99a8cc799b4e7e7f1413912b773ec34eda970454 Mon Sep 17 00:00:00 2001 From: JinJiangHuang Date: Sun, 1 Mar 2026 21:43:02 +0800 Subject: [PATCH] fix bug --- tvbox/Models/AppConfig.swift | 28 ++ tvbox/Services/ApiConfig.swift | 395 ++++++++++++++++++++++- tvbox/Services/NetworkManager.swift | 67 +++- tvbox/ViewModels/SettingsViewModel.swift | 92 ++++++ tvbox/Views/ContentView.swift | 25 ++ tvbox/Views/Settings/SettingsView.swift | 26 ++ 6 files changed, 622 insertions(+), 11 deletions(-) diff --git a/tvbox/Models/AppConfig.swift b/tvbox/Models/AppConfig.swift index 02200f8..ad8482d 100644 --- a/tvbox/Models/AppConfig.swift +++ b/tvbox/Models/AppConfig.swift @@ -214,3 +214,31 @@ struct AppConfigData: Codable { var script: [String]? } } + +extension AppConfigData { + /// 是否包含可直接加载的核心内容 + /// 说明:AppConfigData 字段几乎全是可选,任意 JSON 都可能“解码成功”, + /// 因此需要额外判定,避免把多仓库索引误当成正常配置。 + var hasUsableContent: Bool { + let hasSites = !(sites?.isEmpty ?? true) + let hasLives = !(lives?.isEmpty ?? true) + let hasParses = !(parses?.isEmpty ?? true) + return hasSites || hasLives || hasParses + } +} + +/// 多仓库合并索引配置(如 tvboxmulti.json) +struct MultiRepoConfigData: Codable { + var urls: [Entry]? + + struct Entry: Codable { + var name: String? + var url: String? + } + + var candidateUrls: [String] { + (urls ?? []) + .compactMap { $0.url?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } +} diff --git a/tvbox/Services/ApiConfig.swift b/tvbox/Services/ApiConfig.swift index 64eb812..2abf198 100644 --- a/tvbox/Services/ApiConfig.swift +++ b/tvbox/Services/ApiConfig.swift @@ -5,6 +5,15 @@ import Foundation @MainActor class ApiConfig: ObservableObject { static let shared = ApiConfig() + private static let maxConfigResolveDepth = 6 + private static let maxRedirectCandidates = 20 + + struct MultiRepoOption: Identifiable, Equatable { + let name: String + let url: String + + var id: String { url.lowercased() } + } @Published var sourceBeanList: [SourceBean] = [] @Published var homeSourceBean: SourceBean? @@ -38,21 +47,65 @@ class ApiConfig: ObservableObject { self.liveConfigUrl = resolvedLive if trimmedVod == resolvedLive { - let config = try await fetchConfig(from: trimmedVod) - parseConfig(config, apiUrl: trimmedVod, includeSources: true, includeLive: true) + let configResult = try await fetchConfig(from: trimmedVod) + parseConfig( + configResult.config, + apiUrl: configResult.loadedFrom, + includeSources: true, + includeLive: true + ) } else { let vodConfig = try await fetchConfig(from: trimmedVod) - parseConfig(vodConfig, apiUrl: trimmedVod, includeSources: true, includeLive: false) + parseConfig( + vodConfig.config, + apiUrl: vodConfig.loadedFrom, + includeSources: true, + includeLive: false + ) let liveConfig = try await fetchConfig(from: resolvedLive) - parseConfig(liveConfig, apiUrl: resolvedLive, includeSources: false, includeLive: true) + parseConfig( + liveConfig.config, + apiUrl: liveConfig.loadedFrom, + includeSources: false, + includeLive: true + ) } self.isLoaded = true } - private func fetchConfig(from apiUrl: String) async throws -> AppConfigData { - let jsonStr = try await network.getString(from: apiUrl) + private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) { + try await fetchConfig( + from: apiUrl, + visitedUrls: Set(), + depth: 0 + ) + } + + private func fetchConfig( + from apiUrl: String, + visitedUrls: Set, + depth: Int + ) async throws -> (config: AppConfigData, loadedFrom: String) { + guard depth <= Self.maxConfigResolveDepth else { + throw ConfigError.parseError("配置跳转层级过深(超过 \(Self.maxConfigResolveDepth) 层)") + } + + let normalizedUrl = Self.normalizeConfigUrl(apiUrl) + guard !normalizedUrl.isEmpty else { + throw ConfigError.parseError("配置地址为空") + } + + let visitKey = normalizedUrl.lowercased() + guard !visitedUrls.contains(visitKey) else { + throw ConfigError.parseError("检测到循环引用的配置地址: \(normalizedUrl)") + } + + var nextVisited = visitedUrls + nextVisited.insert(visitKey) + + let jsonStr = try await network.getString(from: normalizedUrl) // 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理) let cleanedJson = Self.stripJsonComments(jsonStr) @@ -61,7 +114,283 @@ class ApiConfig: ObservableObject { throw ConfigError.parseError("无法解析配置数据") } - return try JSONDecoder().decode(AppConfigData.self, from: data) + let decoder = JSONDecoder() + let decodedConfig = try? decoder.decode(AppConfigData.self, from: data) + if let config = decodedConfig, config.hasUsableContent { + return (config, normalizedUrl) + } + + if let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) { + let candidateUrls = Self.uniqueUrlsInOrder( + multiRepo.candidateUrls.map(Self.normalizeConfigUrl) + ) + + var lastError: Error? + for candidateUrl in candidateUrls { + do { + return try await fetchConfig( + from: candidateUrl, + visitedUrls: nextVisited, + depth: depth + 1 + ) + } catch { + lastError = error + } + } + + if let lastError { + throw ConfigError.parseError("多仓库配置中没有可用地址,最后错误: \(lastError.localizedDescription)") + } + throw ConfigError.parseError("多仓库配置中没有可用地址") + } + + let redirectCandidates = Self.extractConfigRedirectCandidates(from: cleanedJson) + .filter { $0.lowercased() != visitKey && !nextVisited.contains($0.lowercased()) } + + if !redirectCandidates.isEmpty { + var lastError: Error? + for candidate in redirectCandidates { + do { + return try await fetchConfig( + from: candidate, + visitedUrls: nextVisited, + depth: depth + 1 + ) + } catch { + lastError = error + } + } + + if let lastError { + throw ConfigError.parseError("页面跳转配置解析失败,最后错误: \(lastError.localizedDescription)") + } + } + + if decodedConfig != nil { + throw ConfigError.parseError("配置缺少可用站点(sites / lives / parses)") + } + + throw ConfigError.parseError("配置格式不受支持") + } + + private static func uniqueUrlsInOrder(_ urls: [String]) -> [String] { + var seen: Set = [] + var result: [String] = [] + + for url in urls { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let key = trimmed.lowercased() + guard !seen.contains(key) else { continue } + seen.insert(key) + result.append(trimmed) + } + + return result + } + + /// 从网页/文本中提取可能的配置地址: + /// - 明文 URL 文本 + /// - data-clipboard-text(常见导航页“点击复制”) + /// - JSON 片段中的 url 字段 + private static func extractConfigRedirectCandidates(from content: String) -> [String] { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + var rawCandidates: [String] = [] + + if (trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://")) && !trimmed.contains("\n") { + rawCandidates.append(trimmed) + } + + rawCandidates.append(contentsOf: matchCaptureGroup( + pattern: #"data-clipboard-text\s*=\s*["']([^"']+)["']"#, + in: content + )) + rawCandidates.append(contentsOf: matchCaptureGroup( + pattern: #""url"\s*:\s*"([^"]+)""#, + in: content + )) + rawCandidates.append(contentsOf: matchCaptureGroup( + pattern: #"(https?://[^\s"'<>\\]+)"#, + in: content + )) + + let normalized = rawCandidates + .map(sanitizeExtractedUrl) + .map(normalizeConfigUrl) + .filter { !$0.isEmpty } + .filter(isLikelyConfigPointerUrl) + .filter { !isLikelyBinaryAssetUrl($0) } + + return Array(uniqueUrlsInOrder(normalized).prefix(maxRedirectCandidates)) + } + + private static func matchCaptureGroup(pattern: String, in content: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return [] + } + + let range = NSRange(content.startIndex..= 2, + let subRange = Range(match.range(at: 1), in: content) else { + return nil + } + return String(content[subRange]) + } + } + + private static func sanitizeExtractedUrl(_ value: String) -> String { + var result = value.trimmingCharacters(in: .whitespacesAndNewlines) + result = result.replacingOccurrences(of: "&", with: "&") + result = result.replacingOccurrences(of: "\\/", with: "/") + + while let last = result.last, [".", ",", ";", ")", "]", "}", "\"", "'"].contains(last) { + result.removeLast() + } + while let first = result.first, ["\"", "'", "(", "[", "{"].contains(first) { + result.removeFirst() + } + + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func isLikelyBinaryAssetUrl(_ urlString: String) -> Bool { + guard let components = URLComponents(string: urlString) else { return false } + let path = components.path.lowercased() + return path.hasSuffix(".png") + || path.hasSuffix(".jpg") + || path.hasSuffix(".jpeg") + || path.hasSuffix(".webp") + || path.hasSuffix(".gif") + || path.hasSuffix(".svg") + || path.hasSuffix(".ico") + || path.hasSuffix(".css") + || path.hasSuffix(".woff") + || path.hasSuffix(".woff2") + || path.hasSuffix(".ttf") + } + + private static func isLikelyConfigPointerUrl(_ urlString: String) -> Bool { + guard let components = URLComponents(string: urlString) else { return false } + + let host = (components.host ?? "").lowercased() + let path = components.path.lowercased() + let query = (components.percentEncodedQuery ?? "").lowercased() + + if host.contains("raw.githubusercontent.com") || host.contains("githubusercontent.com") { + return true + } + + if path.contains(".json") + || path.hasSuffix("/tv") + || path.hasSuffix("/tv/") + || path.hasSuffix("/m") + || path.hasSuffix("/m/") + || path.contains("tvbox") + || path.contains("box") + || query.contains("json") + || query.contains("config") + || query.contains("url=") { + return true + } + + return false + } + + /// 统一规范配置 URL: + /// 1) 修正 https:/xxx 之类的单斜杠写法; + /// 2) 将 github.com/.../blob/... 转为 raw.githubusercontent.com/...; + /// 3) 兼容 gh-proxy + github/blob 的嵌套代理地址。 + static func normalizeConfigUrl(_ rawUrl: String) -> String { + let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + + let fixedScheme = fixMalformedSchemeIfNeeded(trimmed) + + if let normalizedProxy = normalizeGhProxyWrappedUrl(fixedScheme) { + return normalizedProxy + } + + if let githubRaw = convertGitHubBlobUrlToRaw(fixedScheme) { + return githubRaw + } + + return fixedScheme + } + + private static func fixMalformedSchemeIfNeeded(_ urlString: String) -> String { + let fixedHttps = urlString.replacingOccurrences( + of: #"^https:/(?!/)"#, + with: "https://", + options: .regularExpression + ) + return fixedHttps.replacingOccurrences( + of: #"^http:/(?!/)"#, + with: "http://", + options: .regularExpression + ) + } + + private static func normalizeGhProxyWrappedUrl(_ urlString: String) -> String? { + guard let components = URLComponents(string: urlString), + let host = components.host?.lowercased(), + host.contains("gh-proxy") else { + return nil + } + + let path = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !path.isEmpty else { return nil } + + let decodedPath = path.removingPercentEncoding ?? path + let fixedEmbedded = fixMalformedSchemeIfNeeded(decodedPath) + guard fixedEmbedded.hasPrefix("http://") || fixedEmbedded.hasPrefix("https://") else { + return nil + } + + let normalizedEmbedded = convertGitHubBlobUrlToRaw(fixedEmbedded) ?? fixedEmbedded + let scheme = components.scheme ?? "https" + let portSuffix = components.port.map { ":\($0)" } ?? "" + + var rebuilt = "\(scheme)://\(host)\(portSuffix)/\(normalizedEmbedded)" + if let query = components.percentEncodedQuery, !query.isEmpty { + rebuilt += "?\(query)" + } + if let fragment = components.percentEncodedFragment, !fragment.isEmpty { + rebuilt += "#\(fragment)" + } + return rebuilt + } + + private static func convertGitHubBlobUrlToRaw(_ urlString: String) -> String? { + guard let components = URLComponents(string: urlString), + let host = components.host?.lowercased(), + host == "github.com" || host == "www.github.com" else { + return nil + } + + let parts = components.path.split(separator: "/", omittingEmptySubsequences: true) + guard parts.count >= 5, parts[2] == "blob" else { + return nil + } + + let owner = String(parts[0]) + let repo = String(parts[1]) + let branch = String(parts[3]) + let filePath = parts.dropFirst(4).joined(separator: "/") + guard !filePath.isEmpty else { + return nil + } + + var rawUrl = "https://raw.githubusercontent.com/\(owner)/\(repo)/\(branch)/\(filePath)" + if let query = components.percentEncodedQuery, !query.isEmpty { + rawUrl += "?\(query)" + } + if let fragment = components.percentEncodedFragment, !fragment.isEmpty { + rawUrl += "#\(fragment)" + } + return rawUrl } /// 去除 JSON 中的 // 行注释,兼容 TVBox 配置文件格式 @@ -124,6 +453,54 @@ class ApiConfig: ObservableObject { return line } + /// 仅探测“多仓库入口”并返回可选项。 + /// 返回 nil 表示不是多仓库入口;返回数组表示是多仓库入口(数组可能为空)。 + func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? { + let normalizedUrl = Self.normalizeConfigUrl(apiUrl) + let jsonStr = try await network.getString(from: normalizedUrl) + let cleanedJson = Self.stripJsonComments(jsonStr) + + guard let data = cleanedJson.data(using: .utf8) else { + throw ConfigError.parseError("无法解析配置数据") + } + + let decoder = JSONDecoder() + if let config = try? decoder.decode(AppConfigData.self, from: data), config.hasUsableContent { + return nil + } + + guard let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) else { + return nil + } + + let normalizedCandidates = Self.uniqueUrlsInOrder( + multiRepo.candidateUrls.map(Self.normalizeConfigUrl) + ) + + var options: [MultiRepoOption] = [] + for candidate in normalizedCandidates { + let matchedEntry = multiRepo.urls?.first(where: { + Self.normalizeConfigUrl($0.url ?? "") == candidate + }) + let displayName = matchedEntry?.name?.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackName = URL(string: candidate)?.host ?? candidate + let resolvedName: String + if let displayName, !displayName.isEmpty { + resolvedName = displayName + } else { + resolvedName = fallbackName + } + options.append( + MultiRepoOption( + name: resolvedName, + url: candidate + ) + ) + } + + return options + } + /// 解析配置数据 private func parseConfig( _ config: AppConfigData, @@ -389,13 +766,13 @@ class ApiConfig: ObservableObject { let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return trimmed } if let url = URL(string: trimmed), url.scheme != nil { - return trimmed + return Self.normalizeConfigUrl(trimmed) } guard let baseUrl = URL(string: baseConfigUrl), let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else { return trimmed } - return resolved.absoluteString + return Self.normalizeConfigUrl(resolved.absoluteString) } private static func uniqueLiveUrls(_ urls: [String]) -> [String] { diff --git a/tvbox/Services/NetworkManager.swift b/tvbox/Services/NetworkManager.swift index fc6abb6..861f8be 100644 --- a/tvbox/Services/NetworkManager.swift +++ b/tvbox/Services/NetworkManager.swift @@ -4,6 +4,31 @@ import Foundation class NetworkManager { static let shared = NetworkManager() + private static let fallbackCharsetNames: [String] = [ + "utf-8", + "gb18030", + "gbk", + "gb2312", + "utf-16", + "utf-16le", + "utf-16be", + "utf-32", + "windows-1252", + "iso-8859-1" + ] + + private static let fallbackStringEncodings: [String.Encoding] = [ + .utf8, + .utf16, + .utf16LittleEndian, + .utf16BigEndian, + .utf32, + .utf32LittleEndian, + .utf32BigEndian, + .windowsCP1252, + .isoLatin1 + ] + private let session: URLSession private let decoder = JSONDecoder() @@ -35,8 +60,8 @@ class NetworkManager { throw NetworkError.httpError(httpResponse.statusCode) } - guard let str = String(data: data, encoding: .utf8) else { - throw NetworkError.decodingError("UTF-8 解码失败") + guard let str = Self.decodeString(data: data, response: httpResponse) else { + throw NetworkError.decodingError("文本解码失败") } return str @@ -59,6 +84,44 @@ class NetworkManager { let (data, _) = try await session.data(from: url) return data } + + private static func decodeString(data: Data, response: HTTPURLResponse) -> String? { + // 优先使用服务端声明的字符集(如 gbk / gb2312 / gb18030) + if let charset = response.textEncodingName, + let declaredEncoding = encoding(fromIANACharset: charset), + let value = String(data: data, encoding: declaredEncoding) { + return value + } + + for charset in fallbackCharsetNames { + if let encoding = encoding(fromIANACharset: charset), + let value = String(data: data, encoding: encoding) { + return value + } + } + + for encoding in fallbackStringEncodings { + if let value = String(data: data, encoding: encoding) { + return value + } + } + + // 最后兜底,避免单纯因编码不一致直接报错 + if !data.isEmpty { + return String(decoding: data, as: UTF8.self) + } + + return nil + } + + private static func encoding(fromIANACharset charset: String) -> String.Encoding? { + let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString) + guard cfEncoding != kCFStringEncodingInvalidId else { + return nil + } + let nsEncoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding) + return String.Encoding(rawValue: nsEncoding) + } } enum NetworkError: LocalizedError { diff --git a/tvbox/ViewModels/SettingsViewModel.swift b/tvbox/ViewModels/SettingsViewModel.swift index 62f2aa1..b89588e 100644 --- a/tvbox/ViewModels/SettingsViewModel.swift +++ b/tvbox/ViewModels/SettingsViewModel.swift @@ -4,11 +4,31 @@ import SwiftUI /// 设置 ViewModel @MainActor class SettingsViewModel: ObservableObject { + struct PendingMultiRepoSelection: Identifiable { + enum Target { + case vod + case live + + var title: String { + switch self { + case .vod: return "点播" + case .live: return "直播" + } + } + } + + let id = UUID() + let target: Target + let sourceUrl: String + let options: [ApiConfig.MultiRepoOption] + } + @Published var vodApiUrl: String = "" @Published var liveApiUrl: String = "" @Published var isLoadingConfig = false @Published var configError: String? @Published var configSuccess = false + @Published var pendingMultiRepoSelection: PendingMultiRepoSelection? @Published var apiHistory: [String] = [] @Published var vodPlayerEngine: PlayerEngine = .system @Published var livePlayerEngine: PlayerEngine = .system @@ -74,9 +94,20 @@ class SettingsViewModel: ObservableObject { isLoadingConfig = true configError = nil configSuccess = false + pendingMultiRepoSelection = nil do { let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive + + if let pending = try await detectPendingMultiRepoSelection( + vodUrl: trimmedVod, + liveUrl: resolvedLive + ) { + pendingMultiRepoSelection = pending + isLoadingConfig = false + return + } + try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive) UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL) UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL) @@ -92,6 +123,67 @@ class SettingsViewModel: ObservableObject { isLoadingConfig = false } + func selectPendingMultiRepoOption(_ option: ApiConfig.MultiRepoOption) async { + guard let pending = pendingMultiRepoSelection else { return } + let normalizedSource = ApiConfig.normalizeConfigUrl(pending.sourceUrl) + + switch pending.target { + case .vod: + let normalizedLive = ApiConfig.normalizeConfigUrl(liveApiUrl) + let shouldSyncLive = !liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && normalizedLive == normalizedSource + vodApiUrl = option.url + if shouldSyncLive { + liveApiUrl = option.url + } + case .live: + liveApiUrl = option.url + } + + pendingMultiRepoSelection = nil + await loadConfig() + } + + func cancelPendingMultiRepoSelection() { + pendingMultiRepoSelection = nil + isLoadingConfig = false + } + + private func detectPendingMultiRepoSelection( + vodUrl: String, + liveUrl: String + ) async throws -> PendingMultiRepoSelection? { + if let vodOptions = try await ApiConfig.shared.fetchMultiRepoOptions(from: vodUrl) { + guard !vodOptions.isEmpty else { + throw ConfigError.parseError("点播多仓库配置中没有可用地址") + } + return PendingMultiRepoSelection( + target: .vod, + sourceUrl: vodUrl, + options: vodOptions + ) + } + + let normalizedVod = ApiConfig.normalizeConfigUrl(vodUrl) + let normalizedLive = ApiConfig.normalizeConfigUrl(liveUrl) + guard normalizedLive != normalizedVod else { + return nil + } + + if let liveOptions = try await ApiConfig.shared.fetchMultiRepoOptions(from: liveUrl) { + guard !liveOptions.isEmpty else { + throw ConfigError.parseError("直播多仓库配置中没有可用地址") + } + return PendingMultiRepoSelection( + target: .live, + sourceUrl: liveUrl, + options: liveOptions + ) + } + + return nil + } + // MARK: - API 历史 private func loadApiHistory() { diff --git a/tvbox/Views/ContentView.swift b/tvbox/Views/ContentView.swift index 86b5f4f..315d7ee 100644 --- a/tvbox/Views/ContentView.swift +++ b/tvbox/Views/ContentView.swift @@ -21,6 +21,7 @@ struct ContentView: View { setupView } } + .overlay(multiRepoSelectionOverlay) .preferredColorScheme(.dark) .onAppear { // 自动加载已保存的配置 @@ -35,6 +36,30 @@ struct ContentView: View { } } + @ViewBuilder + private var multiRepoSelectionOverlay: some View { + if let pending = settingsVM.pendingMultiRepoSelection { + SelectionModal( + title: "选择\(pending.target.title)仓库", + icon: "list.bullet.rectangle.portrait.fill", + items: pending.options, + selectedItem: nil, + itemTitle: { $0.name }, + onSelect: { option in + Task { + await settingsVM.selectPendingMultiRepoOption(option) + if settingsVM.configSuccess { + appState.applyLoadedConfigState() + } + } + }, + onCancel: { + settingsVM.cancelPendingMultiRepoSelection() + } + ) + } + } + // MARK: - 主界面 private var mainTabView: some View { diff --git a/tvbox/Views/Settings/SettingsView.swift b/tvbox/Views/Settings/SettingsView.swift index cee25b3..40c9598 100644 --- a/tvbox/Views/Settings/SettingsView.swift +++ b/tvbox/Views/Settings/SettingsView.swift @@ -335,11 +335,37 @@ struct SettingsView: View { } } } + .overlay(multiRepoSelectionOverlay) #if os(iOS) .presentationDetents([.medium, .large]) #endif } + @ViewBuilder + private var multiRepoSelectionOverlay: some View { + if let pending = viewModel.pendingMultiRepoSelection { + SelectionModal( + title: "选择\(pending.target.title)仓库", + icon: "list.bullet.rectangle.portrait.fill", + items: pending.options, + selectedItem: nil, + itemTitle: { $0.name }, + onSelect: { option in + Task { + await viewModel.selectPendingMultiRepoOption(option) + if viewModel.configSuccess { + appState.applyLoadedConfigState() + showApiInput = false + } + } + }, + onCancel: { + viewModel.cancelPendingMultiRepoSelection() + } + ) + } + } + private var currentApiBinding: Binding { switch editingApiType { case .vod: