Files

813 lines
43 KiB
Swift

import AppKit
import CoreImage.CIFilterBuiltins
import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
@StateObject private var model = AppModel()
var body: some View {
Group {
if model.isSignedIn {
WorkspaceView(model: model)
} else {
LoginView(model: model)
}
}
.alert("请求失败", isPresented: Binding(get: { !model.errorMessage.isEmpty }, set: { if !$0 { model.errorMessage = "" } })) {
Button("确定", role: .cancel) { model.errorMessage = "" }
} message: {
Text(model.errorMessage)
}
}
}
private struct LiquidGlassModifier: ViewModifier {
let cornerRadius: CGFloat
@ViewBuilder
func body(content: Content) -> some View {
if #available(macOS 26.0, *) {
content.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
} else {
content.background(.regularMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
}
}
private extension View {
func liquidGlass(cornerRadius: CGFloat = 16) -> some View {
modifier(LiquidGlassModifier(cornerRadius: cornerRadius))
}
}
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 {
Color(nsColor: .windowBackgroundColor).ignoresSafeArea()
HStack(spacing: 0) {
LoginBrandPanel()
LoginFormPanel(model: model, mode: $mode)
}
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Color.black.opacity(0.08), lineWidth: 1)
}
.shadow(color: .black.opacity(0.18), radius: 30, y: 14)
.padding(28)
}
.frame(minWidth: 900, minHeight: 620)
.task {
guard autoStartQR, model.qrPayload.isEmpty, !model.isBusy else { return }
await model.startQRLogin()
}
}
}
private struct LoginBrandPanel: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 12) {
BrandMark(size: 46)
VStack(alignment: .leading, spacing: 2) {
Text("光鸭云盘").font(.headline)
Text("GUANGYA CLOUD").font(.system(size: 9, weight: .semibold, design: .rounded)).tracking(1.2).foregroundStyle(.white.opacity(0.62))
}
}
Spacer()
Text("文件,").font(.system(size: 42, weight: .bold, design: .rounded))
Text("在你身边。").font(.system(size: 42, weight: .bold, design: .rounded))
Text("轻松管理每一份重要内容。")
.font(.title3)
.foregroundStyle(.white.opacity(0.76))
.padding(.top, 14)
VStack(alignment: .leading, spacing: 14) {
BrandFeature(icon: "square.stack.3d.up.fill", title: "云端文件集中管理")
BrandFeature(icon: "arrow.triangle.2.circlepath", title: "随时访问你的内容")
BrandFeature(icon: "lock.shield.fill", title: "安全连接,安心使用")
}
.padding(.top, 38)
Spacer()
HStack(spacing: 7) {
Circle().fill(Color.green).frame(width: 7, height: 7)
Text("服务正常运行").font(.caption).foregroundStyle(.white.opacity(0.72))
}
}
.foregroundStyle(.white)
.padding(42)
.frame(width: 370)
.frame(maxHeight: .infinity, alignment: .leading)
.background(Color(red: 0.95, green: 0.30, blue: 0.055))
}
}
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: 20)
Text(title).font(.callout).foregroundStyle(.white.opacity(0.86))
}
}
}
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)
}
Picker("登录方式", selection: $mode) {
Label("二维码登录", systemImage: "qrcode").tag(0)
Label("手机号登录", systemImage: "iphone").tag(1)
}
.pickerStyle(.segmented)
Group {
if mode == 0 {
QRLoginPanel(model: model)
} else {
SMSLoginPanel(model: model)
}
}
.frame(maxWidth: .infinity)
}
.padding(32)
.frame(width: 450)
.liquidGlass(cornerRadius: 18)
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.black.opacity(0.06), lineWidth: 1)
}
Spacer()
HStack(spacing: 5) {
Image(systemName: "checkmark.seal.fill")
Text("光鸭云盘安全登录")
}
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .underPageBackgroundColor))
}
}
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)
}
Button {
Task { await model.startQRLogin() }
} label: {
HStack(spacing: 8) {
if model.isBusy { ProgressView().controlSize(.small) }
Image(systemName: model.qrPayload.isEmpty ? "qrcode" : "arrow.clockwise")
Text(model.qrPayload.isEmpty ? "生成登录二维码" : "刷新二维码")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(Color.orange)
.disabled(model.isBusy)
Text("请使用光鸭 App 扫码登录")
.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)
}
}
private struct WorkspaceView: View {
@ObservedObject var model: AppModel
@State private var selectedFileID: String?
@State private var renameFile: CloudFile?
@State private var isCreatingFolder = false
@State private var isImporting = false
@State private var isShowingSettings = false
@State private var isShowingDuplicates = false
@State private var isShowingTools = false
@State private var folderName = ""
private var selectedFile: CloudFile? { model.files.first { $0.id == selectedFileID } }
var body: some View {
NavigationSplitView {
WorkspaceSidebar(model: model, isShowingTools: $isShowingTools)
} content: {
FilesBrowser(model: model, selectedFileID: $selectedFileID, renameFile: $renameFile, isCreatingFolder: $isCreatingFolder, isImporting: $isImporting, isShowingSettings: $isShowingSettings, isShowingDuplicates: $isShowingDuplicates, folderName: $folderName)
} detail: {
FileInspector(model: model, file: selectedFile)
}
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 270)
.frame(minWidth: 1180, minHeight: 700)
.background(Color(nsColor: .underPageBackgroundColor))
.sheet(item: $renameFile) { file in RenameSheet(file: file) { name in Task { await model.rename(file, to: name) } } }
.sheet(isPresented: $isCreatingFolder) {
CreateFolderSheet(name: $folderName) { name in
isCreatingFolder = false
folderName = ""
Task { await model.createFolder(name: name) }
} onCancel: { isCreatingFolder = false }
}
.sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) }
.sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) }
.sheet(isPresented: $isShowingTools) { ToolsPanel(model: model, isShowingSettings: $isShowingSettings, isShowingDuplicates: $isShowingDuplicates) }
.fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in
if case .success(let urls) = result, let url = urls.first { Task { await model.upload(url: url) } }
}
.alert("提示", isPresented: Binding(get: { !model.lastActionMessage.isEmpty }, set: { if !$0 { model.lastActionMessage = "" } })) {
Button("确定", role: .cancel) { model.lastActionMessage = "" }
} message: { Text(model.lastActionMessage) }
.onChange(of: model.section) { _, _ in Task { await model.refresh() } }
}
}
private struct WorkspaceSidebar: View {
@ObservedObject var model: AppModel
@Binding var isShowingTools: Bool
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
BrandMark(size: 32)
VStack(alignment: .leading, spacing: 1) { Text("光鸭云盘").font(.headline); Text("CLOUD WORKSPACE").font(.system(size: 8, weight: .semibold, design: .rounded)).tracking(1).foregroundStyle(.secondary) }
Spacer()
}.padding(.horizontal, 18).padding(.vertical, 20)
List(selection: $model.section) {
Section("文件空间") { ForEach([WorkspaceSection.files, .photos, .videos, .documents]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } }
Section("工作区") { ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } }
}.listStyle(.sidebar).scrollContentBackground(.hidden)
Divider()
VStack(alignment: .leading, spacing: 8) {
Button { isShowingTools = true } label: {
Label("工具", systemImage: "slider.horizontal.3")
.font(.caption.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
}.buttonStyle(.plain)
Text("清理、去重、相似文件夹和工作区设置").font(.caption2).foregroundStyle(.tertiary).fixedSize(horizontal: false, vertical: true)
}.padding(.horizontal, 18).padding(.vertical, 14)
Divider()
HStack(spacing: 10) {
Circle().fill(Color.orange.opacity(0.18)).frame(width: 32, height: 32).overlay { Text(String(model.user.name.prefix(1))).font(.headline).foregroundStyle(.orange) }
VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.medium)).lineLimit(1); Text(model.user.phone).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Button { model.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") }.buttonStyle(.plain).help("退出登录")
}.padding(14)
}
.padding(8)
.liquidGlass(cornerRadius: 18)
.padding(8)
}
}
private struct FilesBrowser: View {
@ObservedObject var model: AppModel
@Binding var selectedFileID: String?
@Binding var renameFile: CloudFile?
@Binding var isCreatingFolder: Bool
@Binding var isImporting: Bool
@Binding var isShowingSettings: Bool
@Binding var isShowingDuplicates: Bool
@Binding var folderName: String
@State private var searchText = ""
private var filteredFiles: [CloudFile] { searchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(searchText) } }
var body: some View {
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text(model.section.rawValue).font(.system(size: 24, weight: .bold, design: .rounded))
if model.section == .files && !model.folderPath.isEmpty {
HStack(spacing: 5) {
Button("全部文件") { Task { await model.navigateToRoot() } }.buttonStyle(.link)
ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in
Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary)
Button(folder.name) { Task { await model.navigateToFolder(at: index) } }.buttonStyle(.link).lineLimit(1)
}
}.font(.caption)
} else {
Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.subheadline).foregroundStyle(.secondary)
}
}
Spacer()
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("搜索文件", text: $searchText).textFieldStyle(.plain).frame(width: 130)
}.padding(.horizontal, 11).padding(.vertical, 9).liquidGlass(cornerRadius: 11)
Button { isImporting = true } label: { Label("上传", systemImage: "arrow.up.circle") }.buttonStyle(.borderedProminent).tint(.orange)
Button { isCreatingFolder = true } label: { Image(systemName: "folder.badge.plus") }.buttonStyle(.bordered).help("新建文件夹")
Button { Task { await model.refresh() } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.bordered).help("刷新").disabled(model.isBusy || model.isLoadingFiles)
Menu {
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") }.menuStyle(.borderlessButton).help("更多工具")
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.liquidGlass(cornerRadius: 16)
.padding(.horizontal, 12)
.padding(.top, 14)
.padding(.bottom, 12)
if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section) }
else if model.isLoadingFiles && model.files.isEmpty { VStack(spacing: 12) { ProgressView(); Text("正在加载文件…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) }
else if filteredFiles.isEmpty { EmptyFilesView(model: model) }
else {
List(selection: $selectedFileID) {
ForEach(filteredFiles) { file in
BrowserRow(file: file) {
selectedFileID = file.id
Task { await model.open(file) }
} onSelect: { selectedFileID = file.id }
.tag(file.id)
.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
}
}
}.listStyle(.inset(alternatesRowBackgrounds: true))
}
}
.background(Color(nsColor: .underPageBackgroundColor))
}
@ViewBuilder private func fileMenu(_ file: CloudFile) -> some View {
Button { Task { await model.open(file) } } label: { Label("打开", systemImage: "arrow.up.right.square") }
if !file.isDirectory { Button { Task { await model.download(file) } } label: { Label("下载", systemImage: "arrow.down.circle") } }
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") }
Button { Task { await model.showDetails(file) } } label: { Label("查看详情", systemImage: "info.circle") }
if file.isDirectory {
Button { Task { await model.recognizeTMDBFolder(file) } } label: { Label("TMDB 识别当前文件夹及子文件夹", systemImage: "film") }
} else {
Button { Task { await model.recognizeTMDB(file) } } label: { Label("TMDB 识别", systemImage: "film") }
Button { Task { await model.recognizeTMDB(file, organize: true) } } label: { Label("TMDB 识别并整理", systemImage: "film.stack") }
}
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) { Task { await model.delete(file) } } label: { Label("删除", systemImage: "trash") }
}
}
private struct BrowserRow: View {
let file: CloudFile
let onOpen: () -> Void
let onSelect: () -> Void
var body: some View {
Button(action: file.isDirectory ? onOpen : onSelect) {
HStack(spacing: 12) {
Image(systemName: file.icon).font(.title3).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(file.name).font(.body).lineLimit(1)
HStack(spacing: 6) { Text(file.typeName); if file.isDirectory && file.size != nil { Text("·"); Text(file.formattedSize) } }.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(file.formattedSize).font(.callout).foregroundStyle(.secondary).frame(width: 90, alignment: .trailing)
Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout).foregroundStyle(.secondary).frame(width: 130, alignment: .trailing)
if file.isDirectory { Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) }
}.padding(.vertical, 8).frame(maxWidth: .infinity, alignment: .leading)
}.buttonStyle(.plain)
}
}
private struct FileInspector: View {
@ObservedObject var model: AppModel
let file: CloudFile?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let file {
VStack(alignment: .leading, spacing: 16) {
HStack { Image(systemName: file.icon).font(.system(size: 30)).foregroundStyle(file.isDirectory ? .orange : .accentColor); Spacer(); Button { Task { await model.showDetails(file) } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.plain).help("刷新详情") }
Text(file.name).font(.title3.weight(.semibold)).lineLimit(2)
HStack { Label(file.typeName, systemImage: file.isDirectory ? "folder" : "doc"); Spacer(); Text(file.formattedSize).foregroundStyle(.secondary) }.font(.caption)
Divider()
VStack(alignment: .leading, spacing: 10) {
InspectorField(title: "文件 ID", value: file.id)
InspectorField(title: "GCID", value: file.gcid ?? "未获取")
InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt)
}
HStack(spacing: 8) { Button { Task { await model.open(file) } } label: { Label(file.isDirectory ? "打开文件夹" : "下载", systemImage: file.isDirectory ? "folder" : "arrow.down.circle") }.buttonStyle(.borderedProminent).tint(.orange); Button { Task { await model.share(file) } } label: { Image(systemName: "square.and.arrow.up") }.buttonStyle(.bordered) }
if let detail = model.detail { Divider(); Text("接口详情").font(.caption.weight(.semibold)); ScrollView { Text(jsonText(detail)).font(.system(.caption, design: .monospaced)).textSelection(.enabled) }.frame(maxHeight: 220) }
Spacer()
}.padding(22)
} else {
VStack(spacing: 12) { Image(systemName: "sidebar.right").font(.system(size: 32, weight: .light)).foregroundStyle(.tertiary); Text("选择一个文件").font(.headline); Text("详情和操作会显示在这里").font(.caption).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(minWidth: 250, idealWidth: 290)
.padding(8)
.liquidGlass(cornerRadius: 18)
.padding(8)
.onChange(of: file?.id) { _, _ in model.detail = nil }
}
}
private struct InspectorField: View { let title: String; let value: String; var body: some View { VStack(alignment: .leading, spacing: 3) { Text(title.uppercased()).font(.system(size: 9, weight: .semibold)).foregroundStyle(.tertiary); Text(value).font(.caption).lineLimit(2).textSelection(.enabled) } } }
private struct CreateFolderSheet: View {
@Binding var name: String
let onCreate: (String) -> Void
let onCancel: () -> Void
var body: some View { VStack(alignment: .leading, spacing: 16) { Text("新建文件夹").font(.title3.bold()); TextField("文件夹名称", text: $name).textFieldStyle(.roundedBorder); HStack { Spacer(); Button("取消", action: onCancel); Button("创建") { onCreate(name) }.buttonStyle(.borderedProminent).tint(.orange) } }.padding(24).frame(width: 340) }
}
private struct RenameSheet: View {
let file: CloudFile
let onRename: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
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: 16) { Text("重命名").font(.title3.bold()); TextField("名称", text: $name).textFieldStyle(.roundedBorder); HStack { Spacer(); Button("取消") { dismiss() }; Button("保存") { onRename(name); dismiss() }.buttonStyle(.borderedProminent).tint(.orange) } }.padding(24).frame(width: 340) }
}
private struct WorkspaceSettings: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var key = ""
@State private var proxyHost = ""
@State private var proxyPort = ""
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
Image(systemName: "gearshape.2.fill").font(.title2).foregroundStyle(.orange)
Text("工作区设置").font(.title3.bold())
}
Text("TMDB 识别").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
SecureField("本地记住的 API Key", text: $key).textFieldStyle(.roundedBorder)
Text("HTTP 代理").font(.caption.weight(.semibold)).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)
HStack { Spacer(); Button("取消") { dismiss() }; Button("保存") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort); dismiss() }.buttonStyle(.borderedProminent).tint(.orange) }
}
.padding(24)
.frame(width: 500)
.liquidGlass(cornerRadius: 20)
.onAppear { key = model.tmdbAPIKey; proxyHost = model.tmdbProxyHost; proxyPort = model.tmdbProxyPort }
}
}
private struct ToolsPanel: View {
@ObservedObject var model: AppModel
@Binding var isShowingSettings: Bool
@Binding var isShowingDuplicates: Bool
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack { Text("工作区工具").font(.title3.bold()); Spacer(); Button("关闭") { dismiss() } }
ToolAction(title: "清理空文件夹", detail: "递归扫描当前云盘并删除空目录", icon: "sparkles") { Task { await model.cleanEmptyFolders(); dismiss() } }
ToolAction(title: "扫描重复文件", detail: "按 GCID 查找重复资源", icon: "square.on.square") { Task { await model.scanDuplicates(); isShowingDuplicates = true; dismiss() } }
ToolAction(title: "排除小文件扫描", detail: "忽略小于 1 MB 的文件", icon: "square.on.square.fill") { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true; dismiss() } }
ToolAction(title: "查询相似文件夹", detail: "按标准化名称查找相似目录", icon: "rectangle.3.group") { model.findSimilarFolders(); dismiss() }
Divider()
Button { isShowingSettings = true; dismiss() } label: { Label("工作区设置", systemImage: "gearshape") }.buttonStyle(.bordered)
}
.padding(22)
.frame(width: 420)
.liquidGlass(cornerRadius: 20)
}
}
private struct ToolAction: View {
let title: String
let detail: String
let icon: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: icon).font(.title3).foregroundStyle(.orange).frame(width: 28)
VStack(alignment: .leading, spacing: 3) { Text(title).font(.body.weight(.medium)); Text(detail).font(.caption).foregroundStyle(.secondary) }
Spacer()
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
}.contentShape(Rectangle())
}.buttonStyle(.plain)
}
}
private struct DuplicateResults: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("重复文件").font(.title3.bold())
Spacer()
Button("关闭") { dismiss() }
}
if model.isAnalyzing {
ProgressView("正在按 GCID 扫描…")
} else if model.duplicateGroups.isEmpty {
Text("没有发现重复文件").foregroundStyle(.secondary)
} else {
List(model.duplicateGroups) { group in
VStack(alignment: .leading, spacing: 6) {
Text("GCID \(group.id)").font(.caption.monospaced()).foregroundStyle(.secondary)
ForEach(group.files) { file in
HStack {
Image(systemName: file.icon)
Text(file.name).lineLimit(1)
Spacer()
Text(file.formattedSize).foregroundStyle(.secondary)
}
}
}
}
.frame(minHeight: 260)
}
}
.padding(20)
.frame(width: 560, height: 430)
}
}
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 LegacyWorkspaceView: View {
@ObservedObject var model: AppModel
var body: some View {
NavigationSplitView { LegacyWorkspaceSidebar(model: model) } detail: { LegacyFilesView(model: model) }
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 270).frame(minWidth: 980, minHeight: 620)
.onChange(of: model.section) { _, _ in Task { await model.refresh() } }
}
}
private struct LegacyWorkspaceSidebar: View {
@ObservedObject var model: AppModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) { BrandMark(size: 30); Text("光鸭云盘").font(.headline); Spacer() }.padding(.horizontal, 16).padding(.vertical, 18)
List(selection: $model.section) {
Section("文件空间") { ForEach([WorkspaceSection.files, .photos, .videos, .documents]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } }
Section("更多") { ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } }
}.listStyle(.sidebar)
Divider()
HStack(spacing: 10) {
Circle().fill(Color.orange.opacity(0.18)).frame(width: 32, height: 32).overlay { Text(String(model.user.name.prefix(1))).font(.headline).foregroundStyle(.orange) }
VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.medium)).lineLimit(1); Text(model.user.phone).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Button { model.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") }.buttonStyle(.plain).help("退出登录")
}.padding(14)
}.background(Color(nsColor: .controlBackgroundColor))
}
}
private struct LegacyFilesView: View {
@ObservedObject var model: AppModel
@State private var searchText = ""
@State private var isImporting = false
@State private var isCreatingFolder = false
@State private var folderName = ""
private var filteredFiles: [CloudFile] { searchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(searchText) } }
var body: some View {
VStack(spacing: 0) {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 6) {
Text(model.section.rawValue).font(.system(size: 26, weight: .bold))
if model.section == .files && !model.folderPath.isEmpty {
HStack(spacing: 5) {
Button("全部文件") { Task { await model.navigateToRoot() } }.buttonStyle(.link)
ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in
Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary)
Button(folder.name) { Task { await model.navigateToFolder(at: index) } }.buttonStyle(.link).lineLimit(1)
}
}.font(.caption)
} else {
Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.subheadline).foregroundStyle(.secondary)
}
}
Spacer()
TextField("搜索文件", text: $searchText).textFieldStyle(.roundedBorder).frame(width: 190)
Button { isImporting = true } label: { Image(systemName: "arrow.up") }.buttonStyle(.bordered).help("上传文件")
Button { isCreatingFolder = true } label: { Image(systemName: "folder.badge.plus") }.buttonStyle(.bordered).help("新建文件夹")
Button { Task { await model.refresh() } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.bordered).help("刷新")
.disabled(model.isBusy || model.isLoadingFiles)
}.padding(.horizontal, 28).padding(.vertical, 24)
Divider()
if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section) }
else if model.isLoadingFiles && model.files.isEmpty { VStack(spacing: 12) { ProgressView(); Text("正在加载文件…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) }
else if filteredFiles.isEmpty { EmptyFilesView(model: model) }
else {
List {
ForEach(filteredFiles) { file in
Group {
if file.isDirectory {
Button { Task { await model.openFolder(file) } } label: { LegacyFileRow(file: file) }
.buttonStyle(.plain)
} else {
LegacyFileRow(file: file)
}
}
.contextMenu { Button(role: .destructive) { Task { await model.delete(file) } } label: { Label("删除", systemImage: "trash") } }
}
}.listStyle(.inset)
}
}
.fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in
if case .success(let urls) = result, let url = urls.first { Task { await model.upload(url: url) } }
}
.sheet(isPresented: $isCreatingFolder) {
VStack(alignment: .leading, spacing: 16) {
Text("新建文件夹").font(.title3.bold()); TextField("文件夹名称", text: $folderName).textFieldStyle(.roundedBorder)
HStack { Spacer(); Button("取消") { isCreatingFolder = false }; Button("创建") { isCreatingFolder = false; Task { await model.createFolder(name: folderName); folderName = "" } }.buttonStyle(.borderedProminent) }
}.padding(24).frame(width: 330)
}
}
}
private struct LegacyFileRow: View {
let file: CloudFile
var body: some View {
HStack(spacing: 14) {
Image(systemName: file.icon).font(.title3).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(file.name).font(.body).lineLimit(1)
HStack(spacing: 6) {
Text(file.typeName)
if file.isDirectory && file.size != nil {
Text("·")
Text(file.formattedSize)
}
}.font(.caption).foregroundStyle(.secondary)
}
Spacer(); Text(file.formattedSize).font(.callout).foregroundStyle(.secondary).frame(width: 90, alignment: .trailing); Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout).foregroundStyle(.secondary).frame(width: 140, alignment: .trailing)
}.padding(.vertical, 8).frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct EmptyFilesView: View {
@ObservedObject var model: AppModel
var body: some View { VStack(spacing: 12) { Image(systemName: model.section.icon).font(.system(size: 46, weight: .light)).foregroundStyle(.tertiary); Text("这里还没有文件").font(.title3.weight(.medium)); Text("上传文件或创建文件夹开始使用").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) }
}
private struct EmptySectionView: View {
let section: WorkspaceSection
var body: some View { VStack(spacing: 12) { Image(systemName: section.icon).font(.system(size: 46, weight: .light)).foregroundStyle(.tertiary); Text(section == .cloud ? "云下载" : "我的分享").font(.title3.weight(.medium)); Text("接口已就绪,登录后可以在这里管理内容").foregroundStyle(.secondary) }.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("登录页")
}
}