import AppKit import AVKit import CoreImage.CIFilterBuiltins import SwiftUI import UniformTypeIdentifiers import WebKit struct ContentView: View { @StateObject private var model = AppModel() var body: some View { Group { if model.isSignedIn { WorkspaceView(model: model) } else { LoginView(model: model) } } .alert("请求失败", isPresented: Binding(get: { !model.errorMessage.isEmpty }, set: { if !$0 { model.errorMessage = "" } })) { Button("确定", role: .cancel) { model.errorMessage = "" } } message: { Text(model.errorMessage) } } } private func proxyConfigurationIsValid(host: String, port: String) -> Bool { let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedPort = port.trimmingCharacters(in: .whitespacesAndNewlines) if normalizedHost.isEmpty && normalizedPort.isEmpty { return true } guard !normalizedHost.isEmpty, let value = Int(normalizedPort) else { return false } return (1...65535).contains(value) } private struct LoginView: View { @ObservedObject var model: AppModel let autoStartQR: Bool @State private var mode = 0 init(model: AppModel, autoStartQR: Bool = true) { self.model = model self.autoStartQR = autoStartQR } var body: some View { ZStack { AppBackdrop() HStack(spacing: 0) { LoginBrandPanel() LoginFormPanel(model: model, mode: $mode) } .frame(width: 920, height: 610) .clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 34, style: .continuous) .stroke(.white.opacity(0.52), lineWidth: 1) } .shadow(color: .black.opacity(0.22), radius: 42, y: 22) .padding(30) } .frame(minWidth: 980, minHeight: 680) .task { guard autoStartQR, model.qrPayload.isEmpty, !model.isBusy else { return } await model.startQRLogin() } } } private struct LoginBrandPanel: View { var body: some View { ZStack(alignment: .topLeading) { LinearGradient(colors: [AppTheme.orange, AppTheme.orangeDeep, Color(red: 0.45, green: 0.08, blue: 0.03)], startPoint: .topLeading, endPoint: .bottomTrailing) Circle().fill(.white.opacity(0.16)).frame(width: 260, height: 260).blur(radius: 10).offset(x: -95, y: -80) Circle().fill(.yellow.opacity(0.18)).frame(width: 210, height: 210).blur(radius: 16).offset(x: 230, y: 390) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 13) { BrandMark(size: 50) .padding(8) .background(.white.opacity(0.16), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) VStack(alignment: .leading, spacing: 3) { Text("光鸭云盘").font(.title3.weight(.bold)) Text("GUANGYA CLOUD").font(.system(size: 9, weight: .bold, design: .rounded)).tracking(1.6).foregroundStyle(.white.opacity(0.66)) } } Spacer() Text("文件,") Text("在你身边。") Text("轻松管理每一份重要内容。") .font(.title3.weight(.medium)) .foregroundStyle(.white.opacity(0.78)) .padding(.top, 16) VStack(alignment: .leading, spacing: 15) { BrandFeature(icon: "square.stack.3d.up.fill", title: "云端文件集中管理") BrandFeature(icon: "arrow.triangle.2.circlepath", title: "多端同步,随时访问") BrandFeature(icon: "lock.shield.fill", title: "安全连接,安心使用") } .padding(.top, 40) Spacer() HStack(spacing: 8) { Circle().fill(Color.green).frame(width: 8, height: 8).shadow(color: .green.opacity(0.6), radius: 6) Text("服务正常运行").font(.caption.weight(.medium)).foregroundStyle(.white.opacity(0.74)) } .padding(.horizontal, 12) .padding(.vertical, 9) .background(.white.opacity(0.12), in: Capsule()) } .font(.system(size: 44, weight: .black, design: .rounded)) .foregroundStyle(.white) .padding(42) } .frame(width: 380) .frame(maxHeight: .infinity, alignment: .leading) } } private struct BrandFeature: View { let icon: String let title: String var body: some View { HStack(spacing: 12) { Image(systemName: icon) .font(.callout.weight(.semibold)) .frame(width: 30, height: 30) .background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) Text(title).font(.callout.weight(.medium)).foregroundStyle(.white.opacity(0.88)) } } } private struct LoginFormPanel: View { @ObservedObject var model: AppModel @Binding var mode: Int var body: some View { VStack(spacing: 0) { Spacer() VStack(alignment: .leading, spacing: 22) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 7) { Text("欢迎回来").font(.system(size: 31, weight: .bold, design: .rounded)) Text("登录后开始管理你的云端文件").font(.callout).foregroundStyle(.secondary) } Spacer() Image(systemName: "person.crop.circle.badge.checkmark") .font(.title2) .foregroundStyle(Color.orange) } HStack(spacing: 4) { loginModeButton(title: "扫码登录", icon: "qrcode", tag: 0) loginModeButton(title: "手机号", icon: "iphone", tag: 1) } .padding(4) .background(Color.black.opacity(0.055), in: Capsule()) Group { if mode == 0 { QRLoginPanel(model: model) } else { SMSLoginPanel(model: model) } } .frame(maxWidth: .infinity) } .padding(34) .frame(width: 452) .softCard(radius: 24) Spacer() HStack(spacing: 5) { Image(systemName: "checkmark.seal.fill") Text("光鸭云盘安全登录") } .font(.caption) .foregroundStyle(.tertiary) .padding(.bottom, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.clear) } private func loginModeButton(title: String, icon: String, tag: Int) -> some View { Button { withAnimation(.easeInOut(duration: 0.18)) { mode = tag } } label: { Label(title, systemImage: icon) .font(.callout.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 9) .foregroundStyle(mode == tag ? Color.primary : Color.secondary) .background(mode == tag ? Color(nsColor: .windowBackgroundColor) : Color.clear, in: Capsule()) .shadow(color: mode == tag ? .black.opacity(0.08) : .clear, radius: 3, y: 1) } .buttonStyle(.plain) } } private struct QRLoginPanel: View { @ObservedObject var model: AppModel var body: some View { VStack(spacing: 16) { ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.orange.opacity(0.08)) .frame(width: 232, height: 232) if model.qrPayload.isEmpty { VStack(spacing: 13) { Image(systemName: "qrcode.viewfinder") .font(.system(size: 58, weight: .light)) .foregroundStyle(Color.orange.opacity(0.78)) Text("准备登录") .font(.callout.weight(.medium)) .foregroundStyle(.secondary) } } else { QRCodeImage(payload: model.qrPayload) .frame(width: 190, height: 190) .padding(14) .background(.white) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 15, y: 7) } } HStack(spacing: 8) { Circle().fill(model.qrPayload.isEmpty ? Color.secondary : Color.green).frame(width: 7, height: 7) Text(model.qrStatus).font(.callout).foregroundStyle(.secondary) } HStack(spacing: 8) { Text("请使用光鸭 App 扫码登录") .font(.caption) .foregroundStyle(.tertiary) Spacer() Button { Task { await model.startQRLogin() } } label: { if model.isBusy { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") } } .buttonStyle(.bordered) .controlSize(.small) .help("刷新二维码") .disabled(model.isBusy) } Text("二维码会在打开登录页时自动生成") .font(.caption) .foregroundStyle(.tertiary) } } } private struct SMSLoginPanel: View { @ObservedObject var model: AppModel @FocusState private var focusedField: Field? private enum Field { case phone, code } var body: some View { VStack(alignment: .leading, spacing: 16) { LoginFieldLabel(title: "手机号", icon: "phone.fill") TextField("+86 13800138000", text: $model.phoneNumber) .textFieldStyle(.plain) .focused($focusedField, equals: .phone) .padding(.horizontal, 13) .frame(height: 40) .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 8).stroke(focusedField == .phone ? Color.orange : Color.black.opacity(0.12), lineWidth: 1) } .textContentType(.telephoneNumber) LoginFieldLabel(title: "短信验证码", icon: "number.square.fill") HStack(spacing: 10) { TextField("请输入验证码", text: $model.verificationCode) .textFieldStyle(.plain) .focused($focusedField, equals: .code) .padding(.horizontal, 13) .frame(height: 40) .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 8).stroke(focusedField == .code ? Color.orange : Color.black.opacity(0.12), lineWidth: 1) } Button { Task { await model.sendVerificationCode() } } label: { Text(model.codeCountdown > 0 ? "\(model.codeCountdown)s" : "获取验证码") .frame(width: 94, height: 40) } .buttonStyle(.bordered) .disabled(model.isBusy || model.codeCountdown > 0) } Button { Task { await model.verifyAndSignIn() } } label: { HStack(spacing: 8) { if model.isBusy { ProgressView().controlSize(.small) } Text("登录") Image(systemName: "arrow.right") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) .tint(Color.orange) .disabled(model.isBusy) if !model.statusMessage.isEmpty { Label(model.statusMessage, systemImage: "checkmark.circle.fill") .font(.caption) .foregroundStyle(.secondary) } } } } private struct LoginFieldLabel: View { let title: String let icon: String var body: some View { Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(.secondary) } } struct WorkspaceSidebar: View { @ObservedObject var model: AppModel @Binding var selectedTool: WorkspaceTool? @Binding var columnVisibility: NavigationSplitViewVisibility @State private var toolsExpanded = true @State private var categoriesExpanded = false var body: some View { VStack(alignment: .leading, spacing: 18) { HStack(spacing: 12) { BrandMark(size: 34) .padding(7) .background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) VStack(alignment: .leading, spacing: 2) { Text("光鸭云盘").font(.headline.weight(.bold)) Text("Cloud Workspace").font(.system(size: 9, weight: .bold, design: .rounded)).tracking(1.1).foregroundStyle(.secondary) } Spacer() } .padding(.top, 8) ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 8) { Text("文件空间").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8) ForEach([WorkspaceSection.files, .recentViewed, .recentRestored]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } } VStack(alignment: .leading, spacing: 8) { Text("工作区").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8) ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } } VStack(spacing: 5) { Button { withAnimation(.easeInOut(duration: 0.18)) { categoriesExpanded.toggle() } } label: { HStack { Image(systemName: "square.grid.2x2").foregroundStyle(.orange).frame(width: 26); Text("文件分类").font(.callout.weight(.semibold)); Spacer(); Image(systemName: "chevron.right").font(.caption.weight(.bold)).rotationEffect(.degrees(categoriesExpanded ? 90 : 0)) }.frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()).padding(10).background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 13)) }.buttonStyle(.plain) if categoriesExpanded { VStack(spacing: 4) { ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } }.transition(.opacity.combined(with: .move(edge: .top))) } } VStack(spacing: 6) { Button { withAnimation(.easeInOut(duration: 0.18)) { toolsExpanded.toggle() } } label: { HStack(spacing: 10) { Image(systemName: "shippingbox.fill").foregroundStyle(.orange).frame(width: 26) VStack(alignment: .leading, spacing: 2) { Text("工具箱").font(.callout.weight(.semibold)) Text("独立工具页面").font(.caption2).foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.down").font(.caption.weight(.bold)).foregroundStyle(.tertiary).rotationEffect(.degrees(toolsExpanded ? 0 : -90)) } .padding(12) .background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) } .buttonStyle(.plain) if toolsExpanded { VStack(spacing: 4) { ForEach(WorkspaceTool.allCases) { tool in ToolSidebarItem(tool: tool, selected: selectedTool == tool) { if tool == .rename { model.batchRenameTarget = nil model.batchRenameSelection = [] } selectedTool = tool } } } .transition(.opacity.combined(with: .move(edge: .top))) } } } .padding(.trailing, 4) } .frame(maxHeight: .infinity) .clipped() HStack { Spacer() Button { withAnimation(.easeInOut(duration: 0.2)) { columnVisibility = columnVisibility == .detailOnly ? .all : .detailOnly } } label: { Image(systemName: "sidebar.left") } .buttonStyle(.bordered).controlSize(.small).help("显示或隐藏侧边栏") } HStack(spacing: 11) { Menu { Text("会员:\(model.user.memberLevel)") Text("账号:\(model.user.phone.isEmpty ? "未提供" : model.user.phone)") Text("空间:\(model.user.capacityText)") Divider() Button("退出登录", role: .destructive) { model.logout() } } label: { HStack(spacing: 11) { Circle().fill(Color.orange.opacity(0.18)).frame(width: 38, height: 38).overlay { Text(String(model.user.name.prefix(1))).font(.headline.weight(.bold)).foregroundStyle(.orange) } VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.semibold)).lineLimit(1); Text(model.user.memberLevel).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } .contentShape(Rectangle()) }.menuStyle(.borderlessButton).help("查看账户与空间信息") Spacer() Button { model.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") }.buttonStyle(.plain).help("退出登录") } .padding(12) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } .padding(16) .softCard(radius: 24) .padding(10) } } private struct SidebarItem: View { let section: WorkspaceSection let selected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 11) { Image(systemName: section.icon) .font(.system(size: 15, weight: .semibold)) .frame(width: 28, height: 28) .foregroundStyle(selected ? .white : .orange) .background(selected ? Color.orange : Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) Text(section.rawValue).font(.callout.weight(selected ? .semibold : .regular)) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .foregroundStyle(selected ? .primary : .secondary) .padding(.horizontal, 10) .padding(.vertical, 8) .background(selected ? Color.orange.opacity(0.14) : Color.clear, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } .buttonStyle(.plain) .focusable(false) } } private struct ToolSidebarItem: View { let tool: WorkspaceTool let selected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 9) { Image(systemName: tool.icon) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(selected ? .white : .orange) .frame(width: 26, height: 26) .background(selected ? Color.orange : Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) Text(tool.rawValue).font(.caption.weight(selected ? .semibold : .regular)).lineLimit(1) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .padding(.horizontal, 10).padding(.vertical, 6) .background(selected ? Color.orange.opacity(0.13) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) .focusable(false) } } struct FilesBrowser: View { @ObservedObject var model: AppModel @Binding var selectedFileIDs: Set @Binding var globalSearchText: String @Binding var renameFile: CloudFile? @Binding var isCreatingFolder: Bool @Binding var isImporting: Bool @Binding var isShowingSettings: Bool @Binding var isShowingDuplicates: Bool let onPreview: (CloudFile) -> Void let onOpenTool: (WorkspaceTool) -> Void let onShowInspector: () -> Void @State private var pendingDelete: CloudFile? @State private var showBatchDeleteConfirmation = false @State private var selectionAnchorID: String? @State private var keyboardFocusID: String? @State private var tmdbTarget: CloudFile? // Server already returns this page in requested order; keep client filtering order intact. private var filteredFiles: [CloudFile] { globalSearchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(globalSearchText) } } private var fileCount: Int { model.files.filter { !$0.isDirectory }.count } private var folderCount: Int { model.files.filter(\.isDirectory).count } private var totalSizeText: String { let total = model.files.compactMap(\.size).reduce(Int64(0), +) guard total > 0 else { return "--" } let formatter = ByteCountFormatter(); formatter.countStyle = .file return formatter.string(fromByteCount: total) } var body: some View { VStack(spacing: 12) { browserHeader content statusBar } .padding(16) .background(Color.clear) .onMoveCommand { direction in moveKeyboardSelection(direction: direction) } .onCommand(#selector(NSResponder.scrollPageUp(_:))) { moveKeyboardSelection(page: -1) } .onCommand(#selector(NSResponder.scrollPageDown(_:))) { moveKeyboardSelection(page: 1) } .onCommand(#selector(NSResponder.moveToBeginningOfDocument(_:))) { selectKeyboardIndex(0) } .onCommand(#selector(NSResponder.moveToEndOfDocument(_:))) { selectKeyboardIndex(max(0, filteredFiles.count - 1)) } .onCommand(#selector(NSResponder.deleteBackward(_:))) { if !selectedFileIDs.isEmpty { showBatchDeleteConfirmation = true } } .sheet(item: $tmdbTarget) { TMDBRecognizeSheet(model: model, file: $0) } .confirmationDialog("确认删除已选 \(selectedFileIDs.count) 项?", isPresented: $showBatchDeleteConfirmation) { Button("删除 \(selectedFileIDs.count) 项", role: .destructive) { let ids = selectedFileIDs selectedFileIDs = [] selectionAnchorID = nil Task { await model.deleteSelectedFiles(ids) } } Button("取消", role: .cancel) { } } message: { Text("选中的文件及文件夹将被删除。文件夹会连同其内容一起删除,且此操作不可撤销。") } .confirmationDialog( pendingDelete?.isDirectory == true ? "确认删除文件夹?" : "确认删除文件?", isPresented: Binding( get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } } ), presenting: pendingDelete ) { file in Button("删除", role: .destructive) { pendingDelete = nil selectedFileIDs.remove(file.id) Task { await model.delete(file) } } Button("取消", role: .cancel) { pendingDelete = nil } } message: { file in Text(file.isDirectory ? "“\(file.name)”及其包含的内容将被删除。删除成功后会直接从当前列表移除。" : "“\(file.name)”将被删除。删除成功后会直接从当前列表移除。") } } private var browserHeader: some View { HStack(spacing: 12) { breadcrumb Spacer(minLength: 12) browserActions } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4).padding(.vertical, 4) } private var breadcrumb: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 7) { Button { Task { await model.navigateToRoot() } } label: { Image(systemName: "house.fill").font(.system(size: 11, weight: .bold)) } .buttonStyle(.plain).foregroundStyle(.orange) if model.section == .files && !model.folderPath.isEmpty { ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in Image(systemName: "chevron.right").font(.system(size: 8, weight: .bold)).foregroundStyle(.tertiary) Button(folder.name) { Task { await model.navigateToFolder(at: index) } } .buttonStyle(.plain) .font(.caption.weight(index == model.folderPath.count - 1 ? .bold : .medium)) .foregroundStyle(index == model.folderPath.count - 1 ? .primary : .secondary) .lineLimit(1) .padding(.horizontal, 8).padding(.vertical, 5) .background(index == model.folderPath.count - 1 ? Color.orange.opacity(0.12) : Color.clear, in: Capsule()) } } else { Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.caption.weight(.semibold)).foregroundStyle(.secondary) } } .padding(.horizontal, 10).padding(.vertical, 5) .background(.thinMaterial, in: Capsule()) } .frame(maxWidth: .infinity, alignment: .leading) } private var statusBar: some View { HStack(spacing: 12) { Label("本页文件 \(fileCount)", systemImage: "doc.fill").foregroundStyle(.secondary) Label("本页文件夹 \(folderCount)", systemImage: "folder.fill").foregroundStyle(.secondary) Label("本页大小 \(totalSizeText)", systemImage: "externaldrive.fill").foregroundStyle(.secondary) if model.isLoadingFiles || model.isLoadingFolderSizes || model.isBusy { HStack(spacing: 6) { ProgressView().controlSize(.small); Text(model.statusMessage.isEmpty ? "正在同步…" : model.statusMessage).font(.caption).foregroundStyle(.secondary) } } Spacer(minLength: 4) paginationControls } .font(.caption) .padding(.horizontal, 12).padding(.vertical, 8) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.35), lineWidth: 1) } } private var browserActions: some View { HStack(spacing: 7) { if !selectedFileIDs.isEmpty { Text("已选 \(selectedFileIDs.count) 项").font(.caption.weight(.semibold)).foregroundStyle(.secondary) Button(role: .destructive) { showBatchDeleteConfirmation = true } label: { Image(systemName: "trash") } .buttonStyle(.bordered).help("删除选中的 \(selectedFileIDs.count) 项") } if model.section == .files { Button { isImporting = true } label: { Label("上传", systemImage: "arrow.up") } .buttonStyle(.borderedProminent).tint(.orange) Button { isCreatingFolder = true } label: { Image(systemName: "folder.badge.plus") } .buttonStyle(.bordered).help("新建文件夹") } Button { Task { await model.refresh() } } label: { if model.isLoadingFiles { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") } } .buttonStyle(.bordered) .help(model.isLoadingFiles ? "重新加载当前列表" : "刷新") .accessibilityLabel("刷新文件列表") .disabled(model.isBusy) moreMenu } .fixedSize() } private var content: some View { Group { if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section).softCard(radius: 24).padding(.horizontal, 10) } else if model.isLoadingFiles { LoadingFilesView().softCard(radius: 8).padding(.horizontal, 10) } else if filteredFiles.isEmpty { EmptyFilesView(model: model).softCard(radius: 24).padding(.horizontal, 10) } else { List(selection: $selectedFileIDs) { Section { ForEach(filteredFiles) { file in BrowserRow(file: file, isSelected: selectedFileIDs.contains(file.id), isLoadingDetails: model.detailLoadingIDs.contains(file.id), isActionLoading: model.actionLoadingIDs.contains(file.id)) { modifiers in select(file, modifiers: modifiers) } onOpen: { selectedFileIDs = [file.id] if file.isDirectory { Task { await model.openFolder(file) } } else { onPreview(file) } } .tag(file.id) .onAppear { model.loadVisibleItemDetails(file) } .contextMenu { fileMenu(file) } .draggable(file.id) .dropDestination(for: String.self) { values, _ in guard file.isDirectory else { return false } let ids = values.filter { $0 != file.id } guard !ids.isEmpty else { return false } Task { await model.move(fileIDs: ids, to: file.id) } return true } } } header: { HStack { sortHeader("名称", key: .name, alignment: .leading).frame(maxWidth: .infinity, alignment: .leading) sortHeader("大小", key: .size, alignment: .trailing).frame(width: 96, alignment: .trailing) sortHeader("修改时间", key: .modifiedAt, alignment: .trailing).frame(width: 132, alignment: .trailing) Image(systemName: "chevron.right").opacity(0).frame(width: 18) } .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } } .listStyle(.plain) .scrollContentBackground(.hidden) .softCard(radius: 24) } } } private func moveKeyboardSelection(direction: MoveCommandDirection) { moveKeyboardSelection(page: direction == .up || direction == .left ? -1 : 1) } private func moveKeyboardSelection(page: Int) { guard !filteredFiles.isEmpty else { return } let step = abs(page) == 1 ? 1 : 12 let current = keyboardFocusID.flatMap { id in filteredFiles.firstIndex { $0.id == id } } ?? selectedFileIDs.first.flatMap { id in filteredFiles.firstIndex { $0.id == id } } ?? (page > 0 ? -1 : filteredFiles.count) selectKeyboardIndex(max(0, min(filteredFiles.count - 1, current + (page > 0 ? step : -step)))) } private func selectKeyboardIndex(_ index: Int) { guard filteredFiles.indices.contains(index) else { return } let file = filteredFiles[index] keyboardFocusID = file.id; selectionAnchorID = file.id; selectedFileIDs = [file.id] } private func select(_ file: CloudFile, modifiers: NSEvent.ModifierFlags) { keyboardFocusID = file.id if modifiers.contains(.shift), let anchor = selectionAnchorID, let start = filteredFiles.firstIndex(where: { $0.id == anchor }), let end = filteredFiles.firstIndex(where: { $0.id == file.id }) { selectedFileIDs.formUnion(filteredFiles[min(start, end)...max(start, end)].map(\.id)) } else if modifiers.contains(.command) { if selectedFileIDs.contains(file.id) { selectedFileIDs.remove(file.id) } else { selectedFileIDs.insert(file.id) } selectionAnchorID = file.id } else { selectedFileIDs = [file.id] selectionAnchorID = file.id } } private func sortHeader(_ title: String, key: FileSort, alignment: Alignment) -> some View { Button { Task { await model.setServerSort(key) } } label: { HStack(spacing: 3) { Text(title) if model.serverSort == key { Image(systemName: model.serverSortDirection == .ascending ? "chevron.up" : "chevron.down").font(.system(size: 9, weight: .bold)) } } } .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: alignment) .help("按\(title)进行服务端排序") } private var paginationControls: some View { HStack(spacing: 10) { Picker("每页", selection: Binding(get: { model.pageSize }, set: { size in Task { await model.setPageSize(size) } })) { Text("50 / 页").tag(50); Text("100 / 页").tag(100); Text("200 / 页").tag(200); Text("500 / 页").tag(500); Text("1000 / 页").tag(1000) } .labelsHidden().frame(width: 92) Button { Task { await model.goToPage(model.currentPage - 1) } } label: { Image(systemName: "chevron.left") } .buttonStyle(.bordered).disabled(model.currentPage == 0 || model.isLoadingFiles) Text("\(model.currentPage + 1) / \(model.totalPages)").font(.callout.monospacedDigit()).frame(minWidth: 62) Button { Task { await model.goToPage(model.currentPage + 1) } } label: { Image(systemName: "chevron.right") } .buttonStyle(.bordered).disabled(model.currentPage + 1 >= model.totalPages || model.isLoadingFiles) } } private var moreMenu: some View { Menu { Button { model.reloadCurrentListSizes() } label: { Label("重新获取当前列表大小", systemImage: "arrow.triangle.2.circlepath") } .disabled(model.isLoadingFolderSizes || model.files.isEmpty) Divider() Button { Task { await model.cleanEmptyFolders() } } label: { Label("清理空文件夹", systemImage: "sparkles") } Button { Task { await model.scanDuplicates(); isShowingDuplicates = true } } label: { Label("扫描重复文件", systemImage: "square.on.square") } Button { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true } } label: { Label("扫描重复文件(排除小文件)", systemImage: "square.on.square.fill") } Button { model.findSimilarFolders() } label: { Label("查询相似文件夹", systemImage: "rectangle.3.group") } Divider() Button { isShowingSettings = true } label: { Label("工作区设置", systemImage: "gearshape") } } label: { Image(systemName: "ellipsis.circle.fill").font(.title3).foregroundStyle(.secondary) } .menuStyle(.borderlessButton) .help("更多工具") } @ViewBuilder private func fileMenu(_ file: CloudFile) -> some View { Button { if file.isDirectory { Task { await model.openFolder(file) } } else { onPreview(file) } } label: { Label(file.isDirectory ? "打开文件夹" : "预览", systemImage: file.isDirectory ? "folder" : "eye") } if !file.isDirectory { Button { Task { await model.download(file) } } label: { Label("下载", systemImage: "arrow.down.circle") } } if file.fileType == 2 { Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("使用 IINA / VLC 播放", systemImage: "play.rectangle.on.rectangle") } } Button { selectedFileIDs = [file.id] onShowInspector() } label: { Label("在检查器中查看", systemImage: "sidebar.right") } Button { Task { await model.share(file) } } label: { Label("分享", systemImage: "square.and.arrow.up") } Menu("复制到") { Button("当前文件夹") { Task { await model.copy(file, to: model.folderPath.last?.id) } } Button("根目录") { Task { await model.copy(file, to: nil) } } } Menu("移动到") { Button("当前文件夹") { Task { await model.move(file, to: model.folderPath.last?.id) } } Button("根目录") { Task { await model.move(file, to: nil) } } } Divider() Button { renameFile = file } label: { Label("重命名", systemImage: "pencil") } if file.isDirectory { Menu("文件夹工具") { Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") } Button { openScanTool(.emptyFolders, for: file) } label: { Label("扫描空文件夹", systemImage: "folder.badge.minus") } Button { openScanTool(.duplicates, for: file) } label: { Label("扫描重复文件", systemImage: "square.on.square") } Button { openScanTool(.similarFolders, for: file) } label: { Label("扫描相似文件夹", systemImage: "rectangle.3.group") } } Button { model.tmdbTarget = file onOpenTool(.tmdb) } label: { Label("TMDB 识别与刮削…", systemImage: "film") } } else { Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") } Button { tmdbTarget = file } label: { Label("TMDB 识别与刮削…", systemImage: "film") } } Button { model.copyTransferJSON(for: file) } label: { Label("复制秒传 JSON", systemImage: "doc.on.doc") } Divider() if model.section == .recycle { Button { Task { await model.restore(file) } } label: { Label("恢复", systemImage: "arrow.uturn.backward") } } Button(role: .destructive) { pendingDelete = file } label: { Label("删除…", systemImage: "trash") } } private func openScanTool(_ kind: ScanKind, for folder: CloudFile) { model.startScan(ScanRequest(kind: kind, scope: .recursiveCurrentFolder, rootID: folder.id, rootName: folder.name, excludeFilesSmallerThan: nil)) onOpenTool(.scan) } private func openBatchRenameTool(for clickedFile: CloudFile) { let selectedFiles = model.files.filter { selectedFileIDs.contains($0.id) } if selectedFiles.count > 1 { model.batchRenameSelection = selectedFiles model.batchRenameTarget = nil } else if selectedFileIDs.contains(clickedFile.id), let selectedFile = selectedFiles.first { model.batchRenameSelection = [selectedFile] model.batchRenameTarget = nil } else if clickedFile.isDirectory { model.batchRenameSelection = [] model.batchRenameTarget = clickedFile } else { model.batchRenameSelection = [clickedFile] model.batchRenameTarget = nil } onOpenTool(.rename) } } private struct BrowserRow: View { let file: CloudFile let isSelected: Bool let isLoadingDetails: Bool let isActionLoading: Bool let onSelect: (NSEvent.ModifierFlags) -> Void let onOpen: () -> Void var body: some View { Button(action: { onSelect(NSEvent.modifierFlags) }) { HStack(spacing: 12) { Image(systemName: file.icon) .font(.title3.weight(.semibold)) .foregroundStyle(file.isDirectory ? .orange : .accentColor) .frame(width: 34, height: 34) .background((file.isDirectory ? Color.orange : Color.accentColor).opacity(0.11), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) VStack(alignment: .leading, spacing: 4) { Text(file.name).font(.body.weight(.medium)).lineLimit(1) HStack(spacing: 6) { Text(file.typeName) if isLoadingDetails { Text("·"); Text("正在获取详情") } else if file.isDirectory, let folders = file.subDirectoryCount, let files = file.subFileCount { Text("·"); Text("\(folders) 个文件夹,\(files) 个文件") } else if file.gcid != nil { Text("·"); Text("GCID 已获取") } }.font(.caption).foregroundStyle(.secondary) } Spacer() Group { if isActionLoading { ProgressView().controlSize(.small).help("正在处理…") } else if isLoadingDetails && file.size == nil { ProgressView().controlSize(.small) } else { Text(file.formattedSize).font(.callout.monospacedDigit()).foregroundStyle(.secondary) } }.frame(width: 96, alignment: .trailing) Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout.monospacedDigit()).foregroundStyle(.secondary).frame(width: 132, alignment: .trailing) Image(systemName: file.isDirectory ? "chevron.right" : "info.circle").font(.caption.weight(.bold)).foregroundStyle(.tertiary).frame(width: 18) } .padding(.vertical, 8) .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(isSelected ? Color.orange.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(file.name) .accessibilityValue(isSelected ? "已选中" : "未选中") .accessibilityHint(file.isDirectory ? "双击打开文件夹" : "双击预览文件") .accessibilityAddTraits(isSelected ? .isSelected : []) .disabled(isActionLoading) .opacity(isActionLoading ? 0.55 : 1) .simultaneousGesture(TapGesture(count: 2).onEnded(onOpen)) } } private struct TMDBRecognizeSheet: View { @ObservedObject var model: AppModel let file: CloudFile @Environment(\.dismiss) private var dismiss @State private var tmdbID = "" @State private var mediaKind: TMDBMediaKind = .automatic @State private var chineseTitle = "" @State private var englishTitle = "" @State private var yearText = "" @State private var hasQueried = false private var parsed: ParsedMediaName { model.parsedTMDBInfo(for: file) } private var queryJob: TMDBJob? { model.tmdbJobs.first(where: { $0.id == file.id }) } private var queryTitle: String { chineseTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? englishTitle : chineseTitle } var body: some View { VStack(alignment: .leading, spacing: 18) { SheetHeader(icon: "film.stack", title: "TMDB 识别与刮削", subtitle: file.name) VStack(alignment: .leading, spacing: 8) { Text("可编辑识别信息").font(.caption.weight(.bold)).foregroundStyle(.secondary) HStack { TextField("中文标题", text: $chineseTitle).textFieldStyle(.roundedBorder); TextField("英文标题", text: $englishTitle).textFieldStyle(.roundedBorder); TextField("年份", text: $yearText).textFieldStyle(.roundedBorder).frame(width: 90) } } LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { InspectorField(title: "自动解析标题", value: parsed.title) InspectorField(title: "自动解析年份", value: parsed.year.map(String.init) ?? "--") InspectorField(title: "集数", value: parsed.isEpisode ? "S\(String(format: "%02d", parsed.season ?? 0))E\(String(format: "%02d", parsed.episode ?? 0))" : "电影/整季") InspectorField(title: "介质", value: parsed.isDiscStructure ? "ISO / BDMV / 光盘结构" : "普通媒体文件") InspectorField(title: "分辨率", value: parsed.resolution ?? "--") InspectorField(title: "来源 / 编码", value: [parsed.source, parsed.videoCodec].compactMap { $0 }.joined(separator: " · ").isEmpty ? "--" : [parsed.source, parsed.videoCodec].compactMap { $0 }.joined(separator: " · ")) InspectorField(title: "音频", value: parsed.audio ?? "--") } Picker("媒体类型", selection: $mediaKind) { ForEach(TMDBMediaKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) HStack { TextField("手动 TMDB ID", text: $tmdbID).textFieldStyle(.roundedBorder); Button("按 ID 建立刮削任务") { if let id = Int(tmdbID) { Task { await model.recognizeTMDBByID(file, tmdbID: id, mediaKind: mediaKind); dismiss() } } }.buttonStyle(.bordered) } if hasQueried, let job = queryJob { Divider() Text("TMDB 候选结果").font(.headline) if job.candidates.isEmpty { ContentUnavailableView("未找到可用结果", systemImage: "magnifyingglass", description: Text(job.note)) } else { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(job.candidates) { candidate in Button { model.selectTMDBCandidate(jobID: job.id, candidateID: candidate.id) } label: { HStack(alignment: .top, spacing: 10) { Image(systemName: candidate.mediaType == .tv ? "tv" : "film").foregroundStyle(.purple).frame(width: 24); VStack(alignment: .leading, spacing: 3) { Text("\(candidate.title) \(candidate.releaseDate.prefix(4))").font(.callout.weight(.semibold)); if candidate.originalTitle != candidate.title { Text(candidate.originalTitle).font(.caption).foregroundStyle(.secondary) }; Text(candidate.overview.isEmpty ? "暂无简介" : candidate.overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) }; Spacer(); if job.selectedCandidateID == candidate.id { Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) } } .padding(10).frame(maxWidth: .infinity, alignment: .leading).background(job.selectedCandidateID == candidate.id ? Color.purple.opacity(0.12) : Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) }.buttonStyle(.plain) } }.padding(.vertical, 2) }.frame(maxHeight: 240) } if job.selectedCandidate != nil { Button { model.toggleTMDBApproval(jobID: job.id); dismiss() } label: { Label("使用已选候选建立任务", systemImage: "checkmark.circle") }.buttonStyle(.borderedProminent).tint(.purple) } } HStack { Spacer(); Button("取消") { dismiss() }; Button { Task { await model.queryTMDBManually(for: file, title: queryTitle, year: Int(yearText), mediaKind: mediaKind); hasQueried = true } } label: { if model.isRunningTMDBJob { ProgressView().controlSize(.small) }; Label(model.isRunningTMDBJob ? "正在查询…" : (hasQueried ? "重新查询" : "查询 TMDB 候选"), systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.purple).disabled(model.isRunningTMDBJob || queryTitle.isEmpty) } }.padding(22).frame(minWidth: 620) .onAppear { chineseTitle = parsed.title; englishTitle = ""; yearText = parsed.year.map(String.init) ?? "" } } } private struct FileDetailsSheet: View { @ObservedObject var model: AppModel let file: CloudFile @Environment(\.dismiss) private var dismiss @State private var detail: JSONValue? @State private var errorMessage = "" @State private var isLoading = false var body: some View { VStack(spacing: 0) { HStack(spacing: 14) { Image(systemName: file.icon).font(.title2.weight(.semibold)).foregroundStyle(file.isDirectory ? .orange : .blue) .frame(width: 48, height: 48).background((file.isDirectory ? Color.orange : Color.blue).opacity(0.12), in: RoundedRectangle(cornerRadius: 15)) VStack(alignment: .leading, spacing: 3) { Text(file.name).font(.title3.weight(.bold)).lineLimit(2); Text("文件详情").font(.caption).foregroundStyle(.secondary) } Spacer() if isLoading { ProgressView().controlSize(.small) } Button("完成") { dismiss() }.buttonStyle(.borderedProminent).tint(.orange) }.padding(20) Divider() ScrollView { VStack(alignment: .leading, spacing: 14) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { InspectorField(title: "文件 ID", value: file.id) InspectorField(title: "类型", value: file.typeName) InspectorField(title: "大小", value: file.formattedSize) InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt) } if let detail { Text("接口返回").font(.headline) Text(jsonText(detail)).font(.system(.caption, design: .monospaced)).textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading).padding(14) .background(Color.black.opacity(0.04), in: RoundedRectangle(cornerRadius: 14)) } else if !errorMessage.isEmpty { ContentUnavailableView("详情加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) } else if isLoading { ProgressView("正在读取详情…").frame(maxWidth: .infinity, minHeight: 180) } }.padding(20) } } .frame(minWidth: 620, minHeight: 520) .task(id: file.id) { await loadDetails() } } private func loadDetails() async { isLoading = true errorMessage = "" defer { isLoading = false } do { detail = try await model.details(for: file) } catch is CancellationError { } catch { errorMessage = error.localizedDescription } } } struct FilePreviewSheet: View { @ObservedObject var model: AppModel let file: CloudFile @Environment(\.dismiss) private var dismiss @State private var url: URL? @State private var errorText = "" private var ext: String { (file.name as NSString).pathExtension.lowercased() } private var isImage: Bool { file.fileType == 1 || ["jpg", "jpeg", "png", "gif", "webp", "heic", "bmp", "tiff"].contains(ext) } // AVPlayer handles the native family; containers/codecs beyond it can be handed // to IINA/VLC, which are purpose-built for MKV, HEVC, AV1, DTS, subtitles, etc. private var isVideo: Bool { file.fileType == 2 || ["mp4", "mov", "m4v", "mkv", "webm", "avi", "flv", "wmv", "ts", "mts", "m2ts", "mpeg", "mpg", "3gp", "ogv", "vob", "rmvb", "asf", "f4v"].contains(ext) } private var nativeVideo: Bool { ["mp4", "mov", "m4v"].contains(ext) } var body: some View { VStack(spacing: 0) { HStack { Image(systemName: file.icon).foregroundStyle(.orange); Text(file.name).font(.headline).lineLimit(1); Spacer(); if isVideo { Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("使用 IINA / VLC", systemImage: "play.rectangle.on.rectangle") }.buttonStyle(.bordered).help("适用于 MKV、AV1、HEVC、DTS、外挂字幕等格式") }; Button("关闭 (Esc)") { dismiss() }.buttonStyle(.bordered) } .padding(14).background(.thinMaterial) Divider() ZStack { Color.black.opacity(0.92) if let url { if isImage { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable().scaledToFit().padding(24) case .failure: previewFailure("图片加载失败", icon: "photo.badge.exclamationmark") default: ProgressView("正在加载图片…").tint(.white).foregroundStyle(.white) } } } else if isVideo { if nativeVideo { VideoPlayer(player: AVPlayer(url: url)).padding(12) } else { externalPlayerPrompt } } else { WebPreview(url: url) } } else if !errorText.isEmpty { previewFailure(errorText, icon: "eye.slash") } else { ProgressView("正在获取预览地址…").tint(.white).foregroundStyle(.white) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(minWidth: 900, minHeight: 650) .onExitCommand { dismiss() } .task { do { url = try await model.remoteURL(for: file) } catch { errorText = error.localizedDescription } } } private var externalPlayerPrompt: some View { VStack(spacing: 16) { Image(systemName: "play.rectangle.on.rectangle").font(.system(size: 44)).foregroundStyle(.white) Text("建议使用外部播放器").font(.title3.weight(.semibold)).foregroundStyle(.white) Text("该格式可能包含 AVPlayer 不支持的编码、音轨或字幕。已优先支持 IINA,其次 VLC。") .font(.callout).foregroundStyle(.white.opacity(0.72)).multilineTextAlignment(.center) Button { Task { await model.playWithExternalPlayer(file); dismiss() } } label: { Label("使用 IINA / VLC 播放", systemImage: "play.fill") } .buttonStyle(.borderedProminent) }.padding(32) } @ViewBuilder private func previewFailure(_ text: String, icon: String) -> some View { ContentUnavailableView(text, systemImage: icon).foregroundStyle(.white) } } private struct WebPreview: NSViewRepresentable { let url: URL func makeNSView(context: Context) -> WKWebView { let view = WKWebView(); view.setValue(false, forKey: "drawsBackground"); return view } func updateNSView(_ view: WKWebView, context: Context) { if view.url != url { view.load(URLRequest(url: url)) } } } struct FileInspector: View { @ObservedObject var model: AppModel let file: CloudFile? let onPreview: (CloudFile) -> Void let onClose: () -> Void @State private var showsRawDetail = false var body: some View { VStack(alignment: .leading, spacing: 0) { if let file { VStack(alignment: .leading, spacing: 18) { HStack(alignment: .top) { Image(systemName: file.icon) .font(.system(size: 30, weight: .semibold)) .foregroundStyle(file.isDirectory ? .orange : .accentColor) .frame(width: 58, height: 58) .background((file.isDirectory ? Color.orange : Color.accentColor).opacity(0.12), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) Spacer() Button { Task { await model.showDetails(file) } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.bordered).help("刷新详情") Button(action: onClose) { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭详情检查器") } VStack(alignment: .leading, spacing: 7) { Text(file.name).font(.title3.weight(.bold)).lineLimit(3) Label(file.typeName, systemImage: file.isDirectory ? "folder" : "doc").font(.caption).foregroundStyle(.secondary) } HStack(spacing: 8) { Button { if file.isDirectory { Task { await model.openFolder(file) } } else { onPreview(file) } } label: { Label(file.isDirectory ? "打开" : "预览", systemImage: file.isDirectory ? "folder" : "eye") } .buttonStyle(.borderedProminent).tint(.orange) if !file.isDirectory { Button { Task { await model.download(file) } } label: { Image(systemName: "arrow.down.circle") } .buttonStyle(.bordered).help("下载") } Button { Task { await model.share(file) } } label: { Image(systemName: "square.and.arrow.up") }.buttonStyle(.bordered).help("分享") } Divider() VStack(alignment: .leading, spacing: 12) { InspectorField(title: "文件 ID", value: file.id) InspectorField(title: "GCID", value: file.gcid ?? "未获取") InspectorField(title: "大小", value: file.formattedSize) if file.isDirectory { InspectorField(title: "子文件夹", value: file.subDirectoryCount.map(String.init) ?? "--") InspectorField(title: "子文件", value: file.subFileCount.map(String.init) ?? "--") } InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt) } if model.detailLoadingIDs.contains(file.id), model.detail == nil { HStack(spacing: 8) { ProgressView().controlSize(.small); Text("正在读取服务器详情…").font(.caption).foregroundStyle(.secondary) } } else if !model.detailErrorMessage.isEmpty { Label(model.detailErrorMessage, systemImage: "exclamationmark.triangle.fill") .font(.caption).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) } else if model.detailFileID == file.id, let detail = model.detail { Divider() DisclosureGroup("原始接口数据", isExpanded: $showsRawDetail) { ScrollView { Text(jsonText(detail)).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 240) .padding(.top, 8) } .font(.caption.weight(.semibold)) } Spacer() }.padding(22) } else { VStack(alignment: .leading, spacing: 18) { HStack { Text("详情").font(.headline); Spacer(); Button(action: onClose) { Image(systemName: "xmark") }.buttonStyle(.bordered) } ContentUnavailableView("未选择文件", systemImage: "doc.text.magnifyingglass", description: Text("选择一个文件后可在这里查看详细信息。")) }.padding(18).frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(minWidth: 270, idealWidth: 310) .background(.ultraThinMaterial) .task(id: file?.id) { showsRawDetail = false guard let file else { model.detail = nil; model.detailFileID = nil; model.detailErrorMessage = ""; return } await model.showDetails(file) } } } private struct LoadingFilesView: View { var body: some View { VStack(spacing: 14) { ProgressView().controlSize(.large) Text("正在加载文件夹内容…").font(.callout.weight(.medium)) Text("正在同步文件、大小和修改时间").font(.caption).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityElement(children: .combine) .accessibilityLabel("正在加载文件夹内容") } } private struct InspectorField: View { let title: String; let value: String; var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title.uppercased()).font(.system(size: 9, weight: .bold)).foregroundStyle(.tertiary); Text(value).font(.caption).lineLimit(3).textSelection(.enabled) }.frame(maxWidth: .infinity, alignment: .leading).padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } } struct ToolWorkspacePage: View { @ObservedObject var model: AppModel let tool: WorkspaceTool var body: some View { VStack(alignment: .leading, spacing: 18) { HStack(spacing: 14) { Image(systemName: tool.icon).font(.title2.weight(.semibold)).foregroundStyle(.orange).frame(width: 52, height: 52).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 17, style: .continuous)) VStack(alignment: .leading, spacing: 4) { Text(tool.rawValue).font(.system(size: 28, weight: .bold, design: .rounded)); Text(tool.subtitle).foregroundStyle(.secondary) } Spacer() } .padding(20).softCard(radius: 24) switch tool { case .scan: UnifiedScanToolPage(model: model) case .rename: BatchRenameToolPage(model: model) case .tmdb: TMDBToolPage(model: model) case .settings: ToolSettingsPage(model: model) } } .padding(14) .background(Color.clear) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } private struct UnifiedScanToolPage: View { @ObservedObject var model: AppModel @State private var selectedRootID: String? @State private var selectedRootName = "整个云盘" @State private var showFolderPicker = false var body: some View { VStack(spacing: 16) { ToolPageCard(icon: "magnifyingglass.circle.fill", tint: .orange, title: "统一扫描", detail: "选择扫描范围后,可分别扫描空文件夹、重复文件及相似文件夹;扫描不会修改云端文件。") { HStack { VStack(alignment: .leading, spacing: 2) { Text("扫描范围").font(.caption).foregroundStyle(.secondary); Text(selectedRootName).font(.callout.weight(.semibold)).lineLimit(1) }; Spacer(); Button("浏览云盘…") { showFolderPicker = true }.buttonStyle(.bordered); Button("全盘扫描") { selectedRootID = nil; selectedRootName = "整个云盘" }.buttonStyle(.borderedProminent).tint(.orange) } HStack(spacing: 10) { scanButton(.emptyFolders, "扫描空文件夹", "folder.badge.minus"); scanButton(.duplicates, "扫描重复文件", "square.on.square"); scanButton(.similarFolders, "扫描相似文件夹", "rectangle.3.group") } } if model.activeScanRequest != nil || model.scanResult != nil { ScanToolPage(model: model, kind: model.activeScanRequest?.kind ?? model.scanResult!.request.kind) .id(model.activeScanRequest?.kind ?? model.scanResult!.request.kind) .frame(maxHeight: .infinity, alignment: .top) } } .frame(maxHeight: .infinity, alignment: .top) .sheet(isPresented: $showFolderPicker) { ScanFolderPickerSheet(model: model) { id, name in selectedRootID = id selectedRootName = name } } .onAppear { syncSelectedRoot() } .onChange(of: model.activeScanRequest) { _, _ in syncSelectedRoot() } } private func syncSelectedRoot() { guard let request = model.activeScanRequest else { return } selectedRootID = request.rootID selectedRootName = request.rootName } @ViewBuilder private func scanButton(_ kind: ScanKind, _ title: String, _ icon: String) -> some View { let isActive = model.isScanning && model.activeScanRequest?.kind == kind if isActive { scanActionButton(kind, "正在扫描\(kind.title)", icon, showsProgress: true) .buttonStyle(.borderedProminent) .tint(scanTint(for: kind)) } else { scanActionButton(kind, title, icon, showsProgress: false) .buttonStyle(.bordered) } } private func scanActionButton(_ kind: ScanKind, _ title: String, _ icon: String, showsProgress: Bool) -> some View { Button { model.startScan(ScanRequest(kind: kind, scope: selectedRootID == nil ? .wholeDrive : .recursiveCurrentFolder, rootID: selectedRootID, rootName: selectedRootName, excludeFilesSmallerThan: nil)) } label: { HStack(spacing: 6) { if showsProgress { ProgressView().controlSize(.small) } Label(title, systemImage: icon) } } .disabled(model.isScanning) } private func scanTint(for kind: ScanKind) -> Color { switch kind { case .emptyFolders: return .orange case .duplicates: return .blue case .similarFolders: return .green } } } struct ScanFolderPickerSheet: View { @ObservedObject var model: AppModel var title = "选择扫描文件夹" var selectionLabel = "选择此文件夹" let onSelect: (String?, String) -> Void @Environment(\.dismiss) private var dismiss @State private var path: [FolderPath] = [] private var currentID: String? { path.last?.id } private var currentName: String { path.isEmpty ? "整个云盘" : path.map(\.name).joined(separator: " / ") } var body: some View { ZStack { Color(nsColor: .underPageBackgroundColor).ignoresSafeArea() VStack(alignment: .leading, spacing: 16) { HStack(spacing: 13) { Image(systemName: "folder.badge.gearshape") .font(.title2.weight(.semibold)) .foregroundStyle(.orange) .frame(width: 46, height: 46) .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) VStack(alignment: .leading, spacing: 3) { Text(title).font(.title2.weight(.bold)) Text("打开文件夹继续向下浏览,在底部选择当前位置。") .font(.caption).foregroundStyle(.secondary) } Spacer() Button { dismiss() } label: { Image(systemName: "xmark") } .buttonStyle(.bordered).help("关闭") } folderBreadcrumb Group { if model.isLoadingScanPicker { VStack(spacing: 12) { ProgressView(); Text("正在读取文件夹…").foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if model.scanPickerFolders.isEmpty { ContentUnavailableView("没有子文件夹", systemImage: "folder", description: Text("可以选择当前文件夹作为扫描范围。")) } else { List(model.scanPickerFolders) { folder in Button { open(folder) } label: { HStack(spacing: 12) { Image(systemName: "folder.fill") .font(.title3).foregroundStyle(.orange) .frame(width: 32, height: 32) .background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) Text(folder.name).font(.body.weight(.medium)).lineLimit(1) Spacer() Image(systemName: "chevron.right").font(.caption.weight(.bold)).foregroundStyle(.tertiary) } .padding(.vertical, 5) .contentShape(Rectangle()) } .buttonStyle(.plain) } .listStyle(.inset) .scrollContentBackground(.hidden) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(.white.opacity(0.32), lineWidth: 1) } HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text("当前位置").font(.caption).foregroundStyle(.secondary) Text(currentName).font(.callout.weight(.semibold)).lineLimit(1) } Spacer() Button("取消") { dismiss() }.keyboardShortcut(.cancelAction) Button { onSelect(currentID, currentName) dismiss() } label: { Label(path.isEmpty ? "选择整个云盘" : selectionLabel, systemImage: "checkmark") } .buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction) } } .padding(24) } .frame(minWidth: 640, minHeight: 560) .task { await model.loadScanPickerFolders() } } private var folderBreadcrumb: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { Button { navigateToRoot() } label: { Label("整个云盘", systemImage: "externaldrive.fill") } .buttonStyle(.plain).foregroundStyle(path.isEmpty ? .primary : .secondary) ForEach(Array(path.enumerated()), id: \.element.id) { index, folder in Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary) Button(folder.name) { navigate(to: index) } .buttonStyle(.plain) .foregroundStyle(index == path.count - 1 ? .primary : .secondary) .fontWeight(index == path.count - 1 ? .semibold : .regular) } } .padding(.horizontal, 12).padding(.vertical, 8) } .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 11, style: .continuous)) } private func open(_ folder: CloudFile) { guard !model.isLoadingScanPicker else { return } path.append(FolderPath(id: folder.id, name: folder.name)) Task { await model.loadScanPickerFolders(parentID: folder.id) } } private func navigateToRoot() { guard !model.isLoadingScanPicker else { return } path = [] Task { await model.loadScanPickerFolders() } } private func navigate(to index: Int) { guard !model.isLoadingScanPicker, path.indices.contains(index) else { return } path.removeSubrange((index + 1).. = [] @State private var showDeletionConfirmation = false @State private var detailFolder: CloudFile? private var resultItems: [ScanItem] { if model.activeScanRequest?.kind == kind, model.isScanning { return model.liveScanItems } return model.scanResult?.request.kind == kind ? model.scanResult?.items ?? [] : [] } private var completedResult: ScanResult? { guard model.scanResult?.request.kind == kind else { return nil } return model.scanResult } private var selectedDuplicateSize: Int64 { resultItems.reduce(into: Int64(0)) { total, item in guard case .duplicate(let group) = item else { return } total += group.files.filter { selectedIDs.contains($0.id) }.compactMap(\.size).reduce(0, +) } } private var selectedDuplicateSizeText: String { let formatter = ByteCountFormatter() formatter.countStyle = .file return formatter.string(fromByteCount: selectedDuplicateSize) } var body: some View { ToolPageCard(icon: kind.icon, tint: scanTint, title: "\(kind.title)扫描结果", detail: resultSummary) { scanStatus if model.isScanning { Divider() } if resultItems.isEmpty { EmptyState(icon: kind.icon, title: model.isScanning ? "正在扫描\(kind.title)" : "没有发现\(kind.title)", subtitle: model.isScanning ? "符合当前扫描类型的项目会显示在这里。" : "本次扫描没有发现符合条件的项目。") } else { if kind == .emptyFolders { Toggle("全选空文件夹", isOn: Binding(get: { selectedIDs.count == resultItems.count }, set: { selectedIDs = $0 ? Set(resultItems.map(\.id)) : [] })) } else if kind == .duplicates { HStack { Text("勾选项将被删除;每组始终至少保留一个文件。").font(.caption).foregroundStyle(.secondary) Spacer() Text("已选 \(selectedIDs.count) 项 · \(selectedDuplicateSizeText)").font(.caption.weight(.semibold)).monospacedDigit().foregroundStyle(.blue) duplicateQuickSelectionMenu } } else { Text("每组默认保留第一项;勾选其余项后才可删除。请先逐项复核。").font(.caption).foregroundStyle(.secondary) } ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(resultItems) { item in scanResultRow(item) } }.padding(.vertical, 2) } .frame(maxHeight: .infinity) Button(role: .destructive) { showDeletionConfirmation = true } label: { HStack(spacing: 7) { if model.isDeletingScanResults { ProgressView().controlSize(.small) } Label(model.isDeletingScanResults ? deletionButtonTitle : (kind == .emptyFolders ? "删除已选 \(selectedIDs.count) 个空文件夹" : "删除已选 \(selectedIDs.count) 项"), systemImage: "trash") } }.buttonStyle(.borderedProminent).disabled(selectedIDs.isEmpty || model.isScanning || model.isDeletingScanResults) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .confirmationDialog(kind == .emptyFolders ? "确认删除已选空文件夹?" : "确认删除已选项目?", isPresented: $showDeletionConfirmation) { Button(kind == .emptyFolders ? "删除 \(selectedIDs.count) 个文件夹" : "删除 \(selectedIDs.count) 项", role: .destructive) { let ids = selectedFileIDsForDeletion Task { if kind == .emptyFolders { await model.deleteScannedEmptyFolders(ids) } else { await model.deleteScannedFiles(ids) } selectedIDs.subtract(ids) } } } message: { Text(kind == .emptyFolders ? "执行前会逐个重新验证为空,发生变化的目录会被跳过。" : "此操作不可撤销。请确认每个分组至少保留一项。") } .sheet(item: $detailFolder) { FileDetailsSheet(model: model, file: $0) } } private var resultSummary: String { if model.isScanning { return "正在扫描\(kind.title) · \(model.activeScanRequest?.rootName ?? "所选范围"),已发现 \(resultItems.count) 项。" } if let completedResult { return "已扫描 \(completedResult.foldersScanned) 个文件夹、\(completedResult.filesScanned) 个文件,发现 \(resultItems.count) 项。" } return model.scanProgress.phase } private var scanStatus: some View { HStack(spacing: 10) { if model.isScanning || model.isDeletingScanResults { ProgressView().controlSize(.small) } VStack(alignment: .leading, spacing: 2) { Text(model.isDeletingScanResults ? model.scanDeletionProgress.phase : model.scanProgress.phase).font(.callout.weight(.semibold)) if model.isDeletingScanResults { Text("已完成 \(model.scanDeletionProgress.completed) / \(model.scanDeletionProgress.total)").font(.caption).foregroundStyle(.secondary).monospacedDigit() } else { Text("文件夹 \(model.scanProgress.foldersVisited) · 文件 \(model.scanProgress.filesVisited)").font(.caption).foregroundStyle(.secondary) } } Spacer() if model.isScanning { Button("取消扫描") { model.cancelScan() }.buttonStyle(.bordered) } } .padding(10) .background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } private var deletionButtonTitle: String { "正在删除 \(model.scanDeletionProgress.completed)/\(model.scanDeletionProgress.total)" } private var scanTint: Color { switch kind { case .emptyFolders: return .orange case .duplicates: return .blue case .similarFolders: return .green } } private var duplicateQuickSelectionMenu: some View { Menu { Button("保留路径最短,选择其余项") { selectDuplicateFilesKeeping { shortestPathFile(in: $0) } } Button("保留路径最长,选择其余项") { selectDuplicateFilesKeeping { longestPathFile(in: $0) } } Divider() Button("保留最大文件,选择其余项") { selectDuplicateFilesKeeping { $0.max { ($0.size ?? 0) < ($1.size ?? 0) } } } Button("保留最新文件,选择其余项") { selectDuplicateFilesKeeping { $0.max { $0.modifiedAt < $1.modifiedAt } } } Divider() Button("清除快速选择") { clearDuplicateSelection() } } label: { Label("快速选择", systemImage: "wand.and.stars") } .menuStyle(.borderlessButton) .fixedSize() } private func selectDuplicateFilesKeeping(_ choose: ([CloudFile]) -> CloudFile?) { for item in resultItems { guard case .duplicate(let group) = item, let kept = choose(group.files) else { continue } let groupIDs = Set(group.files.map(\.id)) selectedIDs.subtract(groupIDs) selectedIDs.formUnion(groupIDs.subtracting([kept.id])) } } private func clearDuplicateSelection() { let ids = Set(resultItems.flatMap { item -> [String] in guard case .duplicate(let group) = item else { return [] } return group.files.map(\.id) }) selectedIDs.subtract(ids) } private func shortestPathFile(in files: [CloudFile]) -> CloudFile? { files.min { if $0.pathDepth != $1.pathDepth { return $0.pathDepth < $1.pathDepth } if $0.cloudPath.count != $1.cloudPath.count { return $0.cloudPath.count < $1.cloudPath.count } return $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending } } private func longestPathFile(in files: [CloudFile]) -> CloudFile? { files.max { if $0.pathDepth != $1.pathDepth { return $0.pathDepth < $1.pathDepth } if $0.cloudPath.count != $1.cloudPath.count { return $0.cloudPath.count < $1.cloudPath.count } return $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending } } private func duplicateSelectionBinding(for file: CloudFile, in group: DuplicateGroup) -> Binding { Binding( get: { selectedIDs.contains(file.id) }, set: { shouldDelete in if !shouldDelete { selectedIDs.remove(file.id); return } let selectedInGroup = group.files.filter { selectedIDs.contains($0.id) }.count if selectedInGroup < group.files.count - 1 { selectedIDs.insert(file.id) } } ) } private var selectedFileIDsForDeletion: Set { Set(resultItems.flatMap { item -> [String] in switch item { case .emptyFolder(let folder): return selectedIDs.contains(item.id) ? [folder.id] : [] case .duplicate(let group): return group.files.filter { selectedIDs.contains($0.id) }.map(\.id) case .similar(let group): return group.folders.filter { selectedIDs.contains($0.id) }.map(\.id) } }) } @ViewBuilder private func scanResultRow(_ item: ScanItem) -> some View { switch item { case .emptyFolder(let folder): Toggle(isOn: Binding(get: { selectedIDs.contains(item.id) }, set: { if $0 { selectedIDs.insert(item.id) } else { selectedIDs.remove(item.id) } })) { HStack { Image(systemName: "folder").foregroundStyle(.orange); Text(folder.name); Spacer(); Text("空文件夹").font(.caption).foregroundStyle(.secondary); Text(folder.id).font(.caption.monospaced()).foregroundStyle(.secondary) } } .padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) .contextMenu { Button { detailFolder = folder } label: { Label("查看详情", systemImage: "info.circle") } Button(role: .destructive) { selectedIDs.insert(item.id); showDeletionConfirmation = true } label: { Label("删除此空文件夹…", systemImage: "trash") } } case .duplicate(let group): VStack(alignment: .leading, spacing: 8) { HStack { Text("重复组 · \(group.files.count) 项").font(.caption.weight(.bold)).foregroundStyle(.blue); Spacer(); Text("GCID \(group.id)").font(.caption2.monospaced()).foregroundStyle(.tertiary).lineLimit(1) } ForEach(group.files) { file in let selected = selectedIDs.contains(file.id) Toggle(isOn: duplicateSelectionBinding(for: file, in: group)) { HStack(alignment: .top, spacing: 10) { Image(systemName: file.icon).foregroundStyle(.blue).frame(width: 22) VStack(alignment: .leading, spacing: 3) { Text(file.name).font(.callout.weight(.medium)).lineLimit(1) Text(file.cloudPath).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled) } Spacer() VStack(alignment: .trailing, spacing: 3) { Text(file.formattedSize).font(.caption.monospacedDigit()).foregroundStyle(.secondary) Text(selected ? "将删除" : "保留").font(.caption.weight(.semibold)).foregroundStyle(selected ? .red : .green) } } } .disabled(!selected && group.files.filter { !selectedIDs.contains($0.id) }.count <= 1) } }.padding(12).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) case .similar(let group): VStack(alignment: .leading, spacing: 8) { Text("相似名称组 · \(group.folders.count) 个文件夹(默认保留第一项)").font(.caption.weight(.bold)).foregroundStyle(.green) ForEach(Array(group.folders.enumerated()), id: \.element.id) { index, folder in Toggle(isOn: Binding(get: { selectedIDs.contains(folder.id) }, set: { if $0 { selectedIDs.insert(folder.id) } else { selectedIDs.remove(folder.id) } })) { HStack { Image(systemName: "folder.fill").foregroundStyle(.orange); Text(folder.name); Spacer(); Text(index == 0 ? "保留" : folder.id).font(.caption.monospaced()).foregroundStyle(index == 0 ? .green : .secondary) } }.disabled(index == 0) } }.padding(12).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) } } } private struct CleanupToolPage: View { @ObservedObject var model: AppModel @State private var confirmed = false var body: some View { ToolPageCard(icon: "trash.slash", tint: .orange, title: "清理范围", detail: "将从云盘根目录递归扫描。只有完全没有子文件和子目录的文件夹会被删除。") { Toggle("我已了解该操作会删除空文件夹", isOn: $confirmed) Button { Task { await model.cleanEmptyFolders() } } label: { Label(model.isBusy ? "正在清理…" : "开始扫描并清理", systemImage: "sparkles") }.buttonStyle(.borderedProminent).tint(.orange).disabled(!confirmed || model.isBusy) } } } private struct DuplicateToolPage: View { @ObservedObject var model: AppModel var body: some View { VStack(spacing: 16) { ToolPageCard(icon: "square.on.square", tint: .blue, title: "扫描选项", detail: "扫描当前列表中的文件,按 GCID 归组。") { HStack { Button { Task { await model.scanDuplicates() } } label: { Label("扫描全部文件", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.blue) Button { Task { await model.scanDuplicates(excludingSmallFiles: true) } } label: { Label("排除小于 1 MB", systemImage: "line.3.horizontal.decrease.circle") }.buttonStyle(.bordered) if model.isAnalyzing { ProgressView().controlSize(.small) } } } ToolPageCard(icon: "list.bullet.rectangle", tint: .purple, title: "扫描结果", detail: model.duplicateGroups.isEmpty ? "尚未发现重复文件" : "发现 \(model.duplicateGroups.count) 组重复文件") { if model.duplicateGroups.isEmpty { EmptyState(icon: "checkmark.seal", title: "暂无重复项", subtitle: "点击上方按钮开始扫描当前列表。") } else { ForEach(model.duplicateGroups) { group in DuplicateGroupRow(group: group) } } } } } } private struct DuplicateGroupRow: View { let group: DuplicateGroup var body: some View { VStack(alignment: .leading, spacing: 9) { HStack { Text("GCID \(group.id)").font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1); Spacer(); Text("\(group.files.count) 个").font(.caption.weight(.bold)).foregroundStyle(.orange) } ForEach(group.files) { file in HStack { Image(systemName: file.icon).frame(width: 22); Text(file.name).lineLimit(1); Spacer(); Text(file.formattedSize).foregroundStyle(.secondary) }.font(.callout) } }.padding(13).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } } private struct SimilarFoldersToolPage: View { @ObservedObject var model: AppModel var body: some View { ToolPageCard(icon: "rectangle.3.group", tint: .green, title: "当前目录相似文件夹", detail: "忽略空格、点号、括号、连字符和大小写后比较名称。") { Button { model.findSimilarFolders() } label: { Label("查找相似文件夹", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).tint(.green) if model.similarFolders.isEmpty { EmptyState(icon: "folder.badge.questionmark", title: "暂无结果", subtitle: "点击按钮扫描当前目录。") } else { ForEach(model.similarFolders) { folder in HStack { Image(systemName: "folder.fill").foregroundStyle(.orange); Text(folder.name); Spacer(); Text(folder.formattedSize).foregroundStyle(.secondary) }.padding(11).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) } } } } } private struct TMDBToolPage: View { @ObservedObject var model: AppModel @State private var workflow = TMDBWorkflowConfig.load() @State private var sourceID: String? @State private var sourceName = "云盘根目录" @State private var destinationID: String? @State private var destinationName = "云盘根目录" @State private var showRunConfirmation = false var body: some View { VStack(spacing: 16) { ToolPageCard(icon: "film.stack", tint: .purple, title: "TMDB 刮削任务", detail: "按“选择范围 → 建立任务 → 预览匹配 → 确认执行”运行;预览阶段不会修改云盘。") { HStack { Image(systemName: model.tmdbAPIKey.isEmpty ? "xmark.circle.fill" : "checkmark.circle.fill").foregroundStyle(model.tmdbAPIKey.isEmpty ? .red : .green); Text(model.tmdbAPIKey.isEmpty ? "请先在工作区设置配置 TMDB API Key" : "TMDB API Key 已配置") } Picker("媒体类型", selection: $workflow.mediaKind) { ForEach(TMDBMediaKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) Picker("执行方式", selection: $workflow.operation) { ForEach(TMDBOperation.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) scopePicker(title: "源目录", name: $sourceName, id: $sourceID) if workflow.operation == .organize { scopePicker(title: "目标目录", name: $destinationName, id: $destinationID) } Toggle("递归扫描源目录", isOn: $workflow.sourceRecursive) HStack { TextField("文件夹模板,例如 {title} ({year})", text: $workflow.folderTemplate); TextField("文件模板", text: $workflow.fileTemplate) }.textFieldStyle(.roundedBorder) HStack { TextField("扩展名,逗号分隔", text: $workflow.allowedExtensions); Stepper("最小体积 \(workflow.minimumSizeMB) MB", value: $workflow.minimumSizeMB, in: 0...20_000, step: 50) }.textFieldStyle(.roundedBorder) HStack { Toggle("写入 NFO", isOn: $workflow.createNFO); Toggle("下载海报(即将支持)", isOn: $workflow.downloadPoster).disabled(true); Toggle("清理空目录(即将支持)", isOn: $workflow.cleanEmptyFolders).disabled(true) } HStack { Button("保存配置") { model.saveTMDBWorkflow(workflow) }.buttonStyle(.bordered); Spacer(); Button { model.saveTMDBWorkflow(workflow); Task { await model.prepareTMDBJobs(sourceID: sourceID, recursive: workflow.sourceRecursive, minimumSizeMB: workflow.minimumSizeMB, extensionsText: workflow.allowedExtensions) } } label: { Label("建立任务", systemImage: "list.bullet.clipboard") }.buttonStyle(.borderedProminent).tint(.purple).disabled(model.tmdbAPIKey.isEmpty || model.isRunningTMDBJob) } } ToolPageCard(icon: "checklist", tint: .blue, title: "任务预览与执行", detail: model.tmdbJobs.isEmpty ? "尚未建立任务。" : "共 \(model.tmdbJobs.count) 项;先匹配,再确认执行。") { if model.tmdbJobs.isEmpty { EmptyState(icon: "film", title: "等待建立任务", subtitle: "任务会按扩展名、最小体积和扫描范围过滤。") } else { HStack { Button { Task { await model.matchTMDBJobs() } } label: { Label("搜索候选", systemImage: "magnifyingglass") }.buttonStyle(.borderedProminent).disabled(model.isRunningTMDBJob); Button { showRunConfirmation = true } label: { Label(workflow.operation == .preview ? "预览模式,不执行整理" : "确认执行整理", systemImage: "play.fill") }.buttonStyle(.bordered).disabled(model.isRunningTMDBJob || workflow.operation == .preview); if model.isRunningTMDBJob { ProgressView().controlSize(.small) } } ForEach(model.tmdbJobs) { job in VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: job.state == .completed ? "checkmark.circle.fill" : (job.state == .failed ? "xmark.circle.fill" : "film" )).foregroundStyle(job.state == .failed ? .red : .purple); VStack(alignment: .leading) { Text(job.file.name).lineLimit(1); Text(job.note).font(.caption).foregroundStyle(.secondary).lineLimit(1) }; Spacer(); if job.selectedCandidate != nil { Toggle("执行", isOn: Binding(get: { job.isApproved }, set: { _ in model.toggleTMDBApproval(jobID: job.id) })).toggleStyle(.switch).labelsHidden() } } if !job.candidates.isEmpty { Picker("选择 TMDB 匹配", selection: Binding(get: { job.selectedCandidateID }, set: { model.selectTMDBCandidate(jobID: job.id, candidateID: $0) })) { Text("跳过此项").tag(Int?.none); ForEach(job.candidates) { candidate in Text("\(candidate.title) \(candidate.releaseDate.prefix(4)) · \(candidate.mediaType.title)").tag(Optional(candidate.id)) } }.labelsHidden() } if let candidate = job.selectedCandidate { Text(candidate.overview.isEmpty ? "暂无简介" : candidate.overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) } }.padding(11).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 11)) } } } } .confirmationDialog("确认执行已选择的 TMDB 整理任务?", isPresented: $showRunConfirmation) { Button("开始整理", role: .destructive) { Task { await model.runTMDBJobs(destinationID: destinationID) } } } message: { Text("只会处理你手动选择并开启“执行”的条目。媒体文件将移动到目标目录;NFO 会在移动后写入。") } .onAppear { workflow = model.tmdbWorkflow syncTMDBTarget() } .onChange(of: model.tmdbTarget?.id) { _, _ in syncTMDBTarget() } } private func syncTMDBTarget() { guard let target = model.tmdbTarget, target.isDirectory else { return } sourceID = target.id sourceName = target.name } private func scopePicker(title: String, name: Binding, id: Binding) -> some View { HStack { VStack(alignment: .leading, spacing: 2) { Text(title).font(.caption).foregroundStyle(.secondary); Text(name.wrappedValue).font(.callout.weight(.semibold)).lineLimit(1) }; Spacer(); Button("使用当前目录") { id.wrappedValue = model.currentScanRootID; name.wrappedValue = model.scanScopeLocationName }.buttonStyle(.bordered); Button("根目录") { id.wrappedValue = nil; name.wrappedValue = "云盘根目录" }.buttonStyle(.bordered) }.padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) } } private struct ToolSettingsPage: View { @ObservedObject var model: AppModel @State private var key = "" @State private var proxyHost = "" @State private var proxyPort = "" private var isProxyValid: Bool { proxyConfigurationIsValid(host: proxyHost, port: proxyPort) } var body: some View { ToolPageCard(icon: "gearshape.2", tint: .gray, title: "本机配置", detail: "这些配置仅保存在当前 Mac。") { SecureField("TMDB API Key", text: $key).textFieldStyle(.roundedBorder) HStack { TextField("代理地址,例如 127.0.0.1", text: $proxyHost).textFieldStyle(.roundedBorder); TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 100) } if !isProxyValid { Label("请同时填写有效的代理地址和端口", systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.red) } HStack { Spacer(); Button("清空") { key = ""; proxyHost = ""; proxyPort = "" }; Button("保存配置") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort) }.buttonStyle(.borderedProminent).tint(.orange).disabled(!isProxyValid) } }.onAppear { key = model.tmdbAPIKey; proxyHost = model.tmdbProxyHost; proxyPort = model.tmdbProxyPort } } } struct ToolPageCard: View { let icon: String let tint: Color let title: String let detail: String @ViewBuilder let content: Content init(icon: String, tint: Color, title: String, detail: String, @ViewBuilder content: () -> Content) { self.icon = icon; self.tint = tint; self.title = title; self.detail = detail; self.content = content() } var body: some View { VStack(alignment: .leading, spacing: 15) { HStack(spacing: 11) { Image(systemName: icon).foregroundStyle(tint).frame(width: 36, height: 36).background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)); VStack(alignment: .leading, spacing: 3) { Text(title).font(.headline); Text(detail).font(.caption).foregroundStyle(.secondary) }; Spacer() } Divider(); content }.padding(18).softCard(radius: 22) } } struct CreateFolderSheet: View { @Binding var name: String let onCreate: (String) -> Void let onCancel: () -> Void @FocusState private var focused: Bool var body: some View { VStack(alignment: .leading, spacing: 18) { SheetHeader(icon: "folder.badge.plus", title: "新建文件夹", subtitle: "在当前目录创建一个新的云端文件夹") TextField("文件夹名称", text: $name) .textFieldStyle(.plain) .focused($focused) .padding(.horizontal, 13) .frame(height: 42) .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 11).stroke(focused ? Color.orange : Color.black.opacity(0.10), lineWidth: 1) } .onSubmit { createIfPossible() } HStack { Spacer(); Button("取消", action: onCancel).keyboardShortcut(.cancelAction); Button("创建") { createIfPossible() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .padding(24) .frame(width: 380) .softCard(radius: 24) .onAppear { focused = true } } private func createIfPossible() { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } onCreate(trimmed) } } struct RenameSheet: View { let file: CloudFile let onRename: (String) -> Void @Environment(\.dismiss) private var dismiss @State private var name: String @FocusState private var focused: Bool init(file: CloudFile, onRename: @escaping (String) -> Void) { self.file = file self.onRename = onRename _name = State(initialValue: file.name) } var body: some View { VStack(alignment: .leading, spacing: 18) { SheetHeader(icon: "pencil", title: "重命名", subtitle: file.name) TextField("名称", text: $name) .textFieldStyle(.plain) .focused($focused) .padding(.horizontal, 13) .frame(height: 42) .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 11).stroke(focused ? Color.orange : Color.black.opacity(0.10), lineWidth: 1) } .onSubmit { saveIfPossible() } HStack { Spacer(); Button("取消") { dismiss() }.keyboardShortcut(.cancelAction); Button("保存") { saveIfPossible() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || name == file.name) } } .padding(24) .frame(width: 400) .softCard(radius: 24) .onAppear { focused = true } } private func saveIfPossible() { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } onRename(trimmed) dismiss() } } struct WorkspaceSettings: View { @ObservedObject var model: AppModel @Environment(\.dismiss) private var dismiss @State private var key = "" @State private var proxyHost = "" @State private var proxyPort = "" private var isProxyValid: Bool { proxyConfigurationIsValid(host: proxyHost, port: proxyPort) } var body: some View { VStack(alignment: .leading, spacing: 18) { SheetHeader(icon: "gearshape.2.fill", title: "工作区设置", subtitle: "配置本机工具能力,不会上传到云端") VStack(alignment: .leading, spacing: 10) { Label("TMDB 识别", systemImage: "film.fill").font(.caption.weight(.bold)).foregroundStyle(.secondary) SecureField("本地记住的 API Key", text: $key).textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 10) { Label("HTTP 代理", systemImage: "network").font(.caption.weight(.bold)).foregroundStyle(.secondary) HStack(spacing: 8) { TextField("地址,例如 127.0.0.1", text: $proxyHost).textFieldStyle(.roundedBorder) TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 88) } Text("代理仅用于 TMDB 请求。留空表示直连。") .font(.caption).foregroundStyle(.secondary) if !isProxyValid { Label("请同时填写代理地址和 1-65535 之间的端口", systemImage: "exclamationmark.triangle.fill") .font(.caption).foregroundStyle(.red) } } HStack { Spacer(); Button("取消") { dismiss() }.keyboardShortcut(.cancelAction); Button("保存") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort); dismiss() }.buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(!isProxyValid) } } .padding(24) .frame(width: 520) .softCard(radius: 24) .onAppear { key = model.tmdbAPIKey; proxyHost = model.tmdbProxyHost; proxyPort = model.tmdbProxyPort } } } struct DuplicateResults: View { @ObservedObject var model: AppModel @Environment(\.dismiss) private var dismiss var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { SheetHeader(icon: "square.on.square", title: "重复文件", subtitle: model.duplicateGroups.isEmpty ? "按 GCID 扫描当前列表" : "发现 \(model.duplicateGroups.count) 组重复资源"); Spacer(); Button("关闭") { dismiss() } } if model.isAnalyzing { VStack(spacing: 12) { ProgressView(); Text("正在按 GCID 扫描…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, minHeight: 260) } else if model.duplicateGroups.isEmpty { EmptyState(icon: "checkmark.seal", title: "没有发现重复文件", subtitle: "当前目录看起来很干净。") .frame(maxWidth: .infinity, minHeight: 260) } else { List(model.duplicateGroups) { group in VStack(alignment: .leading, spacing: 10) { HStack { Text("GCID").font(.caption.weight(.bold)).foregroundStyle(.secondary) Text(group.id).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) Spacer() Text("\(group.files.count) 个").font(.caption.weight(.bold)).foregroundStyle(.orange) } ForEach(group.files) { file in HStack(spacing: 10) { Image(systemName: file.icon).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 24) Text(file.name).lineLimit(1) Spacer() Text(file.formattedSize).foregroundStyle(.secondary).monospacedDigit() } .font(.callout) } } .padding(.vertical, 8) } .listStyle(.inset) .frame(minHeight: 320) } } .padding(24) .frame(width: 640, height: 500) .softCard(radius: 26) } } private struct SheetHeader: View { let icon: String let title: String let subtitle: String var body: some View { HStack(spacing: 12) { Image(systemName: icon) .font(.title3.weight(.semibold)) .foregroundStyle(.orange) .frame(width: 42, height: 42) .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) VStack(alignment: .leading, spacing: 3) { Text(title).font(.title3.bold()) Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(2) } } } } private struct EmptyState: View { let icon: String let title: String let subtitle: String var body: some View { VStack(spacing: 12) { Image(systemName: icon).font(.system(size: 42, weight: .light)).foregroundStyle(.tertiary) Text(title).font(.title3.weight(.semibold)) Text(subtitle).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) } .padding(28) } } private func jsonText(_ value: JSONValue) -> String { guard let data = try? JSONEncoder().encode(value), let object = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), let text = String(data: pretty, encoding: .utf8) else { return "--" }; return text } private struct EmptyFilesView: View { @ObservedObject var model: AppModel var body: some View { EmptyState(icon: model.section.icon, title: "这里还没有文件", subtitle: model.section == .recycle ? "回收站为空,暂时没有可恢复内容。" : "上传文件或创建文件夹开始使用。") .frame(maxWidth: .infinity, maxHeight: .infinity) } } private struct EmptySectionView: View { let section: WorkspaceSection var body: some View { EmptyState(icon: section.icon, title: section == .cloud ? "云下载" : "我的分享", subtitle: "接口已就绪,后续可以在这里管理相关内容。") .frame(maxWidth: .infinity, maxHeight: .infinity) } } private struct BrandMark: View { let size: CGFloat var body: some View { if let path = Bundle.main.path(forResource: "光鸭", ofType: "svg"), let image = NSImage(contentsOfFile: path) { Image(nsImage: image).resizable().aspectRatio(contentMode: .fit).frame(width: size, height: size) } else { RoundedRectangle(cornerRadius: size * 0.22).fill(Color.orange).frame(width: size, height: size).overlay { Image(systemName: "bird.fill").foregroundStyle(.white) } } } } private struct QRCodeImage: View { let payload: String var body: some View { if let image = makeImage() { Image(nsImage: image).interpolation(.none).resizable().scaledToFit() } else { Image(systemName: "qrcode").font(.largeTitle) } } private func makeImage() -> NSImage? { let filter = CIFilter.qrCodeGenerator(); filter.message = Data(payload.utf8); filter.correctionLevel = "M" guard let output = filter.outputImage else { return nil } let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)); let context = CIContext() guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil } return NSImage(cgImage: cgImage, size: NSSize(width: 210, height: 210)) } } struct LoginView_Previews: PreviewProvider { static var previews: some View { LoginView(model: AppModel(), autoStartQR: false) .previewDisplayName("登录页") } }