Files
guangya_client/guangya_mac/ContentView.swift
T

1938 lines
108 KiB
Swift

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<String>
@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)..<path.count)
Task { await model.loadScanPickerFolders(parentID: path[index].id) }
}
}
private struct ScanToolPage: View {
@ObservedObject var model: AppModel
let kind: ScanKind
@State private var selectedIDs: Set<String> = []
@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<Bool> {
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<String> {
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<String>, id: Binding<String?>) -> 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<Content: View>: 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("登录页")
}
}