0df8013d3e
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
3308 lines
165 KiB
Swift
3308 lines
165 KiB
Swift
import AppKit
|
||
import AVFoundation
|
||
import AVKit
|
||
import CoreImage.CIFilterBuiltins
|
||
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
import WebKit
|
||
|
||
struct ContentView: View {
|
||
@StateObject private var model = AppModel()
|
||
#if DEBUG
|
||
@State private var injectionRevision = 0
|
||
#endif
|
||
|
||
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)
|
||
}
|
||
#if DEBUG
|
||
// InjectionIII posts this after replacing an implementation. Recreating the view
|
||
// hierarchy makes SwiftUI evaluate the newly injected view bodies immediately.
|
||
.id(injectionRevision)
|
||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("INJECTION_BUNDLE_NOTIFICATION"))) { _ in
|
||
DispatchQueue.main.async {
|
||
injectionRevision &+= 1
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
|
||
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?
|
||
let isCompact: Bool
|
||
|
||
var body: some View {
|
||
Group {
|
||
if isCompact { compactSidebar }
|
||
else { expandedSidebar }
|
||
}
|
||
}
|
||
|
||
private var expandedSidebar: 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: 16) {
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
ForEach([WorkspaceSection.files, .recentViewed, .recentRestored]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } }
|
||
}
|
||
Divider().padding(.horizontal, 8)
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in
|
||
SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section }
|
||
}
|
||
}
|
||
Divider().padding(.horizontal, 8)
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { section in
|
||
SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section }
|
||
}
|
||
}
|
||
Divider().padding(.horizontal, 8)
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
ForEach(WorkspaceTool.allCases.filter { $0 != .media && $0 != .tmdb }) { tool in
|
||
ToolSidebarItem(tool: tool, selected: selectedTool == tool) {
|
||
if tool == .rename {
|
||
model.batchRenameTarget = nil
|
||
model.batchRenameSelection = []
|
||
}
|
||
selectedTool = tool
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.trailing, 4)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
.clipped()
|
||
|
||
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)
|
||
.task { await model.loadMediaLibraries() }
|
||
}
|
||
|
||
private var compactSidebar: some View {
|
||
VStack(spacing: 10) {
|
||
BrandMark(size: 26)
|
||
.padding(7).background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 9))
|
||
.help("光鸭云盘")
|
||
Divider()
|
||
compactSectionButton(.files)
|
||
compactSectionButton(.recentViewed)
|
||
compactSectionButton(.recentRestored)
|
||
Divider().padding(.vertical, 2)
|
||
compactSectionButton(.cloud)
|
||
compactSectionButton(.shares)
|
||
compactSectionButton(.recycle)
|
||
Divider().padding(.vertical, 2)
|
||
ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { compactSectionButton($0) }
|
||
Divider().padding(.vertical, 2)
|
||
ForEach(WorkspaceTool.allCases.filter { $0 != .media && $0 != .tmdb }) { tool in
|
||
compactToolButton(tool)
|
||
}
|
||
Spacer(minLength: 6)
|
||
Menu {
|
||
Text(model.user.name)
|
||
if !model.user.phone.isEmpty { Text(model.user.phone) }
|
||
Divider()
|
||
Button("退出登录", role: .destructive) { model.logout() }
|
||
} label: {
|
||
Text(String(model.user.name.prefix(1))).font(.caption.weight(.bold)).foregroundStyle(.orange)
|
||
.frame(width: 32, height: 32).background(Color.orange.opacity(0.16), in: Circle())
|
||
}
|
||
.menuStyle(.borderlessButton).help("账户:\(model.user.name)")
|
||
}
|
||
.padding(.vertical, 14).frame(width: 64).frame(maxHeight: .infinity)
|
||
.background(.regularMaterial)
|
||
.task { await model.loadMediaLibraries() }
|
||
}
|
||
|
||
private func compactSectionButton(_ section: WorkspaceSection) -> some View {
|
||
let isSelected = selectedTool == nil && model.section == section
|
||
return Button { selectedTool = nil; model.section = section } label: {
|
||
Image(systemName: section.icon).frame(width: 34, height: 34)
|
||
.foregroundStyle(isSelected ? Color.orange : Color.secondary)
|
||
.background(isSelected ? Color.orange.opacity(0.16) : .clear, in: RoundedRectangle(cornerRadius: 7))
|
||
}
|
||
.buttonStyle(.plain).help(section.rawValue).compactHoverHint(section.rawValue).accessibilityLabel(section.rawValue)
|
||
}
|
||
|
||
private func compactToolButton(_ tool: WorkspaceTool) -> some View {
|
||
let isSelected = selectedTool == tool
|
||
return Button { selectedTool = tool } label: {
|
||
Image(systemName: tool.icon).frame(width: 34, height: 34)
|
||
.foregroundStyle(isSelected ? Color.orange : Color.secondary)
|
||
.background(isSelected ? Color.orange.opacity(0.16) : .clear, in: RoundedRectangle(cornerRadius: 7))
|
||
}
|
||
.buttonStyle(.plain).help(tool.rawValue).compactHoverHint(tool.rawValue).accessibilityLabel(tool.rawValue)
|
||
}
|
||
}
|
||
|
||
private struct MediaLibrarySidebarRow: View {
|
||
let title: String
|
||
let icon: String
|
||
let isSelected: Bool
|
||
var status: String? = nil
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 9) {
|
||
Image(systemName: icon).frame(width: 20).foregroundStyle(isSelected ? AppTheme.orange : .secondary)
|
||
Text(title).font(.callout).lineLimit(1)
|
||
Spacer()
|
||
if let status { Text(status).font(.caption2.weight(.semibold)).foregroundStyle(AppTheme.orange) }
|
||
}
|
||
.padding(.horizontal, 9).frame(height: 32)
|
||
.background(isSelected ? AppTheme.orange.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 6))
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
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
|
||
@Binding var isDualPanel: Bool
|
||
let onPreview: (CloudFile) -> Void
|
||
let onOpenTool: (WorkspaceTool) -> Void
|
||
let onShowInspector: () -> Void
|
||
var dualPanelContent: AnyView? = nil // Optional dual-panel view to replace file list
|
||
@State private var showBatchDeleteConfirmation = false
|
||
@State private var selectionAnchorID: String?
|
||
@State private var keyboardFocusID: String?
|
||
@State private var tmdbTarget: CloudFile?
|
||
|
||
// The title-bar search is a separate whole-drive result set; normal browsing
|
||
// keeps the server's current-folder order intact.
|
||
private var filteredFiles: [CloudFile] {
|
||
globalSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.files : model.globalFileSearchResults
|
||
}
|
||
private var isShowingGlobalSearchResults: Bool { !globalSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||
private var fileCount: Int { filteredFiles.filter { !$0.isDirectory }.count }
|
||
private var folderCount: Int { filteredFiles.filter(\.isDirectory).count }
|
||
private var totalSizeText: String {
|
||
let total = filteredFiles.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)) }
|
||
.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("选中的文件及文件夹将被删除。文件夹会连同其内容一起删除,且此操作不可撤销。") }
|
||
}
|
||
|
||
private var browserHeader: some View {
|
||
HStack(spacing: 12) {
|
||
if isShowingGlobalSearchResults {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.foregroundStyle(.orange)
|
||
Text("搜索结果:\(globalSearchText)")
|
||
.font(.callout.weight(.semibold))
|
||
.lineLimit(1)
|
||
Button { clearGlobalSearchResults() } label: { Image(systemName: "xmark.circle.fill") }
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(.secondary)
|
||
.help("返回全部文件")
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
} else {
|
||
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("\(globalSearchText.isEmpty ? "本页" : "搜索结果")文件 \(fileCount)", systemImage: "doc.fill").foregroundStyle(.secondary)
|
||
Label("\(globalSearchText.isEmpty ? "本页" : "搜索结果")文件夹 \(folderCount)", systemImage: "folder.fill").foregroundStyle(.secondary)
|
||
Label("\(globalSearchText.isEmpty ? "本页" : "搜索结果")大小 \(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 && !isShowingGlobalSearchResults {
|
||
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 { withAnimation(.easeInOut(duration: 0.2)) { isDualPanel.toggle() } } label: {
|
||
Image(systemName: isDualPanel ? "rectangle.split.2x1" : "rectangle.on.rectangle")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.help(isDualPanel ? "切换到单面板" : "左右双面板浏览")
|
||
}
|
||
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 let dualContent = dualPanelContent {
|
||
dualContent
|
||
} else if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section).softCard(radius: 24).padding(.horizontal, 10) }
|
||
else if model.isLoadingFiles || model.isGlobalFileSearching { 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)
|
||
.onDeleteCommand {
|
||
guard !selectedFileIDs.isEmpty else { return }
|
||
showBatchDeleteConfirmation = true
|
||
}
|
||
.onKeyPress(.delete, phases: .down) { press in
|
||
guard press.modifiers.contains(.command), !selectedFileIDs.isEmpty else { return .ignored }
|
||
showBatchDeleteConfirmation = true
|
||
return .handled
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func moveKeyboardSelection(direction: MoveCommandDirection) {
|
||
moveKeyboardSelection(page: direction == .up || direction == .left ? -1 : 1)
|
||
}
|
||
|
||
private func clearGlobalSearchResults() {
|
||
globalSearchText = ""
|
||
model.clearGlobalFileSearch()
|
||
selectedFileIDs.removeAll()
|
||
selectionAnchorID = nil
|
||
}
|
||
|
||
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); Text("5000 / 页").tag(5000); Text("10000 / 页").tag(10000)
|
||
}
|
||
.labelsHidden().frame(width: 108)
|
||
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.isVideo {
|
||
Menu("播放") {
|
||
if AppModel.installedPlayers.isEmpty {
|
||
Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("系统默认播放器", systemImage: "play.fill") }
|
||
} else {
|
||
ForEach(AppModel.installedPlayers, id: \.name) { player in
|
||
Button { Task { await model.openFile(file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") }
|
||
}
|
||
}
|
||
}
|
||
} else if !file.isDirectory && !AppModel.installedPlayers.isEmpty {
|
||
Menu("用外部播放器打开") {
|
||
ForEach(AppModel.installedPlayers, id: \.name) { player in
|
||
Button { Task { await model.openFile(file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") }
|
||
}
|
||
}
|
||
}
|
||
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") }
|
||
}
|
||
} else {
|
||
Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") }
|
||
}
|
||
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) { requestDelete(file) } label: { Label("删除…", systemImage: "trash") }
|
||
}
|
||
|
||
private func requestDelete(_ file: CloudFile) {
|
||
// Right-clicking an already-selected row must preserve the whole selection.
|
||
// A non-selected row becomes the explicit one-item deletion target.
|
||
if !selectedFileIDs.contains(file.id) {
|
||
selectedFileIDs = [file.id]
|
||
selectionAnchorID = file.id
|
||
}
|
||
showBatchDeleteConfirmation = true
|
||
}
|
||
|
||
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
|
||
var onClose: (() -> Void)? = nil
|
||
@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) }
|
||
private var isVideo: Bool { file.isVideo }
|
||
|
||
var body: some View {
|
||
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 {
|
||
WebPreview(url: url)
|
||
}
|
||
} else if !errorText.isEmpty {
|
||
previewFailure(errorText, icon: "eye.slash")
|
||
} else {
|
||
ProgressView("正在获取预览地址…").tint(.white).foregroundStyle(.white)
|
||
}
|
||
}
|
||
.frame(minWidth: 900, minHeight: 650)
|
||
.navigationTitle(file.name)
|
||
.onExitCommand { closePreview() }
|
||
.task { await loadPreviewURL() }
|
||
}
|
||
|
||
private func loadPreviewURL() async {
|
||
do {
|
||
let fetchedURL = try await model.remoteURL(for: file)
|
||
url = fetchedURL
|
||
} catch {
|
||
errorText = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private func previewFailure(_ text: String, icon: String) -> some View {
|
||
ContentUnavailableView(text, systemImage: icon).foregroundStyle(.white)
|
||
}
|
||
|
||
private func closePreview() {
|
||
if let onClose { onClose() }
|
||
else { dismiss() }
|
||
}
|
||
}
|
||
|
||
/// Non-modal AppKit player window. Unlike a SwiftUI sheet, it never blocks the
|
||
/// cloud-drive window, so playback can stay open while files are managed.
|
||
@MainActor
|
||
final class PlayerWindowCoordinator: NSObject, NSWindowDelegate {
|
||
static let shared = PlayerWindowCoordinator()
|
||
private var windows: [String: NSWindow] = [:]
|
||
|
||
func open(model: AppModel, file: CloudFile) {
|
||
if file.isVideo {
|
||
Task { await openWithExternalPlayer(model: model, file: file) }
|
||
return
|
||
}
|
||
if let window = windows[file.id] {
|
||
window.makeKeyAndOrderFront(nil)
|
||
return
|
||
}
|
||
let window = NSWindow(
|
||
contentRect: NSRect(x: 0, y: 0, width: 1080, height: 720),
|
||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
window.title = file.name
|
||
window.titleVisibility = .hidden
|
||
window.titlebarAppearsTransparent = true
|
||
window.isReleasedWhenClosed = false
|
||
window.minSize = NSSize(width: 760, height: 500)
|
||
window.delegate = self
|
||
window.standardWindowButton(.closeButton)?.isHidden = false
|
||
window.standardWindowButton(.miniaturizeButton)?.isHidden = false
|
||
window.standardWindowButton(.zoomButton)?.isHidden = false
|
||
window.contentViewController = NSHostingController(rootView: FilePreviewSheet(model: model, file: file, onClose: { [weak self] in
|
||
self?.close(fileID: file.id)
|
||
}))
|
||
windows[file.id] = window
|
||
window.center()
|
||
window.makeKeyAndOrderFront(nil)
|
||
}
|
||
|
||
private func openWithExternalPlayer(model: AppModel, file: CloudFile) async {
|
||
let installed = AppModel.installedPlayers
|
||
if installed.count == 1 {
|
||
await model.openFile(file, withPlayerNamed: installed[0].name)
|
||
} else if installed.count > 1 {
|
||
await MainActor.run {
|
||
showPlayerPicker(model: model, file: file, installed: installed)
|
||
}
|
||
} else {
|
||
await model.playWithExternalPlayer(file)
|
||
}
|
||
}
|
||
|
||
private func showPlayerPicker(model: AppModel, file: CloudFile, installed: [(name: String, bundleID: String)]) {
|
||
let panel = NSPanel(
|
||
contentRect: NSRect(x: 0, y: 0, width: 380, height: 0),
|
||
styleMask: [.titled, .closable],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
panel.title = "选择播放器"
|
||
panel.isReleasedWhenClosed = false
|
||
panel.level = .floating
|
||
panel.isFloatingPanel = true
|
||
let hostingView = NSHostingView(rootView: PlayerPickerPanel(model: model, file: file, installed: installed, onDismiss: { panel.close() }))
|
||
panel.contentView = hostingView
|
||
panel.setContentSize(hostingView.fittingSize)
|
||
panel.center()
|
||
panel.makeKeyAndOrderFront(nil)
|
||
}
|
||
|
||
func close(fileID: String) {
|
||
guard let window = windows[fileID] else { return }
|
||
windows[fileID] = nil
|
||
window.close()
|
||
}
|
||
|
||
nonisolated func windowWillClose(_ notification: Notification) {
|
||
guard let window = notification.object as? NSWindow else { return }
|
||
Task { @MainActor [weak self] in
|
||
self?.windows = self?.windows.filter { $0.value !== window } ?? [:]
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// Floating panel for choosing among installed external players.
|
||
private struct PlayerPickerPanel: View {
|
||
@ObservedObject var model: AppModel
|
||
let file: CloudFile
|
||
let installed: [(name: String, bundleID: String)]
|
||
let onDismiss: () -> Void
|
||
|
||
private let columns = [
|
||
GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 0)
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
VStack(spacing: 6) {
|
||
Image(systemName: "play.circle.fill")
|
||
.font(.system(size: 36))
|
||
.foregroundStyle(.orange)
|
||
Text("选择播放器")
|
||
.font(.title3.weight(.semibold))
|
||
Text(file.name)
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.top, 24)
|
||
.padding(.bottom, 20)
|
||
|
||
Divider()
|
||
|
||
LazyVGrid(columns: columns, spacing: 4) {
|
||
ForEach(installed, id: \.name) { player in
|
||
PlayerCard(player: player, file: file, model: model, onDismiss: onDismiss)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 16)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct PlayerCard: View {
|
||
let player: (name: String, bundleID: String)
|
||
let file: CloudFile
|
||
@ObservedObject var model: AppModel
|
||
let onDismiss: () -> Void
|
||
@State private var isHovering = false
|
||
@State private var iconImage: NSImage?
|
||
|
||
var body: some View {
|
||
Button {
|
||
Task { await model.openFile(file, withPlayerNamed: player.name) }
|
||
onDismiss()
|
||
} label: {
|
||
VStack(spacing: 6) {
|
||
Group {
|
||
if let img = iconImage {
|
||
Image(nsImage: img)
|
||
.resizable()
|
||
} else {
|
||
Image(systemName: "play.rectangle.fill")
|
||
.font(.system(size: 28))
|
||
.foregroundStyle(.orange)
|
||
}
|
||
}
|
||
.frame(width: 44, height: 44)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
|
||
Text(player.name)
|
||
.font(.caption2.weight(.medium))
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(width: 84, height: 76)
|
||
.background(isHovering ? Color(nsColor: .controlAccentColor).opacity(0.12) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
.strokeBorder(isHovering ? Color.accentColor.opacity(0.5) : Color(nsColor: .separatorColor), lineWidth: 0.5)
|
||
)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.onHover { isHovering = $0 }
|
||
.onAppear { loadIcon() }
|
||
}
|
||
|
||
private func loadIcon() {
|
||
guard iconImage == nil else { return }
|
||
guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: player.bundleID) else { return }
|
||
iconImage = NSWorkspace.shared.icon(forFile: url.path)
|
||
}
|
||
}
|
||
|
||
|
||
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 {
|
||
if tool == .media {
|
||
MediaLibraryPage(model: model)
|
||
} else if tool == .fastTransfer {
|
||
FastTransferToolPage(model: model)
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
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 .media: EmptyView()
|
||
case .scan: UnifiedScanToolPage(model: model)
|
||
case .rename: BatchRenameToolPage(model: model)
|
||
case .fastTransfer: EmptyView()
|
||
case .tmdb: EmptyView()
|
||
case .settings: ToolSettingsPage(model: model)
|
||
}
|
||
}
|
||
.padding(14)
|
||
.background(Color.clear)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum FastTransferToolMode: String, CaseIterable, Identifiable {
|
||
case importJSON
|
||
case generateJSON
|
||
|
||
var id: String { rawValue }
|
||
var title: String { self == .importJSON ? "JSON 秒传" : "本地生成 JSON" }
|
||
}
|
||
|
||
private enum FastTransferImportPhase {
|
||
case chooseSource
|
||
case parsing
|
||
case ready
|
||
}
|
||
|
||
private enum FastTransferQueueSection: String, CaseIterable, Identifiable {
|
||
case pending
|
||
case issues
|
||
case imported
|
||
case skipped
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .pending: return "主队列"
|
||
case .issues: return "出错"
|
||
case .imported: return "成功"
|
||
case .skipped: return "已跳过"
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct FastTransferQueueSnapshot {
|
||
typealias Task = (entry: FastTransferEntry, result: FastTransferResult?)
|
||
|
||
var latestResults: [UUID: FastTransferResult] = [:]
|
||
var retriedResultIDs: Set<UUID> = []
|
||
var entryByID: [UUID: FastTransferEntry] = [:]
|
||
var pending: [Task] = []
|
||
var issues: [Task] = []
|
||
var imported: [Task] = []
|
||
var skipped: [Task] = []
|
||
|
||
init(entries: [FastTransferEntry] = [], results: [FastTransferResult] = []) {
|
||
latestResults = results.reduce(into: [:]) { $0[$1.entry.id] = $1 }
|
||
retriedResultIDs = Set(results.compactMap(\.retryOf))
|
||
entryByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) })
|
||
pending.reserveCapacity(entries.count)
|
||
for entry in entries {
|
||
let task = (entry: entry, result: latestResults[entry.id])
|
||
guard let result = task.result else {
|
||
pending.append(task)
|
||
continue
|
||
}
|
||
switch result.state {
|
||
case .imported: imported.append(task)
|
||
case .skipped: skipped.append(task)
|
||
case .failed, .cancelled: issues.append(task)
|
||
}
|
||
}
|
||
}
|
||
|
||
func tasks(in section: FastTransferQueueSection) -> [Task] {
|
||
switch section {
|
||
case .pending: return pending
|
||
case .issues: return issues
|
||
case .imported: return imported
|
||
case .skipped: return skipped
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct FastTransferToolPage: View {
|
||
@ObservedObject var model: AppModel
|
||
@State private var mode: FastTransferToolMode = .importJSON
|
||
@State private var jsonText = ""
|
||
@State private var importPhase: FastTransferImportPhase = .chooseSource
|
||
@State private var localURLs: [URL] = []
|
||
@State private var generatedEntries: [FastTransferEntry] = []
|
||
@State private var generatedJSON = ""
|
||
@State private var targetID: String?
|
||
@State private var targetName = "当前文件夹"
|
||
@AppStorage("guangya.fastTransfer.createDirectories") private var createDirectories = true
|
||
@AppStorage("guangya.fastTransfer.skipExisting") private var skipExisting = true
|
||
@State private var showTargetPicker = false
|
||
@State private var showLocalImporter = false
|
||
@State private var localError = ""
|
||
@State private var importTask: Task<Void, Never>?
|
||
@State private var queuePage = 0
|
||
@State private var queuePageSize = 200
|
||
@State private var queueSection: FastTransferQueueSection = .pending
|
||
@State private var queueSnapshot = FastTransferQueueSnapshot()
|
||
|
||
var body: some View {
|
||
Group {
|
||
if mode == .importJSON && importPhase != .ready {
|
||
importSourceView
|
||
} else {
|
||
VStack(spacing: 0) {
|
||
Picker("功能", selection: $mode) {
|
||
ForEach(FastTransferToolMode.allCases) { option in
|
||
Text(option.title).tag(option)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.padding(.horizontal, 22).padding(.vertical, 16)
|
||
Divider()
|
||
Group {
|
||
switch mode {
|
||
case .importJSON: importView
|
||
case .generateJSON: generateView
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showTargetPicker) {
|
||
ScanFolderPickerSheet(model: model, title: "选择秒传目标目录", selectionLabel: "使用此目录") { id, path in
|
||
saveTargetDirectory(id: id, path: path)
|
||
}
|
||
}
|
||
.fileImporter(isPresented: $showLocalImporter, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
|
||
guard case .success(let urls) = result else { return }
|
||
localURLs = urls
|
||
generateJSON(for: urls)
|
||
}
|
||
.alert("秒传工具", isPresented: Binding(get: { !localError.isEmpty }, set: { if !$0 { localError = "" } })) {
|
||
Button("确定", role: .cancel) { localError = "" }
|
||
} message: {
|
||
Text(localError)
|
||
}
|
||
.onAppear {
|
||
refreshQueueSnapshot()
|
||
if model.isFastTransferRunning || model.hasPendingFastTransferEntries {
|
||
mode = .importJSON
|
||
importPhase = .ready
|
||
}
|
||
let defaults = UserDefaults.standard
|
||
if defaults.bool(forKey: "guangya.fastTransfer.hasSavedTarget") {
|
||
let savedID = defaults.string(forKey: "guangya.fastTransfer.targetID") ?? ""
|
||
targetID = savedID.isEmpty ? nil : savedID
|
||
targetName = defaults.string(forKey: "guangya.fastTransfer.targetName") ?? "云盘根目录"
|
||
} else {
|
||
targetID = model.folderPath.last?.id
|
||
targetName = model.folderPath.isEmpty ? "云盘根目录" : model.folderPath.map(\.name).joined(separator: " / ")
|
||
}
|
||
}
|
||
// Published emits before its stored value changes. Build from the value
|
||
// delivered by the publisher rather than reading the old model array,
|
||
// otherwise a freshly parsed JSON can momentarily cache an empty queue.
|
||
.onReceive(model.$fastTransferEntries) { entries in
|
||
refreshQueueSnapshot(entries: entries, results: model.fastTransferResults)
|
||
}
|
||
.onReceive(model.$fastTransferResults) { results in
|
||
refreshQueueSnapshot(entries: model.fastTransferEntries, results: results)
|
||
}
|
||
}
|
||
|
||
private var importSourceView: some View {
|
||
VStack(spacing: 18) {
|
||
if importPhase == .parsing {
|
||
ProgressView().controlSize(.large)
|
||
Text("正在分析 JSON").font(.title3.weight(.semibold))
|
||
Text("正在读取秒传任务和校验信息").font(.callout).foregroundStyle(.secondary)
|
||
} else {
|
||
Image(systemName: "bolt.badge.clock")
|
||
.font(.system(size: 42, weight: .light)).foregroundStyle(.orange)
|
||
.frame(width: 88, height: 88)
|
||
.background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||
VStack(spacing: 6) {
|
||
Text("导入秒传任务").font(.title2.weight(.bold))
|
||
Text("选择 JSON 来源后会先解析为可检查的任务列表。")
|
||
.font(.callout).foregroundStyle(.secondary)
|
||
}
|
||
HStack(spacing: 14) {
|
||
fastTransferSourceButton("粘贴 JSON", icon: "doc.on.clipboard", action: pasteJSONFromClipboard)
|
||
fastTransferSourceButton("选择 JSON", icon: "folder.badge.plus", action: chooseJSONFile)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.padding(30)
|
||
}
|
||
|
||
private func fastTransferSourceButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: icon).font(.title2.weight(.semibold))
|
||
Text(title).font(.headline)
|
||
}
|
||
.frame(width: 190, height: 132)
|
||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 8))
|
||
.overlay { RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.14), lineWidth: 1) }
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(importPhase == .parsing)
|
||
}
|
||
|
||
private var importView: some View {
|
||
VStack(spacing: 0) {
|
||
transferCommandBar
|
||
Divider()
|
||
taskList
|
||
.padding(18)
|
||
}
|
||
.onChange(of: model.fastTransferProgress.currentName) { _, path in
|
||
let pending = queueSnapshot.pending
|
||
guard queueSection == .pending,
|
||
model.isFastTransferRunning,
|
||
let index = pending.firstIndex(where: { $0.entry.path == path }) else { return }
|
||
queuePage = index / queuePageSize
|
||
}
|
||
}
|
||
|
||
private var transferCommandBar: some View {
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("秒传任务").font(.headline.weight(.semibold))
|
||
HStack(spacing: 5) {
|
||
Image(systemName: "folder")
|
||
Text(targetName).lineLimit(1)
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Button { showTargetPicker = true } label: { Image(systemName: "folder.badge.gearshape") }
|
||
.buttonStyle(.bordered)
|
||
.help("选择目标目录")
|
||
.disabled(model.isFastTransferRunning)
|
||
Divider().frame(height: 28)
|
||
Toggle("创建目录", isOn: $createDirectories)
|
||
.toggleStyle(.checkbox)
|
||
.font(.caption)
|
||
.disabled(model.isFastTransferRunning)
|
||
Toggle("跳过同名", isOn: $skipExisting)
|
||
.toggleStyle(.checkbox)
|
||
.font(.caption)
|
||
.disabled(model.isFastTransferRunning)
|
||
Stepper(value: $model.fastTransferConcurrency, in: 1...20) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "point.3.connected.trianglepath.dotted")
|
||
Text("并发 \(model.fastTransferConcurrency)")
|
||
.monospacedDigit()
|
||
}
|
||
.font(.caption)
|
||
}
|
||
.frame(width: 94)
|
||
.disabled(model.isFastTransferRunning)
|
||
.help("同时提交的秒传请求数量")
|
||
Spacer(minLength: 10)
|
||
Button { resetImportSource() } label: { Image(systemName: "arrow.uturn.backward") }
|
||
.buttonStyle(.bordered)
|
||
.help("重新选择 JSON")
|
||
.disabled(model.isFastTransferRunning)
|
||
if model.isFastTransferRunning {
|
||
Button {
|
||
if model.isFastTransferPaused { model.resumeFastTransfer() }
|
||
else { model.pauseFastTransfer() }
|
||
} label: {
|
||
Label(model.isFastTransferPaused ? "继续" : "暂停", systemImage: model.isFastTransferPaused ? "play.fill" : "pause.fill")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
Button(role: .destructive, action: stopImport) { Label("终止", systemImage: "stop.fill") }
|
||
.buttonStyle(.bordered)
|
||
.help("终止当前任务,保留未执行任务")
|
||
} else {
|
||
Button { runImport() } label: {
|
||
Label("开始秒传 \(pendingEntries.count) 项", systemImage: "bolt.fill")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(.orange)
|
||
.disabled(pendingEntries.isEmpty)
|
||
}
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.vertical, 12)
|
||
}
|
||
|
||
private var generateView: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("本地文件生成秒传 JSON").font(.headline.weight(.semibold))
|
||
Text(localURLs.isEmpty ? "" : "已选择 \(localURLs.count) 个文件或文件夹")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button { showLocalImporter = true } label: { Label("选择文件或文件夹", systemImage: "folder.badge.plus") }
|
||
.buttonStyle(.bordered)
|
||
Button { saveGeneratedJSON() } label: { Label("导出 JSON", systemImage: "square.and.arrow.up") }
|
||
.buttonStyle(.borderedProminent).disabled(generatedJSON.isEmpty || model.isGeneratingFastTransferJSON)
|
||
}
|
||
if model.isGeneratingFastTransferJSON || model.fastTransferGenerationProgress.total > 0 {
|
||
progressView(model.fastTransferGenerationProgress, title: "GCID 计算进度")
|
||
}
|
||
if generatedJSON.isEmpty && !model.isGeneratingFastTransferJSON {
|
||
ContentUnavailableView("尚未选择本地文件", systemImage: "doc.badge.plus")
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
TextEditor(text: .constant(generatedJSON))
|
||
.font(.system(.body, design: .monospaced))
|
||
.scrollContentBackground(.hidden)
|
||
.padding(8)
|
||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 7))
|
||
.overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.12), lineWidth: 1) }
|
||
.textSelection(.enabled)
|
||
}
|
||
}
|
||
.padding(22)
|
||
}
|
||
|
||
private var taskList: some View {
|
||
let pendingTasks = queueSnapshot.pending
|
||
let issueTasks = queueSnapshot.issues
|
||
let importedTasks = queueSnapshot.imported
|
||
let skippedTasks = queueSnapshot.skipped
|
||
let tasks = queueSnapshot.tasks(in: queueSection)
|
||
let retryable = retryableResults
|
||
let retryableIDs = Set(retryable.map(\.id))
|
||
let pageCount = max(1, Int(ceil(Double(tasks.count) / Double(queuePageSize))))
|
||
let currentPage = min(queuePage, pageCount - 1)
|
||
let pageStart = min(currentPage * queuePageSize, tasks.count)
|
||
let visibleTasks = Array(tasks.dropFirst(pageStart).prefix(queuePageSize))
|
||
let pageEnd = pageStart + visibleTasks.count
|
||
return VStack(alignment: .leading, spacing: 7) {
|
||
HStack {
|
||
Label(queueSection.title, systemImage: queueSection == .pending ? "list.bullet.rectangle" : (queueSection == .issues ? "exclamationmark.triangle" : (queueSection == .imported ? "checkmark.circle" : "forward.circle")))
|
||
.font(.headline.weight(.semibold))
|
||
Text("\(tasks.count) 项")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
if !model.fastTransferEntries.isEmpty {
|
||
Button("清空任务") { model.clearFastTransferSession() }
|
||
.buttonStyle(.borderless)
|
||
.controlSize(.small)
|
||
.disabled(model.isFastTransferRunning)
|
||
}
|
||
}
|
||
HStack(spacing: 5) {
|
||
queueSectionTab(.pending, count: pendingTasks.count, icon: "list.bullet", tint: .orange)
|
||
queueSectionTab(.issues, count: issueTasks.count, icon: "exclamationmark.triangle", tint: .red)
|
||
queueSectionTab(.imported, count: importedTasks.count, icon: "checkmark.circle", tint: .green)
|
||
queueSectionTab(.skipped, count: skippedTasks.count, icon: "forward.circle", tint: .secondary)
|
||
}
|
||
.padding(3)
|
||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||
HStack(spacing: 8) {
|
||
Text(tasks.isEmpty ? "暂无任务" : "显示 \(pageStart + 1)-\(pageEnd) / \(tasks.count)")
|
||
.font(.caption2.monospacedDigit())
|
||
.foregroundStyle(.tertiary)
|
||
Spacer()
|
||
Picker("每页", selection: $queuePageSize) {
|
||
Text("200 条/页").tag(200)
|
||
Text("500 条/页").tag(500)
|
||
Text("1,000 条/页").tag(1_000)
|
||
}
|
||
.labelsHidden()
|
||
.pickerStyle(.menu)
|
||
.controlSize(.small)
|
||
.onChange(of: queuePageSize) { _, _ in queuePage = 0 }
|
||
Button { queuePage = max(0, currentPage - 1) } label: { Image(systemName: "chevron.left") }
|
||
.buttonStyle(.borderless)
|
||
.disabled(currentPage == 0)
|
||
.help("上一页")
|
||
Text("\(currentPage + 1)/\(pageCount)")
|
||
.font(.caption2.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
.frame(minWidth: 38)
|
||
Button { queuePage = min(pageCount - 1, currentPage + 1) } label: { Image(systemName: "chevron.right") }
|
||
.buttonStyle(.borderless)
|
||
.disabled(currentPage >= pageCount - 1)
|
||
.help("下一页")
|
||
}
|
||
if model.isFastTransferRunning || model.fastTransferProgress.total > 0 {
|
||
queueProgress
|
||
}
|
||
if queueSection == .issues && !retryable.isEmpty {
|
||
HStack(spacing: 8) {
|
||
Label("失败任务可单独重传", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button("重传失败项") { retry(retryable) }
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
.disabled(model.isFastTransferRunning)
|
||
}
|
||
}
|
||
List {
|
||
if visibleTasks.isEmpty {
|
||
ContentUnavailableView(emptyQueueTitle, systemImage: queueSection == .pending ? "checkmark.circle" : "tray")
|
||
} else {
|
||
ForEach(visibleTasks, id: \.entry.id) { task in
|
||
let entry = task.entry
|
||
let isActive = isTaskActive(entry)
|
||
HStack(alignment: .top, spacing: 9) {
|
||
Group {
|
||
if isActive {
|
||
if model.isFastTransferPaused {
|
||
Image(systemName: "pause.circle.fill").foregroundStyle(.orange)
|
||
} else {
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
} else {
|
||
Image(systemName: isTaskQueuedForCancellation(entry) ? "xmark.circle" : taskIcon(task.result))
|
||
.foregroundStyle(isTaskQueuedForCancellation(entry) ? .secondary : taskColor(task.result))
|
||
}
|
||
}
|
||
.frame(width: 18, height: 18)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 6) {
|
||
Text(entry.name).font(.callout.weight(.medium)).lineLimit(1)
|
||
Text(taskStatusTitle(for: entry, result: task.result, isActive: isActive))
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(taskColor(task.result, isActive: isActive))
|
||
}
|
||
Text(entry.path).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||
Text(taskMetadata(entry))
|
||
.font(.caption2.monospaced()).foregroundStyle(.tertiary).lineLimit(1)
|
||
if let result = task.result { Text(result.message).font(.caption2).foregroundStyle(taskColor(result)).lineLimit(1) }
|
||
}
|
||
Spacer(minLength: 4)
|
||
if let result = task.result,
|
||
result.state == .failed,
|
||
retryableIDs.contains(result.id) {
|
||
Button { retry([result]) } label: { Image(systemName: "arrow.clockwise") }
|
||
.buttonStyle(.borderless)
|
||
.help("重传此任务")
|
||
.disabled(model.isFastTransferRunning)
|
||
}
|
||
if isActive {
|
||
Button(role: .destructive, action: stopImport) { Image(systemName: "stop.fill") }
|
||
.buttonStyle(.borderless)
|
||
.help("终止当前传输,保留未执行任务")
|
||
} else if task.result == nil {
|
||
Button { cancelTask(entry) } label: { Image(systemName: "xmark") }
|
||
.buttonStyle(.borderless)
|
||
.help("取消任务")
|
||
.disabled(isTaskQueuedForCancellation(entry))
|
||
}
|
||
}
|
||
.contextMenu {
|
||
Button { copyTaskPath(entry.path) } label: { Label("复制路径", systemImage: "doc.on.doc") }
|
||
if task.result == nil {
|
||
Button(role: .destructive) { cancelTask(entry) } label: { Label("取消任务", systemImage: "xmark.circle") }
|
||
.disabled(isTaskQueuedForCancellation(entry))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.frame(minHeight: 145, maxHeight: .infinity)
|
||
}
|
||
}
|
||
|
||
private func queueSectionTab(_ section: FastTransferQueueSection, count: Int, icon: String, tint: Color) -> some View {
|
||
let isSelected = queueSection == section
|
||
return Button {
|
||
queueSection = section
|
||
queuePage = 0
|
||
} label: {
|
||
HStack(spacing: 5) {
|
||
Image(systemName: icon)
|
||
Text(section.title)
|
||
Text("\(count)").monospacedDigit()
|
||
}
|
||
.font(.caption.weight(isSelected ? .semibold : .regular))
|
||
.foregroundStyle(isSelected ? tint : .secondary)
|
||
.padding(.horizontal, 10)
|
||
.frame(height: 28)
|
||
.frame(maxWidth: .infinity)
|
||
.background(isSelected ? tint.opacity(0.14) : .clear, in: RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("\(section.title),\(count) 项")
|
||
}
|
||
|
||
private var emptyQueueTitle: String {
|
||
switch queueSection {
|
||
case .pending: return "主队列已处理完毕"
|
||
case .issues: return "没有出错任务"
|
||
case .imported: return "还没有成功的任务"
|
||
case .skipped: return "没有跳过的任务"
|
||
}
|
||
}
|
||
|
||
private var pendingEntries: [FastTransferEntry] {
|
||
queueSnapshot.pending.map(\.entry)
|
||
}
|
||
|
||
private var latestResultsByEntryID: [UUID: FastTransferResult] {
|
||
queueSnapshot.latestResults
|
||
}
|
||
|
||
private func taskIcon(_ result: FastTransferResult?) -> String {
|
||
guard let result else { return "clock" }
|
||
return resultIcon(result.state)
|
||
}
|
||
|
||
private func taskColor(_ result: FastTransferResult?) -> Color {
|
||
guard let result else { return .orange }
|
||
return resultColor(result.state)
|
||
}
|
||
|
||
private func taskColor(_ result: FastTransferResult?, isActive: Bool) -> Color {
|
||
isActive ? (model.isFastTransferPaused ? .orange : .accentColor) : taskColor(result)
|
||
}
|
||
|
||
private func isTaskActive(_ entry: FastTransferEntry) -> Bool {
|
||
model.isFastTransferRunning && model.fastTransferProgress.activeEntryIDs.contains(entry.id)
|
||
}
|
||
|
||
private func taskStatusTitle(for entry: FastTransferEntry, result: FastTransferResult?, isActive: Bool) -> String {
|
||
if isActive { return model.isFastTransferPaused ? "已暂停" : "处理中" }
|
||
if result == nil, isTaskQueuedForCancellation(entry) { return "待取消" }
|
||
guard let result else { return "待处理" }
|
||
return resultTitle(result.state)
|
||
}
|
||
|
||
private func isTaskQueuedForCancellation(_ entry: FastTransferEntry) -> Bool {
|
||
model.cancelledFastTransferEntryIDs.contains(entry.id)
|
||
}
|
||
|
||
private var importedTaskCount: Int {
|
||
model.fastTransferProgress.imported
|
||
}
|
||
|
||
private var skippedTaskCount: Int {
|
||
model.fastTransferProgress.skipped
|
||
}
|
||
|
||
private var failedTaskCount: Int {
|
||
model.fastTransferProgress.failed
|
||
}
|
||
|
||
private var cancelledTaskCount: Int {
|
||
model.fastTransferProgress.cancelled
|
||
}
|
||
|
||
private var queueProgress: some View {
|
||
let progress = model.fastTransferProgress
|
||
let activeEntries = model.fastTransferActiveEntries
|
||
return VStack(alignment: .leading, spacing: 7) {
|
||
HStack(spacing: 8) {
|
||
Label(model.isFastTransferPaused ? "任务已暂停" : "任务进度", systemImage: model.isFastTransferPaused ? "pause.circle" : "chart.bar.xaxis")
|
||
.font(.caption.weight(.semibold))
|
||
Spacer()
|
||
Text("\(progress.processed)/\(progress.total)")
|
||
.font(.caption.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||
if model.isFastTransferPaused {
|
||
Text("已暂停")
|
||
} else if let remaining = model.fastTransferEstimatedRemaining(at: context.date) {
|
||
Text("预计剩余 \(fastTransferDurationText(remaining))")
|
||
} else if model.isFastTransferRunning, progress.processed == 0 {
|
||
Text("正在估算")
|
||
}
|
||
}
|
||
.font(.caption2.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
ProgressView(value: progress.fraction)
|
||
.tint(.orange)
|
||
HStack(spacing: 12) {
|
||
Label("成功 \(importedTaskCount)", systemImage: "checkmark.circle.fill")
|
||
.foregroundStyle(.green)
|
||
Label("已跳过 \(skippedTaskCount)", systemImage: "forward.fill")
|
||
.foregroundStyle(.secondary)
|
||
if failedTaskCount > 0 {
|
||
Label("失败 \(failedTaskCount)", systemImage: "xmark.circle.fill")
|
||
.foregroundStyle(.red)
|
||
}
|
||
if cancelledTaskCount > 0 {
|
||
Label("已终止 \(cancelledTaskCount)", systemImage: "stop.circle.fill")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.font(.caption2)
|
||
if !activeEntries.isEmpty {
|
||
Divider()
|
||
HStack(spacing: 6) {
|
||
Text(model.isFastTransferPaused ? "已暂停" : "正在上传")
|
||
.font(.caption2.weight(.semibold))
|
||
Text("\(activeEntries.count)/\(model.fastTransferConcurrency)")
|
||
.font(.caption2.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
ForEach(activeEntries) { entry in
|
||
HStack(spacing: 7) {
|
||
if model.isFastTransferPaused {
|
||
Image(systemName: "pause.circle.fill").foregroundStyle(.orange)
|
||
} else {
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
Text(entry.name).lineLimit(1)
|
||
Spacer(minLength: 4)
|
||
Text(byteCount(entry.size)).foregroundStyle(.tertiary)
|
||
}
|
||
.font(.caption2)
|
||
}
|
||
} else if !progress.currentName.isEmpty {
|
||
Text(progress.currentName)
|
||
.font(.caption2.monospaced())
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel("秒传任务进度,已处理 \(progress.processed) 项,共 \(progress.total) 项")
|
||
}
|
||
|
||
private func fastTransferDurationText(_ interval: TimeInterval) -> String {
|
||
let total = max(0, Int(interval.rounded(.up)))
|
||
if total >= 3_600 { return "\(total / 3_600)小时\((total % 3_600) / 60)分" }
|
||
if total >= 60 { return "\(total / 60)分\(total % 60)秒" }
|
||
return "\(total)秒"
|
||
}
|
||
|
||
private var retryableResults: [FastTransferResult] {
|
||
queueSnapshot.issues.compactMap(\.result).filter { $0.state == .failed && !queueSnapshot.retriedResultIDs.contains($0.id) }
|
||
}
|
||
|
||
private func resultTitle(_ state: FastTransferResult.State) -> String {
|
||
switch state {
|
||
case .imported: return "已完成"
|
||
case .skipped: return "已跳过"
|
||
case .failed: return "失败"
|
||
case .cancelled: return "已终止"
|
||
}
|
||
}
|
||
|
||
private func byteCount(_ value: Int64) -> String {
|
||
let formatter = ByteCountFormatter()
|
||
formatter.countStyle = .file
|
||
return formatter.string(fromByteCount: value)
|
||
}
|
||
|
||
private func taskMetadata(_ entry: FastTransferEntry) -> String {
|
||
let hashValue = entry.etag ?? entry.gcid ?? "-"
|
||
return "\(byteCount(entry.size)) · \(entry.hashKind) \(hashValue)"
|
||
}
|
||
|
||
private func progressView(_ progress: FastTransferProgress, title: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
HStack {
|
||
Text(title).font(.caption.weight(.semibold))
|
||
Spacer()
|
||
Text("\(progress.processed)/\(progress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||
}
|
||
ProgressView(value: progress.fraction)
|
||
if !progress.currentName.isEmpty { Text(progress.currentName).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
|
||
}
|
||
.padding(10)
|
||
.background(Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 7))
|
||
}
|
||
|
||
private func validateJSON() {
|
||
analyzeJSON(jsonText)
|
||
}
|
||
|
||
private func analyzeJSON(_ text: String) {
|
||
let source = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !source.isEmpty else {
|
||
localError = "JSON 内容为空"
|
||
return
|
||
}
|
||
jsonText = text
|
||
importPhase = .parsing
|
||
Task {
|
||
await Task.yield()
|
||
do {
|
||
try await Task.sleep(for: .milliseconds(120))
|
||
let entries = try model.parseFastTransferJSON(text)
|
||
model.replaceFastTransferEntries(entries)
|
||
// State publishers are delivered on a later render pass. Refresh
|
||
// synchronously so a freshly parsed JSON never opens an empty
|
||
// task panel while SwiftUI waits for that notification.
|
||
refreshQueueSnapshot()
|
||
queueSection = .pending
|
||
queuePage = 0
|
||
importPhase = .ready
|
||
} catch {
|
||
model.clearFastTransferSession()
|
||
importPhase = .chooseSource
|
||
localError = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
private func pasteJSONFromClipboard() {
|
||
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else {
|
||
localError = "剪贴板中没有可用的 JSON 文本"
|
||
return
|
||
}
|
||
analyzeJSON(text)
|
||
}
|
||
|
||
private func removeTask(_ entry: FastTransferEntry) {
|
||
model.fastTransferEntries.removeAll { $0.id == entry.id }
|
||
}
|
||
|
||
private func cancelTask(_ entry: FastTransferEntry) {
|
||
if model.isFastTransferRunning {
|
||
model.cancelFastTransferEntry(entry.id)
|
||
} else {
|
||
removeTask(entry)
|
||
}
|
||
}
|
||
|
||
private func saveTargetDirectory(id: String?, path: String) {
|
||
targetID = id
|
||
targetName = path
|
||
let defaults = UserDefaults.standard
|
||
defaults.set(true, forKey: "guangya.fastTransfer.hasSavedTarget")
|
||
defaults.set(id ?? "", forKey: "guangya.fastTransfer.targetID")
|
||
defaults.set(path, forKey: "guangya.fastTransfer.targetName")
|
||
}
|
||
|
||
private func copyTaskPath(_ path: String) {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(path, forType: .string)
|
||
}
|
||
|
||
private func chooseJSONFile() {
|
||
let panel = NSOpenPanel()
|
||
panel.title = "选择秒传 JSON"
|
||
panel.prompt = "打开"
|
||
panel.allowedContentTypes = [.json]
|
||
panel.allowsMultipleSelection = false
|
||
panel.canChooseDirectories = false
|
||
panel.canChooseFiles = true
|
||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
let accessed = url.startAccessingSecurityScopedResource()
|
||
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
|
||
do {
|
||
analyzeJSON(try String(contentsOf: url, encoding: .utf8))
|
||
} catch {
|
||
localError = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func resetImportSource() {
|
||
guard !model.isFastTransferRunning else { return }
|
||
jsonText = ""
|
||
model.clearFastTransferSession()
|
||
importPhase = .chooseSource
|
||
}
|
||
|
||
private func refreshQueueSnapshot(entries: [FastTransferEntry]? = nil, results: [FastTransferResult]? = nil) {
|
||
queueSnapshot = FastTransferQueueSnapshot(entries: entries ?? model.fastTransferEntries, results: results ?? model.fastTransferResults)
|
||
let count = queueSnapshot.tasks(in: queueSection).count
|
||
let maxPage = max(0, Int(ceil(Double(count) / Double(queuePageSize))) - 1)
|
||
if queuePage > maxPage { queuePage = maxPage }
|
||
}
|
||
|
||
private func runImport() {
|
||
guard !pendingEntries.isEmpty else { return }
|
||
startImport(pendingEntries)
|
||
}
|
||
|
||
private func retry(_ results: [FastTransferResult]) {
|
||
guard !results.isEmpty else { return }
|
||
let retryingResultIDs = Dictionary(uniqueKeysWithValues: results.map { ($0.entry.id, $0.id) })
|
||
startImport(results.map(\.entry), retryingResultIDs: retryingResultIDs)
|
||
}
|
||
|
||
private func startImport(_ entries: [FastTransferEntry], retryingResultIDs: [UUID: UUID] = [:]) {
|
||
importTask = Task {
|
||
_ = await model.importFastTransferEntries(
|
||
entries,
|
||
parentID: targetID,
|
||
createDirectories: createDirectories,
|
||
skipExisting: skipExisting,
|
||
retryingResultIDs: retryingResultIDs
|
||
)
|
||
importTask = nil
|
||
}
|
||
}
|
||
|
||
private func stopImport() {
|
||
model.cancelFastTransfer()
|
||
importTask?.cancel()
|
||
}
|
||
|
||
private func generateJSON(for urls: [URL]) {
|
||
Task {
|
||
let accessed = urls.map { ($0, $0.startAccessingSecurityScopedResource()) }
|
||
defer { accessed.forEach { if $0.1 { $0.0.stopAccessingSecurityScopedResource() } } }
|
||
do {
|
||
generatedEntries = try await model.generateFastTransferEntries(from: urls)
|
||
generatedJSON = try model.fastTransferJSON(for: generatedEntries)
|
||
if model.fastTransferGenerationProgress.failed > 0 {
|
||
localError = "已生成 \(generatedEntries.count) 项,\(model.fastTransferGenerationProgress.failed) 项无法读取。"
|
||
}
|
||
} catch {
|
||
localError = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
private func saveGeneratedJSON() {
|
||
let panel = NSSavePanel()
|
||
panel.nameFieldStringValue = "光鸭秒传.json"
|
||
panel.allowedContentTypes = [.json]
|
||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||
do { try generatedJSON.write(to: url, atomically: true, encoding: .utf8) }
|
||
catch { localError = error.localizedDescription }
|
||
}
|
||
|
||
private func resultIcon(_ state: FastTransferResult.State) -> String {
|
||
switch state {
|
||
case .imported: return "checkmark.circle.fill"
|
||
case .skipped: return "forward.circle.fill"
|
||
case .failed: return "xmark.circle.fill"
|
||
case .cancelled: return "stop.circle.fill"
|
||
}
|
||
}
|
||
|
||
private func resultColor(_ state: FastTransferResult.State) -> Color {
|
||
switch state {
|
||
case .imported: return .green
|
||
case .skipped: return .orange
|
||
case .failed: return .red
|
||
case .cancelled: return .secondary
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct UnifiedScanToolPage: View {
|
||
@ObservedObject var model: AppModel
|
||
@State private var selectedRootID: String?
|
||
@State private var selectedRootName = "整个云盘"
|
||
@State private var showFolderPicker = false
|
||
@State private var duplicateMinimumSizeMB = 0
|
||
|
||
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") }
|
||
HStack(spacing: 10) {
|
||
Label("重复文件大小筛选", systemImage: "line.3.horizontal.decrease.circle")
|
||
.font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||
Stepper(value: $duplicateMinimumSizeMB, in: 0...50_000, step: 10) {
|
||
Text(duplicateMinimumSizeMB == 0 ? "不跳过小文件" : "跳过小于 \(duplicateMinimumSizeMB) MB 的文件")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.disabled(model.isScanning)
|
||
Spacer()
|
||
}
|
||
}
|
||
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 {
|
||
let minimumSize = kind == .duplicates && duplicateMinimumSizeMB > 0
|
||
? Int64(duplicateMinimumSizeMB) * 1_024 * 1_024
|
||
: nil
|
||
return Button { model.startScan(ScanRequest(kind: kind, scope: selectedRootID == nil ? .wholeDrive : .recursiveCurrentFolder, rootID: selectedRootID, rootName: selectedRootName, excludeFilesSmallerThan: minimumSize)) } 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
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum ScanFolderPickerSort: String, CaseIterable, Identifiable {
|
||
case nameAscending
|
||
case nameDescending
|
||
case lengthAscending
|
||
case lengthDescending
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .nameAscending: return "名称 A-Z"
|
||
case .nameDescending: return "名称 Z-A"
|
||
case .lengthAscending: return "名称长度:短到长"
|
||
case .lengthDescending: return "名称长度:长到短"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ScanFolderPickerSheet: View {
|
||
@ObservedObject var model: AppModel
|
||
var title = "选择扫描文件夹"
|
||
var selectionLabel = "选择此文件夹"
|
||
var allowsFilteringAndSorting = false
|
||
let onSelect: (String?, String) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var path: [FolderPath] = []
|
||
@State private var folderSearchText = ""
|
||
@State private var folderSort: ScanFolderPickerSort = .nameAscending
|
||
|
||
private var currentID: String? { path.last?.id }
|
||
private var currentName: String { path.isEmpty ? "整个云盘" : path.map(\.name).joined(separator: " / ") }
|
||
private var visibleFolders: [CloudFile] {
|
||
let query = folderSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let filtered = query.isEmpty
|
||
? model.scanPickerFolders
|
||
: model.scanPickerFolders.filter { $0.name.localizedCaseInsensitiveContains(query) }
|
||
return filtered.sorted { lhs, rhs in
|
||
switch folderSort {
|
||
case .nameAscending:
|
||
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||
case .nameDescending:
|
||
return lhs.name.localizedStandardCompare(rhs.name) == .orderedDescending
|
||
case .lengthAscending:
|
||
return lhs.name.count == rhs.name.count
|
||
? lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||
: lhs.name.count < rhs.name.count
|
||
case .lengthDescending:
|
||
return lhs.name.count == rhs.name.count
|
||
? lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
||
: lhs.name.count > rhs.name.count
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
if allowsFilteringAndSorting {
|
||
HStack(spacing: 10) {
|
||
Label("当前文件夹", systemImage: "folder")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
TextField("筛选目录", text: $folderSearchText)
|
||
.textFieldStyle(.roundedBorder)
|
||
Menu {
|
||
Picker("排序", selection: $folderSort) {
|
||
ForEach(ScanFolderPickerSort.allCases) { option in
|
||
Text(option.title).tag(option)
|
||
}
|
||
}
|
||
} label: {
|
||
Label("排序", systemImage: "arrow.up.arrow.down")
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
}
|
||
}
|
||
|
||
Group {
|
||
if model.isLoadingScanPicker {
|
||
VStack(spacing: 12) { ProgressView(); Text("正在读取文件夹…").foregroundStyle(.secondary) }
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else if visibleFolders.isEmpty {
|
||
ContentUnavailableView("没有子文件夹", systemImage: "folder", description: Text("可以选择当前文件夹作为扫描范围。"))
|
||
} else {
|
||
List(visibleFolders) { 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))
|
||
folderSearchText = ""
|
||
Task { await model.loadScanPickerFolders(parentID: folder.id) }
|
||
}
|
||
|
||
private func navigateToRoot() {
|
||
guard !model.isLoadingScanPicker else { return }
|
||
path = []
|
||
folderSearchText = ""
|
||
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)
|
||
folderSearchText = ""
|
||
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 automaticallySelectedSimilarIDs: Set<String> = []
|
||
@State private var showDeletionConfirmation = false
|
||
@State private var detailFolder: CloudFile?
|
||
@State private var duplicateQuickSelectMinimumMB = 100
|
||
|
||
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 {
|
||
HStack {
|
||
Toggle("全选空文件夹", isOn: Binding(get: { emptyFolderIDs.isSubset(of: selectedIDs) && !emptyFolderIDs.isEmpty }, set: { selectedIDs = $0 ? selectedIDs.union(emptyFolderIDs) : selectedIDs.subtracting(emptyFolderIDs) }))
|
||
Spacer()
|
||
Text("已选 \(selectedIDs.intersection(emptyFolderIDs).count) 个").font(.caption.weight(.semibold)).foregroundStyle(.orange)
|
||
emptyQuickSelectionMenu
|
||
}
|
||
}
|
||
else if kind == .duplicates {
|
||
HStack {
|
||
Text("勾选项将被删除;每组始终至少保留一个文件。").font(.caption).foregroundStyle(.secondary)
|
||
Spacer()
|
||
Text("已选 \(selectedFileIDsForDeletion.count) 项 · \(selectedDuplicateSizeText)").font(.caption.weight(.semibold)).monospacedDigit().foregroundStyle(.blue)
|
||
duplicateQuickSelectionMenu
|
||
}
|
||
} else {
|
||
HStack {
|
||
Text("每组已默认勾选除第一项外的文件夹;请根据完整路径复核保留项。").font(.caption).foregroundStyle(.secondary)
|
||
Spacer()
|
||
Text("已选 \(selectedFileIDsForDeletion.count) 个").font(.caption.weight(.semibold)).foregroundStyle(.green)
|
||
similarQuickSelectionMenu
|
||
}
|
||
}
|
||
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 ? "删除已选 \(selectedFileIDsForDeletion.count) 个空文件夹" : "删除已选 \(selectedFileIDsForDeletion.count) 项"), systemImage: "trash")
|
||
}
|
||
}.buttonStyle(.borderedProminent).disabled(selectedFileIDsForDeletion.isEmpty || model.isScanning || model.isDeletingScanResults)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.confirmationDialog(kind == .emptyFolders ? "确认删除已选空文件夹?" : "确认删除已选项目?", isPresented: $showDeletionConfirmation) {
|
||
Button(kind == .emptyFolders ? "删除 \(selectedFileIDsForDeletion.count) 个文件夹" : "删除 \(selectedFileIDsForDeletion.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) }
|
||
.onAppear { autoSelectSimilarFolders() }
|
||
.onChange(of: scanItemSignature) { _, _ in autoSelectSimilarFolders() }
|
||
.onChange(of: model.scanResult?.id) { _, resultID in
|
||
if resultID == nil && model.isScanning {
|
||
selectedIDs.removeAll()
|
||
automaticallySelectedSimilarIDs.removeAll()
|
||
}
|
||
}
|
||
}
|
||
|
||
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 emptyFolderIDs: Set<String> {
|
||
Set(resultItems.compactMap { item in
|
||
guard case .emptyFolder(let folder) = item else { return nil }
|
||
return folder.id
|
||
})
|
||
}
|
||
|
||
private var scanItemSignature: String {
|
||
resultItems.map { item in
|
||
switch item {
|
||
case .emptyFolder(let folder): return folder.id
|
||
case .duplicate(let group): return group.files.map(\.id).joined(separator: ",")
|
||
case .similar(let group): return group.folders.map(\.id).joined(separator: ",")
|
||
}
|
||
}
|
||
.joined(separator: "|")
|
||
}
|
||
|
||
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()
|
||
Stepper("阈值:\(duplicateQuickSelectMinimumMB) MB", value: $duplicateQuickSelectMinimumMB, in: 1...50_000, step: 10)
|
||
Button("选择大于 \(duplicateQuickSelectMinimumMB) MB 的文件") { selectDuplicateFilesLargerThan(Int64(duplicateQuickSelectMinimumMB) * 1_024 * 1_024) }
|
||
Divider()
|
||
Button("清除快速选择") { clearDuplicateSelection() }
|
||
} label: {
|
||
Label("快速选择", systemImage: "wand.and.stars")
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.fixedSize()
|
||
}
|
||
|
||
private var emptyQuickSelectionMenu: some View {
|
||
Menu {
|
||
Button("选择全部空文件夹") { selectedIDs.formUnion(emptyFolderIDs) }
|
||
Button("清除空文件夹选择") { selectedIDs.subtract(emptyFolderIDs) }
|
||
} label: {
|
||
Label("快速选择", systemImage: "wand.and.stars")
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.fixedSize()
|
||
}
|
||
|
||
private var similarQuickSelectionMenu: some View {
|
||
Menu {
|
||
Button("保留路径最短,选择其余项") { selectSimilarFoldersKeeping { shortestPathFile(in: $0) } }
|
||
Button("保留路径最长,选择其余项") { selectSimilarFoldersKeeping { longestPathFile(in: $0) } }
|
||
Divider()
|
||
Button("恢复默认选择") { autoSelectSimilarFolders(force: true) }
|
||
Button("清除相似文件夹选择") { clearSimilarSelection() }
|
||
} 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 selectDuplicateFilesLargerThan(_ minimumSize: Int64) {
|
||
for item in resultItems {
|
||
guard case .duplicate(let group) = item else { continue }
|
||
let groupIDs = Set(group.files.map(\.id))
|
||
var targets = group.files.filter { ($0.size ?? 0) > minimumSize }
|
||
// A duplicate group must retain one resource even when every file
|
||
// is above the configured threshold.
|
||
if targets.count == group.files.count,
|
||
let keep = group.files.min(by: { ($0.size ?? 0) < ($1.size ?? 0) }) {
|
||
targets.removeAll { $0.id == keep.id }
|
||
}
|
||
selectedIDs.subtract(groupIDs)
|
||
selectedIDs.formUnion(targets.map(\.id))
|
||
}
|
||
}
|
||
|
||
private func selectSimilarFoldersKeeping(_ choose: ([CloudFile]) -> CloudFile?) {
|
||
for item in resultItems {
|
||
guard case .similar(let group) = item, let kept = choose(group.folders) else { continue }
|
||
let groupIDs = Set(group.folders.map(\.id))
|
||
selectedIDs.subtract(groupIDs)
|
||
selectedIDs.formUnion(groupIDs.subtracting([kept.id]))
|
||
automaticallySelectedSimilarIDs.formUnion(groupIDs)
|
||
}
|
||
}
|
||
|
||
private func clearSimilarSelection() {
|
||
let ids = Set(resultItems.flatMap { item -> [String] in
|
||
guard case .similar(let group) = item else { return [] }
|
||
return group.folders.map(\.id)
|
||
})
|
||
selectedIDs.subtract(ids)
|
||
}
|
||
|
||
private func autoSelectSimilarFolders(force: Bool = false) {
|
||
guard kind == .similarFolders else { return }
|
||
if force {
|
||
clearSimilarSelection()
|
||
automaticallySelectedSimilarIDs.removeAll()
|
||
}
|
||
for item in resultItems {
|
||
guard case .similar(let group) = item else { continue }
|
||
for folder in group.folders.dropFirst() where !automaticallySelectedSimilarIDs.contains(folder.id) {
|
||
selectedIDs.insert(folder.id)
|
||
automaticallySelectedSimilarIDs.insert(folder.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
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 func similarSelectionBinding(for folder: CloudFile, in group: SimilarFolderGroup) -> Binding<Bool> {
|
||
Binding(
|
||
get: { selectedIDs.contains(folder.id) },
|
||
set: { shouldDelete in
|
||
if !shouldDelete { selectedIDs.remove(folder.id); return }
|
||
let selectedInGroup = group.folders.filter { selectedIDs.contains($0.id) }.count
|
||
if selectedInGroup < group.folders.count - 1 { selectedIDs.insert(folder.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(folder.id) }, set: { if $0 { selectedIDs.insert(folder.id) } else { selectedIDs.remove(folder.id) } })) {
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Image(systemName: "folder").foregroundStyle(.orange).frame(width: 22)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(folder.name).font(.callout.weight(.medium)).lineLimit(1)
|
||
Text(folder.cloudPath).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled)
|
||
}
|
||
Spacer()
|
||
VStack(alignment: .trailing, spacing: 3) {
|
||
Text("空文件夹").font(.caption.weight(.semibold)).foregroundStyle(.orange)
|
||
Text(folder.id).font(.caption2.monospaced()).foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
}
|
||
.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) {
|
||
HStack {
|
||
Text("相似名称组 · \(group.folders.count) 个文件夹").font(.caption.weight(.bold)).foregroundStyle(.green)
|
||
Spacer()
|
||
Text("自动保留第一项").font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
ForEach(group.folders) { folder in
|
||
let selected = selectedIDs.contains(folder.id)
|
||
Toggle(isOn: similarSelectionBinding(for: folder, in: group)) {
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Image(systemName: "folder.fill").foregroundStyle(.orange).frame(width: 22)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(folder.name).font(.callout.weight(.medium)).lineLimit(1)
|
||
Text(folder.cloudPath).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled)
|
||
}
|
||
Spacer()
|
||
Text(selected ? "将删除" : "保留")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(selected ? .red : .green)
|
||
}
|
||
}
|
||
}
|
||
}.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)) } }
|
||
}
|
||
}
|
||
}
|
||
|
||
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("登录页")
|
||
}
|
||
}
|