import SwiftUI import UniformTypeIdentifiers enum WorkspaceTool: String, CaseIterable, Identifiable, Hashable { case media = "影视库" case scan = "文件扫描与清理" case rename = "批量重命名" case fastTransfer = "秒传工具" case tmdb = "TMDB 整理" case settings = "工作区设置" var id: String { rawValue } var icon: String { switch self { case .media: return "play.tv.fill" case .scan: return "magnifyingglass.circle.fill" case .rename: return "character.cursor.ibeam" case .fastTransfer: return "bolt.badge.clock" case .tmdb: return "film.stack" case .settings: return "gearshape" } } var subtitle: String { switch self { case .media: return "海报墙、媒体详情与播放入口" case .scan: return "空文件夹、重复文件与相似文件夹统一扫描" case .rename: return "按字符、正则和组合规则批量修改名称" case .fastTransfer: return "导入秒传 JSON,或从本地文件生成 GCID JSON" case .tmdb: return "配置并执行媒体识别整理" case .settings: return "代理和本机工具配置" } } } enum WorkspaceMode: String, CaseIterable, Identifiable { case files = "光鸭云盘" case media = "光鸭影视" var id: String { rawValue } var icon: String { self == .files ? "folder" : "play.tv.fill" } } private enum ToolbarSearchScope: Hashable { case files case media } struct CompactHoverHint: ViewModifier { let title: String @State private var isHovering = false func body(content: Content) -> some View { content .onHover { isHovering = $0 } .popover(isPresented: $isHovering, arrowEdge: .leading) { Text(title) .font(.callout.weight(.medium)) .padding(.horizontal, 12).padding(.vertical, 8) .fixedSize() } } } extension View { @ViewBuilder func compactHoverHint(_ title: String, enabled: Bool = true) -> some View { if enabled { modifier(CompactHoverHint(title: title)) } else { self } } } struct WorkspaceView: View { @ObservedObject var model: AppModel @State private var selectedFileIDs: Set = [] @State private var renameFile: CloudFile? @State private var isCreatingFolder = false @State private var isImporting = false @State private var isShowingSettings = false @State private var isShowingDuplicates = false @State private var isInspectorPresented = false @State private var selectedTool: WorkspaceTool? @State private var folderName = "" @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var globalSearchText = "" @State private var workspaceMode: WorkspaceMode = .files @State private var isCompactWindow = false @State private var sidebarWasAutoCollapsed = false @State private var retainedTools: Set = [] @State private var hasVisitedMediaMode = false @State private var isToolbarSearchExpanded = false @State private var isDualPanel = false @State private var isWindowDropTarget = false @FocusState private var toolbarSearchFocus: ToolbarSearchScope? private var selectedFile: CloudFile? { guard selectedFileIDs.count == 1, let id = selectedFileIDs.first else { return nil } return (model.files + model.globalFileSearchResults).first { $0.id == id } } private var dualPanelContent: AnyView? { guard isDualPanel, selectedTool == nil else { return nil } return AnyView(DualPanelView(model: model)) } private var filesBrowserContent: some View { FilesBrowser( model: model, selectedFileIDs: $selectedFileIDs, globalSearchText: $globalSearchText, renameFile: $renameFile, isCreatingFolder: $isCreatingFolder, isImporting: $isImporting, isShowingSettings: $isShowingSettings, isShowingDuplicates: $isShowingDuplicates, isDualPanel: $isDualPanel, onPreview: { PlayerWindowCoordinator.shared.open(model: model, file: $0) }, onOpenTool: { selectedTool = $0 }, onShowInspector: { isInspectorPresented = true }, dualPanelContent: dualPanelContent ) .frame(minWidth: 680, maxWidth: .infinity, maxHeight: .infinity) .inspector(isPresented: $isInspectorPresented) { FileInspector( model: model, file: selectedFile, onPreview: { PlayerWindowCoordinator.shared.open(model: model, file: $0) }, onClose: { isInspectorPresented = false } ) .inspectorColumnWidth(min: 280, ideal: 320, max: 420) } } var body: some View { GeometryReader { geometry in ZStack { AppBackdrop() ZStack { NavigationSplitView(columnVisibility: $columnVisibility) { WorkspaceSidebar(model: model, selectedTool: $selectedTool, isCompact: isCompactWindow) .navigationSplitViewColumnWidth(min: isCompactWindow ? 64 : 210, ideal: isCompactWindow ? 64 : 238, max: isCompactWindow ? 64 : 280) } detail: { ZStack { // Always render FilesBrowser — it provides the // action bar (upload / toggle / refresh) and // breadcrumb. In dual-panel mode its file-list // content is replaced by DualPanelView. filesBrowserContent ForEach(retainedToolList) { tool in ToolWorkspacePage(model: model, tool: tool) .opacity(selectedTool == tool ? 1 : 0) .allowsHitTesting(selectedTool == tool) .accessibilityHidden(selectedTool != tool) } } } .opacity(workspaceMode == .files ? 1 : 0) .allowsHitTesting(workspaceMode == .files) .accessibilityHidden(workspaceMode != .files) if hasVisitedMediaMode || workspaceMode == .media { MediaLibraryPage(model: model) .opacity(workspaceMode == .media ? 1 : 0) .allowsHitTesting(workspaceMode == .media) .accessibilityHidden(workspaceMode != .media) } } .scrollContentBackground(.hidden) .toolbar { ToolbarItem(placement: .principal) { WorkspaceModeSwitcher(selection: $workspaceMode, isCompact: isCompactWindow) } ToolbarItem(placement: .primaryAction) { HStack(spacing: 6) { if let scope = toolbarSearchScope { toolbarSearchControl(scope) } } } } } .onDrop(of: [UTType.fileURL], isTargeted: $isWindowDropTarget) { providers in for provider in providers { _ = provider.loadObject(ofClass: URL.self) { url, _ in guard let url else { return } Task { @MainActor in await model.upload(url: url) } } } return !providers.isEmpty } .overlay { if isWindowDropTarget { RoundedRectangle(cornerRadius: 20, style: .continuous) .stroke(Color.orange, style: StrokeStyle(lineWidth: 3, dash: [8, 5])) .fill(Color.orange.opacity(0.12)) .padding(10) .allowsHitTesting(false) } } .onAppear { updateCompactLayout(for: geometry.size.width) } .onChange(of: geometry.size.width) { _, width in updateCompactLayout(for: width) } } .frame(minWidth: 860, minHeight: 640) .sheet(item: $renameFile) { file in RenameSheet(file: file) { name in Task { await model.rename(file, to: name) } } } .sheet(isPresented: $isCreatingFolder) { CreateFolderSheet(name: $folderName) { name in isCreatingFolder = false folderName = "" Task { await model.createFolder(name: name) } } onCancel: { isCreatingFolder = false; folderName = "" } } .sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) } .sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) } .fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in switch result { case .success(let urls): if let url = urls.first { Task { await model.upload(url: url) } } case .failure(let error): model.errorMessage = error.localizedDescription } } .alert("提示", isPresented: Binding(get: { !model.lastActionMessage.isEmpty }, set: { if !$0 { model.lastActionMessage = "" } })) { Button("确定", role: .cancel) { model.lastActionMessage = "" } } message: { Text(model.lastActionMessage) } .task { if model.files.isEmpty && !model.isLoadingFiles { await model.refresh() } } .onChange(of: selectedTool) { _, tool in if let tool { retainedTools.insert(tool) isInspectorPresented = false } if tool != nil { collapseToolbarSearch(clearQuery: false) } } .onChange(of: workspaceMode) { _, mode in selectedTool = nil isInspectorPresented = false collapseToolbarSearch(clearQuery: false) if mode == .media { hasVisitedMediaMode = true } else if isCompactWindow { columnVisibility = .detailOnly sidebarWasAutoCollapsed = true } } .onChange(of: model.section) { _, _ in selectedFileIDs = [] selectedTool = nil isInspectorPresented = false globalSearchText = "" model.clearGlobalFileSearch() collapseToolbarSearch(clearQuery: false) Task { await model.refresh() } } .onChange(of: model.files.map(\.id)) { _, ids in selectedFileIDs.formIntersection(Set(ids)) if selectedFileIDs.isEmpty { isInspectorPresented = false } } .onChange(of: model.folderPath) { _, _ in selectedFileIDs = [] isInspectorPresented = false globalSearchText = "" model.clearGlobalFileSearch() } .onChange(of: model.globalFileSearchResults.map(\.id)) { _, ids in selectedFileIDs.formIntersection(Set(ids)) if selectedFileIDs.isEmpty { isInspectorPresented = false } } } private func updateCompactLayout(for width: CGFloat) { let compact = width < 1120 guard compact != isCompactWindow else { return } isCompactWindow = compact guard workspaceMode == .files else { return } if sidebarWasAutoCollapsed { columnVisibility = .all; sidebarWasAutoCollapsed = false } } private var retainedToolList: [WorkspaceTool] { let activeTool = selectedTool.map { retainedTools.union([$0]) } ?? retainedTools return WorkspaceTool.allCases.filter(activeTool.contains) } private var toolbarSearchScope: ToolbarSearchScope? { if workspaceMode == .media { return .media } if selectedTool == nil, model.section == .files { return .files } return nil } @ViewBuilder private func toolbarSearchControl(_ scope: ToolbarSearchScope) -> some View { if isToolbarSearchExpanded { HStack(spacing: 6) { expandedToolbarSearchField(scope) Button { collapseToolbarSearch(clearQuery: true) } label: { Image(systemName: "xmark") } .buttonStyle(.plain) .help("关闭搜索") .accessibilityLabel("关闭搜索") } .padding(.horizontal, 8) .frame(width: 286, height: 30) .background(Color.primary.opacity(0.07), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) } else { Button { expandToolbarSearch(scope) } label: { Image(systemName: "magnifyingglass") } .help(scope == .files ? "搜索全盘文件和文件夹" : "搜索影视") .accessibilityLabel(scope == .files ? "搜索全盘文件和文件夹" : "搜索影视") } } @ViewBuilder private func expandedToolbarSearchField(_ scope: ToolbarSearchScope) -> some View { switch scope { case .files: TextField("搜索全盘文件和文件夹", text: $globalSearchText) .textFieldStyle(.plain) .focused($toolbarSearchFocus, equals: .files) .onChange(of: globalSearchText) { _, query in model.searchFilesGlobally(query) } case .media: TextField("搜索影视", text: $model.mediaLibrarySearchText) .textFieldStyle(.plain) .focused($toolbarSearchFocus, equals: .media) } } private func expandToolbarSearch(_ scope: ToolbarSearchScope) { withAnimation(.easeInOut(duration: 0.18)) { isToolbarSearchExpanded = true } DispatchQueue.main.async { toolbarSearchFocus = scope } } private func collapseToolbarSearch(clearQuery: Bool) { toolbarSearchFocus = nil withAnimation(.easeInOut(duration: 0.18)) { isToolbarSearchExpanded = false } guard clearQuery else { return } switch toolbarSearchScope { case .files: globalSearchText = "" model.clearGlobalFileSearch() case .media: model.mediaLibrarySearchText = "" case nil: break } } } private struct WorkspaceModeSwitcher: View { @Binding var selection: WorkspaceMode let isCompact: Bool @Environment(\.colorScheme) private var colorScheme var body: some View { HStack(spacing: 3) { ForEach(WorkspaceMode.allCases) { mode in modeButton(mode) } } .padding(3) .background(Color.primary.opacity(colorScheme == .dark ? 0.09 : 0.06), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 9, style: .continuous) .stroke(Color.primary.opacity(colorScheme == .dark ? 0.18 : 0.11), lineWidth: 1) } .fixedSize(horizontal: true, vertical: false) .accessibilityElement(children: .contain) .accessibilityLabel("工作模式") } private func modeButton(_ mode: WorkspaceMode) -> some View { let isSelected = selection == mode let selectedBackground = isSelected ? Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.09) : Color.clear return Button { withAnimation(.easeInOut(duration: 0.18)) { selection = mode } } label: { Group { if isCompact { Label(mode.rawValue, systemImage: mode.icon).labelStyle(.iconOnly) } else { Label(mode.rawValue, systemImage: mode.icon).labelStyle(.titleAndIcon) } } .font(.callout.weight(isSelected ? .semibold : .medium)) .foregroundStyle(isSelected ? Color.primary : Color.secondary) .frame(minWidth: isCompact ? 34 : 102, minHeight: 30) .padding(.horizontal, isCompact ? 2 : 10) .background(selectedBackground, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) } .buttonStyle(.plain) .help("切换到\(mode.rawValue)") .compactHoverHint(mode.rawValue, enabled: isCompact) .accessibilityLabel("切换到\(mode.rawValue)") .accessibilityAddTraits(isSelected ? .isSelected : []) } }