1938 lines
108 KiB
Swift
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("登录页")
|
|
}
|
|
}
|