From ba54db52e52812fcf49aeb9660d2503845de69c4 Mon Sep 17 00:00:00 2001 From: William Date: Tue, 30 Jun 2026 11:45:42 +0600 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98=E6=9C=BA=E5=88=B6=EF=BC=8C?= =?UTF-8?q?=E6=90=9C=E7=B4=A2/=E9=87=8D=E7=BD=AE/=E6=8D=A2=E9=A1=B5?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B0=83=E7=94=A8=20API=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E9=97=B4=E6=AD=87=E6=80=A7=E7=AD=BE=E5=90=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VODManager/Views/VideoListView.swift | 180 ++++++++++++++---- 1 file changed, 141 insertions(+), 39 deletions(-) diff --git a/apps/macos/VODManager/Sources/VODManager/Views/VideoListView.swift b/apps/macos/VODManager/Sources/VODManager/Views/VideoListView.swift index 18694cc..960b226 100644 --- a/apps/macos/VODManager/Sources/VODManager/Views/VideoListView.swift +++ b/apps/macos/VODManager/Sources/VODManager/Views/VideoListView.swift @@ -1,48 +1,63 @@ import SwiftUI struct VideoListView: View { - @State private var videos: [VideoItem] = [] + @State private var allVideos: [VideoItem] = [] + @State private var filteredVideos: [VideoItem] = [] + @State private var displayVideos: [VideoItem] = [] @State private var selectedVideos: Set = [] @State private var keyword: String = "" @State private var isLoading = false - @State private var total = 0 - @State private var pageNo = 1 - @State private var pageSize = 10 + @State private var loadingStatus = "" @State private var errorMessage: String? @State private var showDownloadPanel = false @State private var downloadResults: [(video: VideoItem, playInfos: [PlayInfo])] = [] + private let pageSizes = [20, 50, 100] + @State private var pageSizeIndex = 0 + private var pageSize: Int { pageSizes[pageSizeIndex] } + @State private var pageNo = 1 + private var totalPages: Int { max(1, (filteredVideos.count + pageSize - 1) / pageSize) } + var body: some View { VStack(spacing: 0) { HStack(spacing: 12) { - TextField("搜索标题 / 视频 ID", text: $keyword) + TextField("搜索本地缓存标题", text: $keyword) .textFieldStyle(.roundedBorder) .frame(minWidth: 200) + .onChange(of: keyword) { _, _ in + applyFilter() + } Button("搜索") { - pageNo = 1 - Task { await fetch() } + applyFilter() } .keyboardShortcut(.return, modifiers: [.command]) Button("重置") { keyword = "" - pageNo = 1 - Task { await fetch() } + applyFilter() } Spacer() + Button { + Task { await refreshFromAPI() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(isLoading) + .help("从服务器刷新") + Button("批量下载") { Task { await prepareDownload() } } .disabled(selectedVideos.isEmpty || isLoading) Button("全选") { - if selectedVideos.count == videos.count { + if selectedVideos.count == displayVideos.count { selectedVideos.removeAll() } else { - selectedVideos = Set(videos.map(\.videoId)) + selectedVideos = Set(displayVideos.map(\.videoId)) } } @@ -72,6 +87,18 @@ struct VideoListView: View { .padding(.horizontal) } + if isLoading && !loadingStatus.isEmpty { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(loadingStatus) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 6) + } + Table(of: VideoItem.self, selection: $selectedVideos) { TableColumn("") { video in Image(systemName: selectedVideos.contains(video.videoId) ? "checkmark.circle.fill" : "circle") @@ -129,51 +156,103 @@ struct VideoListView: View { } .width(min: 120, ideal: 160) } rows: { - ForEach(videos) { video in + ForEach(displayVideos) { video in TableRow(video) } } .frame(minHeight: 300) HStack { - Text("共 \(total) 条,已选 \(selectedVideos.count) 个") + Text("共 \(filteredVideos.count) 条,已选 \(selectedVideos.count) 个") .foregroundStyle(.secondary) Spacer() + + Picker("每页", selection: $pageSizeIndex) { + ForEach(0..= total) + .disabled(pageNo >= totalPages) } .padding() } .task { - await fetch() + if allVideos.isEmpty { + await refreshFromAPI() + } } .sheet(isPresented: $showDownloadPanel) { DownloadPanelView(items: downloadResults) } } - private func fetch() async { + private func applyFilter() { + let kw = keyword.trimmingCharacters(in: .whitespaces) + if kw.isEmpty { + filteredVideos = allVideos + } else { + filteredVideos = allVideos.filter { + $0.title.localizedCaseInsensitiveContains(kw) || + $0.videoId.localizedCaseInsensitiveContains(kw) + } + } + pageNo = 1 + selectedVideos.removeAll() + updateDisplay() + } + + private func updateDisplay() { + let start = (pageNo - 1) * pageSize + let end = min(start + pageSize, filteredVideos.count) + if start < filteredVideos.count { + displayVideos = Array(filteredVideos[start..= total || videos.isEmpty { + break + } + page += 1 + } + allVideos = collected + applyFilter() + loadingStatus = "" } catch { errorMessage = "加载失败:\(error.localizedDescription)" } @@ -185,7 +264,8 @@ struct VideoListView: View { do { try await VODClient.shared.deleteVideos(videoIds: ids) selectedVideos.removeAll() - await fetch() + allVideos.removeAll { ids.contains($0.videoId) } + applyFilter() } catch { errorMessage = "删除失败:\(error.localizedDescription)" } @@ -194,20 +274,19 @@ struct VideoListView: View { private func prepareDownload() async { isLoading = true errorMessage = nil + loadingStatus = "" var results: [(video: VideoItem, playInfos: [PlayInfo])] = [] var errors: [String] = [] - for video in videos where selectedVideos.contains(video.videoId) { - do { - let response = try await VODClient.shared.getPlayInfo(videoId: video.videoId) - results.append((video, response.playInfos)) - if response.playInfos.isEmpty { - errors.append("「\(video.title)」无可用播放地址") - } - } catch { - results.append((video, [])) - errors.append("「\(video.title)」获取播放信息失败:\(error.localizedDescription)") + let selected = displayVideos.filter { selectedVideos.contains($0.videoId) } + for (index, video) in selected.enumerated() { + loadingStatus = "正在获取下载地址 (\(index + 1)/\(selected.count))「\(video.title)」..." + let playInfos = await fetchPlayInfoWithRetry(videoId: video.videoId, title: video.title, maxRetries: 3) + results.append((video, playInfos)) + if playInfos.isEmpty { + errors.append("「\(video.title)」无可用播放地址") } } + loadingStatus = "" if !errors.isEmpty { errorMessage = errors.joined(separator: "\n") } @@ -216,6 +295,29 @@ struct VideoListView: View { isLoading = false } + private func fetchPlayInfoWithRetry(videoId: String, title: String, maxRetries: Int) async -> [PlayInfo] { + for attempt in 1...maxRetries { + do { + let response = try await VODClient.shared.getPlayInfo(videoId: videoId) + if !response.playInfos.isEmpty { + return response.playInfos + } + if attempt < maxRetries { + print("[Download]「\(title)」第 \(attempt) 次获取到空播放地址,\(attempt)s 后重试...") + try? await Task.sleep(for: .seconds(attempt)) + } + } catch { + if attempt < maxRetries { + print("[Download]「\(title)」第 \(attempt) 次获取失败: \(error),\(attempt)s 后重试...") + try? await Task.sleep(for: .seconds(attempt)) + } else { + print("[Download]「\(title)」\(maxRetries) 次尝试均失败: \(error)") + } + } + } + return [] + } + private func formatDuration(_ seconds: Double?) -> String { guard let seconds = seconds else { return "-" } let m = Int(seconds) / 60