实现现代化云盘工作区与文件管理功能
@@ -0,0 +1,15 @@
|
||||
.DS_Store
|
||||
|
||||
# Xcode user and build data
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
DerivedData/
|
||||
build/
|
||||
|
||||
# Swift package and local tooling artifacts
|
||||
.swiftpm/
|
||||
.build/
|
||||
|
||||
# Local signing and environment files
|
||||
*.mobileprovision
|
||||
.env
|
||||
@@ -0,0 +1,357 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
8C0E0B3E2C40000100A10001 /* guangya_macApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B362C40000100A10001 /* guangya_macApp.swift */; };
|
||||
8C0E0B3F2C40000100A10001 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B372C40000100A10001 /* ContentView.swift */; };
|
||||
8C0E0B402C40000100A10001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B382C40000100A10001 /* Assets.xcassets */; };
|
||||
8C0E0B412C40000100A10001 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B3A2C40000100A10001 /* Preview Assets.xcassets */; };
|
||||
8C0E0B522C40000100A10001 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B422C40000100A10001 /* Models.swift */; };
|
||||
8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */; };
|
||||
8C0E0B542C40000100A10001 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B442C40000100A10001 /* AppModel.swift */; };
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
8C0E0B332C40000100A10001 /* guangya_mac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = guangya_mac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8C0E0B362C40000100A10001 /* guangya_macApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = guangya_macApp.swift; sourceTree = "<group>"; };
|
||||
8C0E0B372C40000100A10001 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
8C0E0B382C40000100A10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
8C0E0B3A2C40000100A10001 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
8C0E0B422C40000100A10001 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
8C0E0B432C40000100A10001 /* GuangyaAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuangyaAPI.swift; sourceTree = "<group>"; };
|
||||
8C0E0B442C40000100A10001 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
8C0E0B452C40000100A10001 /* 光鸭.svg */ = {isa = PBXFileReference; lastKnownFileType = image.svg; path = "光鸭.svg"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
8C0E0B302C40000100A10001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
8C0E0B292C40000100A10001 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C0E0B352C40000100A10001 /* guangya_mac */,
|
||||
8C0E0B342C40000100A10001 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8C0E0B342C40000100A10001 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C0E0B332C40000100A10001 /* guangya_mac.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8C0E0B352C40000100A10001 /* guangya_mac */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C0E0B362C40000100A10001 /* guangya_macApp.swift */,
|
||||
8C0E0B372C40000100A10001 /* ContentView.swift */,
|
||||
8C0E0B422C40000100A10001 /* Models.swift */,
|
||||
8C0E0B432C40000100A10001 /* GuangyaAPI.swift */,
|
||||
8C0E0B442C40000100A10001 /* AppModel.swift */,
|
||||
8C0E0B452C40000100A10001 /* 光鸭.svg */,
|
||||
8C0E0B382C40000100A10001 /* Assets.xcassets */,
|
||||
8C0E0B392C40000100A10001 /* Preview Content */,
|
||||
);
|
||||
path = guangya_mac;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8C0E0B392C40000100A10001 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C0E0B3A2C40000100A10001 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
8C0E0B322C40000100A10001 /* guangya_mac */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 8C0E0B4B2C40000100A10001 /* Build configuration list for PBXNativeTarget "guangya_mac" */;
|
||||
buildPhases = (
|
||||
8C0E0B2F2C40000100A10001 /* Sources */,
|
||||
8C0E0B302C40000100A10001 /* Frameworks */,
|
||||
8C0E0B312C40000100A10001 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = guangya_mac;
|
||||
productName = guangya_mac;
|
||||
productReference = 8C0E0B332C40000100A10001 /* guangya_mac.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
8C0E0B2A2C40000100A10001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
8C0E0B322C40000100A10001 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 8C0E0B2D2C40000100A10001 /* Build configuration list for PBXProject "guangya_mac" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 8C0E0B292C40000100A10001;
|
||||
productRefGroup = 8C0E0B342C40000100A10001 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
8C0E0B322C40000100A10001 /* guangya_mac */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
8C0E0B312C40000100A10001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
8C0E0B402C40000100A10001 /* Assets.xcassets in Resources */,
|
||||
8C0E0B412C40000100A10001 /* Preview Assets.xcassets in Resources */,
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
8C0E0B2F2C40000100A10001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
8C0E0B3E2C40000100A10001 /* guangya_macApp.swift in Sources */,
|
||||
8C0E0B3F2C40000100A10001 /* ContentView.swift in Sources */,
|
||||
8C0E0B522C40000100A10001 /* Models.swift in Sources */,
|
||||
8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */,
|
||||
8C0E0B542C40000100A10001 /* AppModel.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
8C0E0B492C40000100A10001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"guangya_mac/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = guangya_mac;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.guangya-mac;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
8C0E0B4A2C40000100A10001 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"guangya_mac/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = guangya_mac;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.guangya-mac;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
8C0E0B4C2C40000100A10001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
8C0E0B4D2C40000100A10001 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
8C0E0B2D2C40000100A10001 /* Build configuration list for PBXProject "guangya_mac" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
8C0E0B4C2C40000100A10001 /* Debug */,
|
||||
8C0E0B4D2C40000100A10001 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
8C0E0B4B2C40000100A10001 /* Build configuration list for PBXNativeTarget "guangya_mac" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
8C0E0B492C40000100A10001 /* Debug */,
|
||||
8C0E0B4A2C40000100A10001 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 8C0E0B2A2C40000100A10001 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
@Published var isSignedIn = false
|
||||
@Published var user = UserProfile(json: .object([:]))
|
||||
@Published var section: WorkspaceSection = .files
|
||||
@Published var files: [CloudFile] = []
|
||||
@Published var folderPath: [FolderPath] = []
|
||||
@Published var isLoadingFiles = false
|
||||
@Published var isLoadingFolderSizes = false
|
||||
@Published var duplicateGroups: [DuplicateGroup] = []
|
||||
@Published var similarFolders: [CloudFile] = []
|
||||
@Published var detail: JSONValue?
|
||||
@Published var lastActionMessage = ""
|
||||
@Published var tmdbAPIKey = UserDefaults.standard.string(forKey: "guangya.tmdbAPIKey") ?? ""
|
||||
@Published var tmdbProxyHost = UserDefaults.standard.string(forKey: "guangya.tmdbProxyHost") ?? ""
|
||||
@Published var tmdbProxyPort = UserDefaults.standard.string(forKey: "guangya.tmdbProxyPort") ?? ""
|
||||
@Published var isAnalyzing = false
|
||||
@Published var isBusy = false
|
||||
@Published var statusMessage = ""
|
||||
@Published var errorMessage = ""
|
||||
@Published var phoneNumber = "+86 "
|
||||
@Published var verificationCode = ""
|
||||
@Published var captchaToken = ""
|
||||
@Published var verificationID = ""
|
||||
@Published var qrPayload = ""
|
||||
@Published var qrToken = ""
|
||||
@Published var qrStatus = "等待生成二维码"
|
||||
@Published var codeCountdown = 0
|
||||
|
||||
let api: GuangyaAPI
|
||||
private var qrPollingTask: Task<Void, Never>?
|
||||
private var countdownTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
let defaults = UserDefaults.standard
|
||||
let access = defaults.string(forKey: "guangya.accessToken") ?? ""
|
||||
let refresh = defaults.string(forKey: "guangya.refreshToken")
|
||||
api = GuangyaAPI(accessToken: access, refreshToken: refresh)
|
||||
if !access.isEmpty {
|
||||
isSignedIn = true
|
||||
Task { await loadAccount() }
|
||||
}
|
||||
}
|
||||
|
||||
func sendVerificationCode() async {
|
||||
guard phoneNumber.filter({ $0.isNumber }).count >= 8 else { errorMessage = "请输入有效的手机号"; return }
|
||||
await perform("正在发送验证码…") { [self] in
|
||||
let initResult = try await api.loginSMSInit(phoneNumber: phoneNumber)
|
||||
guard let captcha = initResult.firstString(["captcha_token", "captchaToken"]) else {
|
||||
if initResult.firstString(["url", "verify_url", "verifyUrl"]) != nil { throw GuangyaAPIError.http(status: 403, message: "需要完成验证码验证后再发送短信") }
|
||||
throw GuangyaAPIError.missingField("captcha_token")
|
||||
}
|
||||
captchaToken = captcha
|
||||
let result = try await api.loginSMSSend(phoneNumber: phoneNumber, captchaToken: captcha)
|
||||
guard let id = result.firstString(["verification_id", "verificationId", "id"]) else { throw GuangyaAPIError.missingField("verification_id") }
|
||||
verificationID = id
|
||||
startCountdown()
|
||||
statusMessage = "验证码已发送"
|
||||
}
|
||||
}
|
||||
|
||||
func verifyAndSignIn() async {
|
||||
guard !verificationID.isEmpty, !captchaToken.isEmpty else { errorMessage = "请先发送验证码"; return }
|
||||
guard verificationCode.count >= 4 else { errorMessage = "请输入短信验证码"; return }
|
||||
await perform("正在登录…") { [self] in
|
||||
let verified = try await api.loginSMSVerify(verificationID: verificationID, verificationCode: verificationCode)
|
||||
guard let token = verified.firstString(["verification_token", "verificationToken"]) else { throw GuangyaAPIError.missingField("verification_token") }
|
||||
_ = try await api.loginSMSSignIn(code: verificationCode, verificationToken: token, username: phoneNumber, captchaToken: captchaToken)
|
||||
completeLogin()
|
||||
}
|
||||
}
|
||||
|
||||
func startQRLogin() async {
|
||||
qrPollingTask?.cancel()
|
||||
qrPayload = ""
|
||||
qrToken = ""
|
||||
qrStatus = "正在生成二维码…"
|
||||
await perform("正在生成二维码…") { [self] in
|
||||
let result = try await api.loginQRInit()
|
||||
qrToken = result.firstStringDeep(["device_code", "deviceCode", "qr_token", "qrToken", "token", "id"]) ?? ""
|
||||
qrPayload = result.firstStringDeep(["verification_uri_complete", "verificationUriComplete", "qr_url", "qrUrl", "url", "verification_uri", "verificationUri", "qrcode", "qrCode", "code"]) ?? qrToken
|
||||
guard !qrPayload.isEmpty else { throw GuangyaAPIError.missingField("二维码内容") }
|
||||
qrStatus = "请使用光鸭 App 扫码"
|
||||
let expiresIn = result.firstIntDeep(["expires_in", "expiresIn"]) ?? 120
|
||||
let interval = max(2, result.firstIntDeep(["interval", "pollInterval", "poll_interval"]) ?? 3)
|
||||
startQRPolling(expiresIn: expiresIn, interval: interval)
|
||||
}
|
||||
}
|
||||
|
||||
func stopQRLogin() { qrPollingTask?.cancel(); qrPollingTask = nil }
|
||||
|
||||
func refresh() async {
|
||||
guard isSignedIn else { return }
|
||||
await perform("正在刷新…") { [self] in await loadFiles() }
|
||||
}
|
||||
|
||||
func openFolder(_ file: CloudFile) async {
|
||||
guard file.isDirectory, !isLoadingFiles else { return }
|
||||
folderPath.append(FolderPath(id: file.id, name: file.name))
|
||||
await loadFiles()
|
||||
}
|
||||
|
||||
func navigateToRoot() async {
|
||||
guard !folderPath.isEmpty, !isLoadingFiles else { return }
|
||||
folderPath.removeAll()
|
||||
await loadFiles()
|
||||
}
|
||||
|
||||
func navigateToFolder(at index: Int) async {
|
||||
guard folderPath.indices.contains(index), !isLoadingFiles else { return }
|
||||
folderPath.removeSubrange((index + 1)..<folderPath.count)
|
||||
await loadFiles()
|
||||
}
|
||||
|
||||
func createFolder(name: String) async {
|
||||
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
await perform("正在创建文件夹…") { [self] in _ = try await api.fsCreateDir(name: name, parentID: folderPath.last?.id); await loadFiles() }
|
||||
}
|
||||
|
||||
func delete(_ file: CloudFile) async {
|
||||
await perform("正在删除…") { [self] in _ = try await api.fsDelete(fileIDs: [file.id]); await loadFiles() }
|
||||
}
|
||||
|
||||
func upload(url: URL) async {
|
||||
await perform("正在上传 \(url.lastPathComponent)…") { [self] in _ = try await api.fileUpload(url: url, parentID: folderPath.last?.id); await loadFiles() }
|
||||
}
|
||||
|
||||
func open(_ file: CloudFile) async {
|
||||
if file.isDirectory { await openFolder(file) } else { await download(file) }
|
||||
}
|
||||
|
||||
func download(_ file: CloudFile) async {
|
||||
await perform("正在准备下载…") { [self] in
|
||||
let result = try await api.downloadURL(fileID: file.id)
|
||||
guard let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]) else { throw GuangyaAPIError.missingField("下载地址") }
|
||||
guard let url = URL(string: value) else { throw GuangyaAPIError.invalidResponse }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func rename(_ file: CloudFile, to name: String) async {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, trimmed != file.name else { return }
|
||||
await perform("正在重命名…") { [self] in _ = try await api.fsRename(fileID: file.id, newName: trimmed); await loadFiles() }
|
||||
}
|
||||
|
||||
func copy(_ file: CloudFile, to parentID: String?) async {
|
||||
await perform("正在复制…") { [self] in _ = try await api.fsCopy(fileIDs: [file.id], parentID: parentID); await loadFiles() }
|
||||
}
|
||||
|
||||
func move(fileIDs: [String], to parentID: String?) async {
|
||||
guard !fileIDs.isEmpty else { return }
|
||||
await perform("正在移动…") { [self] in _ = try await api.fsMove(fileIDs: fileIDs, parentID: parentID); await loadFiles() }
|
||||
}
|
||||
|
||||
func move(_ file: CloudFile, to parentID: String?) async {
|
||||
await perform("正在移动…") { [self] in _ = try await api.fsMove(fileIDs: [file.id], parentID: parentID); await loadFiles() }
|
||||
}
|
||||
|
||||
func restore(_ file: CloudFile) async {
|
||||
await perform("正在恢复…") { [self] in _ = try await api.fsRecycle(fileIDs: [file.id]); await loadFiles() }
|
||||
}
|
||||
|
||||
func showDetails(_ file: CloudFile) async {
|
||||
isBusy = true
|
||||
statusMessage = "正在读取详情…"
|
||||
do {
|
||||
detail = try await api.fsDetail(fileID: file.id)
|
||||
} catch {
|
||||
var fallback: [String: JSONValue] = [
|
||||
"fileId": .string(file.id),
|
||||
"fileName": .string(file.name),
|
||||
"dirType": .number(file.isDirectory ? 1 : 0),
|
||||
"resType": .number(Double(file.fileType))
|
||||
]
|
||||
if let size = file.size { fallback["size"] = .number(Double(size)) }
|
||||
if let gcid = file.gcid { fallback["gcid"] = .string(gcid) }
|
||||
detail = .object(["fileInfo": .object(fallback), "message": .string("详情请求失败:\(error.localizedDescription),已显示当前列表详情")])
|
||||
}
|
||||
isBusy = false
|
||||
statusMessage = ""
|
||||
}
|
||||
|
||||
func share(_ file: CloudFile) async {
|
||||
await perform("正在创建分享…") { [self] in
|
||||
let result = try await api.shareCreate(fileIDs: [file.id], title: file.name)
|
||||
if let url = result.firstStringDeep(["url", "shareUrl", "share_url", "link"]) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
lastActionMessage = "分享链接已复制"
|
||||
} else {
|
||||
lastActionMessage = "分享已创建"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cleanEmptyFolders() async {
|
||||
await perform("正在扫描空文件夹…") { [self] in
|
||||
var emptyIDs: [String] = []
|
||||
@MainActor func scan(parentID: String?) async throws {
|
||||
let items = extractFiles(from: try await api.fsFiles(parentID: parentID))
|
||||
for item in items where item.isDirectory {
|
||||
let children = extractFiles(from: try await api.fsFiles(parentID: item.id))
|
||||
if children.isEmpty { emptyIDs.append(item.id) }
|
||||
else { try await scan(parentID: item.id) }
|
||||
}
|
||||
}
|
||||
try await scan(parentID: nil)
|
||||
for id in emptyIDs.reversed() { _ = try await api.fsDelete(fileIDs: [id]) }
|
||||
lastActionMessage = emptyIDs.isEmpty ? "没有发现空文件夹" : "已清理 \(emptyIDs.count) 个空文件夹"
|
||||
await loadFiles()
|
||||
}
|
||||
}
|
||||
|
||||
func scanDuplicates(excludingSmallFiles: Bool = false) async {
|
||||
guard !isAnalyzing else { return }
|
||||
isAnalyzing = true
|
||||
defer { isAnalyzing = false }
|
||||
let candidates = files.filter { !$0.isDirectory }
|
||||
let api = self.api
|
||||
let detailed = await withTaskGroup(of: CloudFile?.self, returning: [CloudFile].self) { group in
|
||||
for file in candidates {
|
||||
group.addTask {
|
||||
guard let detail = try? await api.fsDetail(fileID: file.id) else { return file }
|
||||
var copy = file
|
||||
copy.gcid = detail.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"])
|
||||
copy.size = detail.firstInt64Deep(["size", "fileSize", "resSize"])
|
||||
return copy
|
||||
}
|
||||
}
|
||||
var result: [CloudFile] = []
|
||||
for await file in group { if let file { result.append(file) } }
|
||||
return result
|
||||
}
|
||||
let eligible = excludingSmallFiles ? detailed.filter { ($0.size ?? 0) >= 1024 * 1024 } : detailed
|
||||
let groups: [String: [(String, CloudFile)]] = Dictionary(grouping: eligible.compactMap { file in file.gcid.map { ($0, file) } }, by: { $0.0 })
|
||||
duplicateGroups = groups.compactMap { key, values in
|
||||
guard values.count > 1 else { return nil }
|
||||
return DuplicateGroup(id: key, files: values.map { $0.1 })
|
||||
}.sorted { $0.files.count > $1.files.count }
|
||||
lastActionMessage = duplicateGroups.isEmpty ? "没有发现重复文件" : "发现 \(duplicateGroups.count) 组重复文件"
|
||||
}
|
||||
|
||||
func copyTransferJSON(for file: CloudFile) {
|
||||
let payload: [String: Any] = ["name": file.name, "fileId": file.id, "size": file.size ?? 0, "gcid": file.gcid ?? ""]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]), let text = String(data: data, encoding: .utf8) else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "\(file.name).transfer.json"
|
||||
panel.allowedContentTypes = [.json]
|
||||
if panel.runModal() == .OK, let url = panel.url { try? data.write(to: url, options: .atomic); lastActionMessage = "秒传 JSON 已保存并复制" }
|
||||
else { lastActionMessage = "秒传 JSON 已复制" }
|
||||
}
|
||||
|
||||
func saveTMDBSettings(key: String, proxyHost: String, proxyPort: String) {
|
||||
tmdbAPIKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
tmdbProxyHost = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
tmdbProxyPort = proxyPort.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
UserDefaults.standard.set(tmdbAPIKey, forKey: "guangya.tmdbAPIKey")
|
||||
UserDefaults.standard.set(tmdbProxyHost, forKey: "guangya.tmdbProxyHost")
|
||||
UserDefaults.standard.set(tmdbProxyPort, forKey: "guangya.tmdbProxyPort")
|
||||
lastActionMessage = tmdbAPIKey.isEmpty ? "TMDB 配置已清除" : "TMDB 配置已保存"
|
||||
}
|
||||
|
||||
func recognizeTMDB(_ file: CloudFile, organize: Bool = false) async {
|
||||
guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return }
|
||||
await perform(organize ? "正在识别并整理…" : "正在识别…") { [self] in
|
||||
let match = try await tmdbMatch(for: file.name)
|
||||
if organize {
|
||||
let created = try await api.fsCreateDir(name: match.title, parentID: folderPath.last?.id)
|
||||
guard let folderID = created.firstStringDeep(["fileId", "id", "resId"]) else { throw GuangyaAPIError.missingField("整理文件夹 ID") }
|
||||
_ = try await api.fsMove(fileIDs: [file.id], parentID: folderID)
|
||||
let metadata = ["tmdbId": match.id, "title": match.title, "mediaType": match.mediaType, "releaseDate": match.releaseDate, "overview": match.overview] as [String: Any]
|
||||
let metadataURL = FileManager.default.temporaryDirectory.appendingPathComponent("tmdb-\(match.id).json")
|
||||
let data = try JSONSerialization.data(withJSONObject: metadata, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: metadataURL, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: metadataURL) }
|
||||
_ = try await api.fileUpload(url: metadataURL, parentID: folderID, contentType: "application/json")
|
||||
lastActionMessage = "已整理到「\(match.title)」并上传刮削信息"
|
||||
await loadFiles()
|
||||
} else {
|
||||
lastActionMessage = "识别结果:\(match.title)\(match.releaseDate.isEmpty ? "" : "(\(match.releaseDate.prefix(4)))")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recognizeTMDBFolder(_ folder: CloudFile) async {
|
||||
guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return }
|
||||
await perform("正在扫描文件夹并识别…") { [self] in
|
||||
var pending = [folder.id]
|
||||
var media: [CloudFile] = []
|
||||
while let parentID = pending.popLast() {
|
||||
let items = extractFiles(from: try await api.fsFiles(parentID: parentID))
|
||||
for item in items {
|
||||
if item.isDirectory { pending.append(item.id) } else { media.append(item) }
|
||||
}
|
||||
}
|
||||
var matches: [String] = []
|
||||
for file in media {
|
||||
if let match = try? await tmdbMatch(for: file.name) { matches.append(match.title) }
|
||||
}
|
||||
lastActionMessage = matches.isEmpty ? "当前文件夹及子文件夹没有识别结果" : "识别到 \(matches.count) 个媒体文件:\n" + matches.prefix(8).joined(separator: "、")
|
||||
}
|
||||
}
|
||||
|
||||
func findSimilarFolders() {
|
||||
let folders = files.filter(\.isDirectory)
|
||||
let groups = Dictionary(grouping: folders, by: { normalizedFolderName($0.name) })
|
||||
similarFolders = groups.values.filter { $0.count > 1 }.flatMap { $0 }
|
||||
lastActionMessage = similarFolders.isEmpty ? "当前目录没有相似文件夹" : "找到 \(similarFolders.count) 个相似文件夹"
|
||||
}
|
||||
|
||||
func logout() {
|
||||
stopQRLogin()
|
||||
UserDefaults.standard.removeObject(forKey: "guangya.accessToken")
|
||||
UserDefaults.standard.removeObject(forKey: "guangya.refreshToken")
|
||||
isSignedIn = false
|
||||
files = []
|
||||
folderPath = []
|
||||
statusMessage = ""
|
||||
}
|
||||
|
||||
private func completeLogin() {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(api.accessToken, forKey: "guangya.accessToken")
|
||||
if let refresh = api.refreshTokenValue { defaults.set(refresh, forKey: "guangya.refreshToken") }
|
||||
isSignedIn = true
|
||||
Task { await loadAccount() }
|
||||
}
|
||||
|
||||
private func loadAccount() async {
|
||||
await api.prepareSession()
|
||||
isLoadingFiles = true
|
||||
defer { isLoadingFiles = false }
|
||||
async let userResult: JSONValue? = try? await api.userInfo()
|
||||
async let fileResult: JSONValue? = try? await filesResponse()
|
||||
if let result = await userResult {
|
||||
user = UserProfile(json: result)
|
||||
} else {
|
||||
statusMessage = "登录状态已恢复"
|
||||
}
|
||||
if let result = await fileResult {
|
||||
files = extractFiles(from: result)
|
||||
await enrichPageDetails()
|
||||
} else {
|
||||
errorMessage = "文件列表加载失败,请点击刷新重试"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFiles() async {
|
||||
guard !isLoadingFiles else { return }
|
||||
isLoadingFiles = true
|
||||
defer { isLoadingFiles = false }
|
||||
do {
|
||||
let result = try await filesResponse()
|
||||
files = extractFiles(from: result)
|
||||
await enrichPageDetails()
|
||||
if files.isEmpty { statusMessage = "当前文件夹暂无文件" }
|
||||
else if statusMessage == "当前文件夹暂无文件" { statusMessage = "" }
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func filesResponse() async throws -> JSONValue {
|
||||
switch section {
|
||||
case .files: return try await api.fsFiles(parentID: folderPath.last?.id)
|
||||
case .photos: return try await api.fsImageList()
|
||||
case .videos: return try await api.fsVideoList()
|
||||
case .documents: return try await api.fsDocumentList()
|
||||
case .recycle: return try await api.fsRecycleFiles()
|
||||
case .cloud, .shares: return .object([:])
|
||||
}
|
||||
}
|
||||
|
||||
private func tmdbMatch(for name: String) async throws -> TMDBMatch {
|
||||
let query = name.replacingOccurrences(of: "\\.[^.]+$", with: "", options: .regularExpression).replacingOccurrences(of: "[._()\\[\\]{}-]+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let response = try await api.tmdbSearch(query: query, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||||
guard let matches = response["results"]?.arrayValue?.compactMap(TMDBMatch.init), let match = matches.first else { throw GuangyaAPIError.missingField("TMDB 识别结果") }
|
||||
return match
|
||||
}
|
||||
|
||||
private func normalizedFolderName(_ value: String) -> String {
|
||||
value.lowercased().replacingOccurrences(of: "[\\s._()\\[\\]{}-]+", with: "", options: .regularExpression)
|
||||
}
|
||||
|
||||
private func enrichPageDetails() async {
|
||||
let page = files
|
||||
guard !page.isEmpty else { return }
|
||||
isLoadingFolderSizes = true
|
||||
defer { isLoadingFolderSizes = false }
|
||||
let api = self.api
|
||||
let details = await withTaskGroup(of: (String, Int64?, String?, Int64?).self, returning: [(String, Int64?, String?, Int64?)].self) { group in
|
||||
for file in page {
|
||||
group.addTask {
|
||||
let detail = try? await api.fsDetail(fileID: file.id)
|
||||
return (file.id, detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]), detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]), detail?.firstInt64Deep(["utime", "ctime"]))
|
||||
}
|
||||
}
|
||||
var result: [(String, Int64?, String?, Int64?)] = []
|
||||
for await item in group { result.append(item) }
|
||||
return result
|
||||
}
|
||||
var updatedFiles = files
|
||||
for index in updatedFiles.indices {
|
||||
guard let item = details.first(where: { $0.0 == updatedFiles[index].id }) else { continue }
|
||||
if let size = item.1 { updatedFiles[index].size = size }
|
||||
if let gcid = item.2 { updatedFiles[index].gcid = gcid }
|
||||
if let epoch = item.3 { updatedFiles[index].modifiedAt = Self.formatDate(epoch) }
|
||||
}
|
||||
files = updatedFiles
|
||||
}
|
||||
|
||||
private static func formatDate(_ epoch: Int64) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch)))
|
||||
}
|
||||
|
||||
private func extractFiles(from value: JSONValue) -> [CloudFile] {
|
||||
var result: [CloudFile] = []
|
||||
func visit(_ value: JSONValue) {
|
||||
if let file = CloudFile(json: value), !result.contains(where: { $0.id == file.id }) { result.append(file) }
|
||||
if let object = value.objectValue { object.values.forEach(visit) }
|
||||
if let array = value.arrayValue { array.forEach(visit) }
|
||||
}
|
||||
visit(value)
|
||||
return result
|
||||
}
|
||||
|
||||
private func startQRPolling(expiresIn: Int, interval: Int) {
|
||||
qrPollingTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let deadline = Date().addingTimeInterval(TimeInterval(expiresIn))
|
||||
while !Task.isCancelled && !self.qrToken.isEmpty && Date() < deadline {
|
||||
try? await Task.sleep(for: .seconds(interval))
|
||||
guard !Task.isCancelled else { return }
|
||||
do {
|
||||
let result = try await self.api.loginQRPoll(token: self.qrToken)
|
||||
if self.api.accessToken.isEmpty == false {
|
||||
self.qrStatus = "扫码成功,正在进入网盘…"
|
||||
self.completeLogin()
|
||||
return
|
||||
}
|
||||
self.qrStatus = result.firstStringDeep(["message", "msg", "statusText"]) ?? "等待扫码"
|
||||
} catch let error as GuangyaAPIError where error.isDeviceAuthorizationPending {
|
||||
self.qrStatus = "等待扫码"
|
||||
} catch {
|
||||
self.qrStatus = "二维码状态查询失败"
|
||||
self.errorMessage = error.localizedDescription
|
||||
return
|
||||
}
|
||||
}
|
||||
if !Task.isCancelled && Date() >= deadline { self.qrStatus = "二维码已过期,请刷新" }
|
||||
}
|
||||
}
|
||||
|
||||
private func startCountdown() {
|
||||
countdownTask?.cancel()
|
||||
codeCountdown = 60
|
||||
countdownTask = Task { [weak self] in
|
||||
while let self, self.codeCountdown > 0, !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
self.codeCountdown -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ message: String, operation: @escaping @MainActor () async throws -> Void) async {
|
||||
isBusy = true; errorMessage = ""; statusMessage = message
|
||||
do { try await operation(); if errorMessage.isEmpty { statusMessage = "" } }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
isBusy = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_16x16.png",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_32x32.png",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_128x128.png",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_256x256.png",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_512x512.png",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 694 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 408 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
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("登录页")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import CryptoKit
|
||||
import CFNetwork
|
||||
import Foundation
|
||||
|
||||
enum GuangyaAPIError: LocalizedError {
|
||||
case invalidResponse
|
||||
case http(status: Int, message: String)
|
||||
case missingField(String)
|
||||
case noRefreshToken
|
||||
|
||||
var isDeviceAuthorizationPending: Bool {
|
||||
guard case .http(let status, let message) = self, status == 400 else { return false }
|
||||
let normalized = message.lowercased()
|
||||
return normalized.contains("authorization_pending") || normalized.contains("slow_down")
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: return "服务器返回了无法识别的数据"
|
||||
case .http(let status, let message): return "请求失败(\(status)):\(message)"
|
||||
case .missingField(let field): return "响应缺少字段:\(field)"
|
||||
case .noRefreshToken: return "没有可用的刷新令牌"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class GuangyaAPI: @unchecked Sendable {
|
||||
static let accountBase = URL(string: "https://account.guangyapan.com")!
|
||||
static let apiBase = URL(string: "https://api.guangyapan.com")!
|
||||
static let clientID = "aMe-8VSlkrbQXpUR"
|
||||
|
||||
private let session: URLSession
|
||||
private(set) var accessToken: String
|
||||
private(set) var refreshTokenValue: String?
|
||||
private var tokenExpiresAt: Date?
|
||||
let deviceID: String
|
||||
|
||||
init(accessToken: String = "", refreshToken: String? = nil, deviceID: String = GuangyaAPI.generateDeviceID()) {
|
||||
self.accessToken = accessToken
|
||||
self.refreshTokenValue = refreshToken
|
||||
self.deviceID = deviceID
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
configuration.timeoutIntervalForResource = 30
|
||||
configuration.waitsForConnectivity = false
|
||||
configuration.httpAdditionalHeaders = [
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"did": deviceID,
|
||||
"dt": "4",
|
||||
"Origin": "https://www.guangyapan.com",
|
||||
"Referer": "https://www.guangyapan.com/",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
|
||||
]
|
||||
session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
func updateTokens(from value: JSONValue) -> AuthTokens? {
|
||||
guard let access = value.firstStringDeep(["access_token", "accessToken"]) else { return nil }
|
||||
accessToken = access
|
||||
refreshTokenValue = value.firstStringDeep(["refresh_token", "refreshToken"]) ?? refreshTokenValue
|
||||
let expires = value.firstIntDeep(["expires_in", "expiresIn"]).map(Double.init)
|
||||
tokenExpiresAt = expires.map { Date().addingTimeInterval($0) }
|
||||
return AuthTokens(accessToken: access, refreshToken: refreshTokenValue, expiresIn: expires)
|
||||
}
|
||||
|
||||
func prepareSession() async {
|
||||
guard !accessToken.isEmpty, refreshTokenValue != nil, tokenExpiresAt == nil else { return }
|
||||
_ = try? await refreshToken()
|
||||
}
|
||||
|
||||
// MARK: Authentication
|
||||
|
||||
func loginSMSInit(phoneNumber: String, captchaToken: String? = nil) async throws -> JSONValue {
|
||||
var body: [String: JSONValue] = [
|
||||
"client_id": .string(Self.clientID),
|
||||
"action": .string("POST:/v1/auth/verification"),
|
||||
"device_id": .string(deviceID),
|
||||
"meta": .object(["phone_number": .string(phoneNumber)])
|
||||
]
|
||||
if let captchaToken { body["captcha_token"] = .string(captchaToken) }
|
||||
return try await accountRequest("/v1/shield/captcha/init", body: .object(body))
|
||||
}
|
||||
|
||||
func loginSMSSend(phoneNumber: String, captchaToken: String, target: String = "ANY") async throws -> JSONValue {
|
||||
try await accountRequest("/v1/auth/verification", body: .object([
|
||||
"phone_number": .string(phoneNumber), "target": .string(target), "client_id": .string(Self.clientID)
|
||||
]), extraHeaders: ["x-captcha-token": captchaToken])
|
||||
}
|
||||
|
||||
func loginSMSVerify(verificationID: String, verificationCode: String) async throws -> JSONValue {
|
||||
try await accountRequest("/v1/auth/verification/verify", body: .object([
|
||||
"verification_id": .string(verificationID), "verification_code": .string(verificationCode), "client_id": .string(Self.clientID)
|
||||
]))
|
||||
}
|
||||
|
||||
func loginSMSSignIn(code: String, verificationToken: String, username: String, captchaToken: String) async throws -> JSONValue {
|
||||
let result = try await accountRequest("/v1/auth/signin", body: .object([
|
||||
"verification_code": .string(code), "verification_token": .string(verificationToken), "username": .string(username), "client_id": .string(Self.clientID)
|
||||
]), extraHeaders: ["x-captcha-token": captchaToken])
|
||||
_ = updateTokens(from: result)
|
||||
return result
|
||||
}
|
||||
|
||||
/// OAuth device authorization flow used by the web client QR login.
|
||||
func loginQRInit() async throws -> JSONValue {
|
||||
try await accountRequest("/v1/auth/device/code", body: .object([
|
||||
"scope": .string("user"), "client_id": .string(Self.clientID)
|
||||
]))
|
||||
}
|
||||
|
||||
func loginQRPoll(token: String) async throws -> JSONValue {
|
||||
let result = try await accountRequest("/v1/auth/token", body: .object([
|
||||
"grant_type": .string("urn:ietf:params:oauth:grant-type:device_code"),
|
||||
"device_code": .string(token),
|
||||
"client_id": .string(Self.clientID)
|
||||
]))
|
||||
_ = updateTokens(from: result)
|
||||
return result
|
||||
}
|
||||
|
||||
func refreshToken(_ token: String? = nil) async throws -> JSONValue {
|
||||
guard let token = token ?? refreshTokenValue else { throw GuangyaAPIError.noRefreshToken }
|
||||
let result = try await accountRequest("/v1/auth/token", body: .object([
|
||||
"client_id": .string(Self.clientID), "grant_type": .string("refresh_token"), "refresh_token": .string(token)
|
||||
]), extraHeaders: ["x-action": "401"])
|
||||
_ = updateTokens(from: result)
|
||||
return result
|
||||
}
|
||||
|
||||
func userInfo() async throws -> JSONValue {
|
||||
try await accountRequest("/v1/user/me", body: nil, authenticated: true)
|
||||
}
|
||||
|
||||
// MARK: Cloud download, file system, and sharing
|
||||
|
||||
func cloudTaskList(page: Int = 0, pageSize: Int = 50, status: [Int] = [0, 1, 3, 4]) async throws -> JSONValue {
|
||||
try await apiRequest("/nd.bizcloudcollection.s/v1/list_task", body: object(("page", page), ("pageSize", pageSize), ("status", status)))
|
||||
}
|
||||
|
||||
func cloudResolveURL(_ url: String) async throws -> JSONValue {
|
||||
try await apiRequest("/nd.bizcloudcollection.s/v1/resolve_res", body: object(("url", url)))
|
||||
}
|
||||
|
||||
func cloudResolveTorrent(data: Data, filename: String = "file.torrent") async throws -> JSONValue {
|
||||
try await apiRequest("/nd.bizcloudcollection.s/v1/resolve_torrent", method: "POST", multipart: [("torrent", filename, "application/octet-stream", data)])
|
||||
}
|
||||
|
||||
func cloudCreateTask(url: String, parentID: String? = nil) async throws -> JSONValue {
|
||||
try await apiRequest("/nd.bizcloudcollection.s/v1/create_task", body: object(("url", url), ("parentId", parentID ?? "")))
|
||||
}
|
||||
|
||||
func downloadURL(fileID: String) async throws -> JSONValue {
|
||||
try await apiRequest("/nd.bizuserres.s/v1/get_res_download_url", body: object(("fileId", fileID)))
|
||||
}
|
||||
|
||||
func taskStatus(taskID: String) async throws -> JSONValue {
|
||||
try await apiRequest("/nd.bizuserres.s/v1/get_task_status", body: object(("taskId", taskID)))
|
||||
}
|
||||
|
||||
func fsFiles(parentID: String? = nil, page: Int = 0, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0, fileTypes: [Int]? = nil, resType: Int? = nil, dirType: Int? = nil, needPlayRecord: Bool = false) async throws -> JSONValue {
|
||||
var body: [String: JSONValue] = ["parentId": .string(parentID ?? ""), "page": .number(Double(page)), "pageSize": .number(Double(pageSize)), "orderBy": .number(Double(orderBy)), "sortType": .number(Double(sortType))]
|
||||
if let fileTypes { body["fileTypes"] = .array(fileTypes.map { .number(Double($0)) }) }
|
||||
if let resType { body["resType"] = .number(Double(resType)) }
|
||||
if let dirType { body["dirType"] = .number(Double(dirType)) }
|
||||
if needPlayRecord { body["needPlayRecord"] = .bool(true) }
|
||||
return try await apiRequest("/userres/v1/file/get_file_list", body: .object(body))
|
||||
}
|
||||
|
||||
func fsImageList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [1], resType: 1) }
|
||||
func fsVideoList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [2], resType: 1) }
|
||||
func fsDocumentList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [4], resType: 1) }
|
||||
func fsRecycleFiles() async throws -> JSONValue { try await fsFiles(orderBy: 10, dirType: 4) }
|
||||
|
||||
func fsCreateDir(name: String, parentID: String? = nil, failIfNameExist: Bool = false) async throws -> JSONValue {
|
||||
var body = ["dirName": JSONValue.string(name), "parentId": .string(parentID ?? "")]
|
||||
if failIfNameExist { body["failIfNameExist"] = .bool(true) }
|
||||
return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body))
|
||||
}
|
||||
func fsCopy(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/copy_file", body: object(("fileIds", fileIDs), ("parentId", parentID ?? ""))) }
|
||||
func fsMove(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/move_file", body: object(("fileIds", fileIDs), ("parentId", parentID ?? ""))) }
|
||||
func fsDelete(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/delete_file", body: object(("fileIds", fileIDs))) }
|
||||
func fsRecycle(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/recycle_file", body: object(("fileIds", fileIDs))) }
|
||||
func fsClearRecycleBin() async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/clear_recycle_bin") }
|
||||
func fsRename(fileID: String, newName: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/rename", body: object(("fileId", fileID), ("newName", newName))) }
|
||||
func fsDetail(fileID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_file_detail", body: object(("fileId", fileID))) }
|
||||
|
||||
func shareCreate(fileIDs: [String], title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue {
|
||||
try await apiRequest("/nd.bizuserres.s/v1/share_file", body: object(("fileIds", fileIDs), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType)))
|
||||
}
|
||||
func shareUserList(page: Int = 0, pageSize: Int = 50) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_list", body: object(("page", page), ("pageSize", pageSize), ("orderType", 1), ("sortType", 1))) }
|
||||
func shareUpdate(shareID: String, title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/update_share", body: object(("id", shareID), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) }
|
||||
func shareDelete(ids: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/delete_share", body: object(("ids", ids))) }
|
||||
func shareRestore(accessToken: String, fileIDs: [String], parentID: String = "") async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/restore_share", body: object(("accessToken", accessToken), ("fileIds", fileIDs), ("parentId", parentID))) }
|
||||
func shareDownloadURL(fileID: String, accessToken: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_download_url", body: object(("fileId", fileID), ("accessToken", accessToken))) }
|
||||
func shareFilesSize(accessToken: String, fileIDs: [String], download: Bool = true) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_files_size", body: object(("accessToken", accessToken), ("fileIds", fileIDs), ("download", download))) }
|
||||
func shareSummary(shareID: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_summary", body: object(("shareId", shareID))) }
|
||||
func shareAccessToken(shareID: String, code: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_access_token", body: object(("shareId", shareID), ("code", code))) }
|
||||
func shareFilesList(accessToken: String, parentID: String = "", page: Int = 1, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_page_files_list", body: object(("accessToken", accessToken), ("parentId", parentID), ("page", page), ("pageSize", pageSize), ("orderBy", orderBy), ("sortType", sortType))) }
|
||||
|
||||
// MARK: Upload
|
||||
|
||||
func uploadToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String? = nil) async throws -> JSONValue {
|
||||
var res: [String: JSONValue] = ["fileSize": .number(Double(fileSize))]
|
||||
if let md5 { res["md5"] = .string(md5) }
|
||||
return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": .string(parentID ?? "")]))
|
||||
}
|
||||
func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) }
|
||||
func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID))) }
|
||||
|
||||
func tmdbSearch(query: String, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue {
|
||||
var components = URLComponents(string: "https://api.themoviedb.org/3/search/multi")!
|
||||
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "include_adult", value: "false")]
|
||||
var request = URLRequest(url: components.url!)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
configuration.timeoutIntervalForResource = 30
|
||||
configuration.waitsForConnectivity = false
|
||||
let normalizedHost = proxyHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "^https?://", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if !normalizedHost.isEmpty, let port = Int(proxyPort), (1...65535).contains(port) {
|
||||
configuration.connectionProxyDictionary = [
|
||||
kCFNetworkProxiesHTTPEnable as String: true,
|
||||
kCFNetworkProxiesHTTPProxy as String: normalizedHost,
|
||||
kCFNetworkProxiesHTTPPort as String: port,
|
||||
kCFNetworkProxiesHTTPSEnable as String: true,
|
||||
kCFNetworkProxiesHTTPSProxy as String: normalizedHost,
|
||||
kCFNetworkProxiesHTTPSPort as String: port
|
||||
]
|
||||
}
|
||||
let tmdbSession = URLSession(configuration: configuration)
|
||||
let (data, response) = try await tmdbSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") }
|
||||
return try JSONDecoder().decode(JSONValue.self, from: data)
|
||||
}
|
||||
|
||||
func fileUpload(url: URL, parentID: String? = nil, contentType: String = "application/octet-stream", chunkSize: Int = 5 * 1024 * 1024) async throws -> JSONValue {
|
||||
let values = try Data(contentsOf: url)
|
||||
let name = url.lastPathComponent
|
||||
if values.count < 1024 * 1024 {
|
||||
let digest = Data(Insecure.MD5.hash(data: values)).base64EncodedString()
|
||||
let token = try await uploadToken(name: name, fileSize: Int64(values.count), parentID: parentID, md5: digest)
|
||||
guard let taskID = token["data"]?.firstString(["taskId", "task_id"]) else { throw GuangyaAPIError.missingField("taskId") }
|
||||
return try await uploadInfo(taskID: taskID)
|
||||
}
|
||||
let token = try await uploadToken(name: name, fileSize: Int64(values.count), parentID: parentID)
|
||||
guard let taskID = token["data"]?.firstString(["taskId", "task_id"]) else { throw GuangyaAPIError.missingField("taskId") }
|
||||
let canFlash = try await checkCanFlashUpload(taskID: taskID, gcid: Self.calculateGCID(data: values))
|
||||
if canFlash["data"]?["canFlashUpload"]?.boolValue == true { return try await uploadInfo(taskID: taskID) }
|
||||
_ = try await cdnUpload(data: values, tokenData: token["data"] ?? .null, contentType: contentType, chunkSize: chunkSize)
|
||||
return try await uploadInfo(taskID: taskID)
|
||||
}
|
||||
|
||||
// MARK: Low-level requests and OSS signing
|
||||
|
||||
private func accountRequest(_ path: String, method: String = "POST", body: JSONValue?, extraHeaders: [String: String] = [:], authenticated: Bool = false) async throws -> JSONValue {
|
||||
var headers = accountHeaders()
|
||||
headers.merge(extraHeaders) { _, new in new }
|
||||
if authenticated || !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" }
|
||||
return try await request(Self.accountBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, refreshOnUnauthorized: false)
|
||||
}
|
||||
|
||||
private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil) async throws -> JSONValue {
|
||||
if let expiry = tokenExpiresAt, Date() >= expiry, refreshTokenValue != nil { _ = try? await refreshToken() }
|
||||
var headers = ["traceparent": Self.generateTraceparent()]
|
||||
if !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" }
|
||||
return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart)
|
||||
}
|
||||
|
||||
private func publicPost(_ path: String, body: JSONValue) async throws -> JSONValue {
|
||||
try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: "POST", body: body, headers: ["traceparent": Self.generateTraceparent()], refreshOnUnauthorized: false)
|
||||
}
|
||||
|
||||
private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true) async throws -> JSONValue {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
||||
if let multipart {
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = Self.multipartBody(parts: multipart, boundary: boundary)
|
||||
} else if let body { request.httpBody = try JSONEncoder().encode(body) }
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse }
|
||||
if http.statusCode == 401, refreshOnUnauthorized, refreshTokenValue != nil {
|
||||
_ = try? await refreshToken()
|
||||
return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let message: String
|
||||
if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) {
|
||||
message = decoded.firstStringDeep(["message", "msg", "error_description", "error"]) ?? String(data: data, encoding: .utf8) ?? "未知错误"
|
||||
} else {
|
||||
message = String(data: data, encoding: .utf8) ?? "未知错误"
|
||||
}
|
||||
throw GuangyaAPIError.http(status: http.statusCode, message: message)
|
||||
}
|
||||
if data.isEmpty { return .object([:]) }
|
||||
if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) {
|
||||
if let code = decoded.firstIntDeep(["code"]), code != 0 {
|
||||
throw GuangyaAPIError.http(status: code, message: decoded.firstStringDeep(["msg", "message"]) ?? "接口返回错误")
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
return .string(String(data: data, encoding: .utf8) ?? "")
|
||||
}
|
||||
|
||||
private func accountHeaders() -> [String: String] {
|
||||
["accept": "*/*", "content-type": "application/json", "origin": "https://www.guangyapan.com", "referer": "https://www.guangyapan.com/", "x-client-id": Self.clientID, "x-client-version": "0.0.1", "x-device-id": deviceID, "x-device-model": "chrome%2F147.0.0.0", "x-device-name": "PC-Chrome", "x-device-sign": "wdi10.\(deviceID)\(Self.randomHex(16))", "x-net-work-type": "NONE", "x-os-version": "MacIntel", "x-platform-version": "1", "x-protocol-version": "301", "x-provider-name": "NONE", "x-sdk-version": "9.0.2"]
|
||||
}
|
||||
|
||||
private func cdnUpload(data: Data, tokenData: JSONValue, contentType: String, chunkSize: Int) async throws -> String {
|
||||
guard let creds = tokenData["creds"], let key = creds.firstString(["accessKeyID", "accessKeyId"]), let secret = creds.firstString(["secretAccessKey"]), let sessionToken = creds.firstString(["sessionToken"]), let endpoint = tokenData.firstString(["fullEndPoint", "fullEndpoint"]), let bucket = tokenData.firstString(["bucketName"]), let objectPath = tokenData.firstString(["objectPath"]) else { throw GuangyaAPIError.missingField("OSS token data") }
|
||||
let objectURL = "\(endpoint)/\(objectPath)"
|
||||
let uploadResponse = try await ossRequest(method: "POST", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, subResources: ["uploads": ""])
|
||||
guard let uploadID = Self.xmlValue(uploadResponse.body, tag: "UploadId") else { throw GuangyaAPIError.missingField("UploadId") }
|
||||
var parts: [(Int, String)] = []
|
||||
var offset = 0
|
||||
var number = 1
|
||||
while offset < data.count {
|
||||
let end = min(offset + chunkSize, data.count)
|
||||
let chunk = data.subdata(in: offset..<end)
|
||||
let contentMD5 = Data(Insecure.MD5.hash(data: chunk)).base64EncodedString()
|
||||
let response = try await ossRequest(method: "PUT", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, content: chunk, contentType: "application/octet-stream", contentMD5: contentMD5, subResources: ["partNumber": "\(number)", "uploadId": uploadID])
|
||||
parts.append((number, response.headers["etag"]?.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) ?? ""))
|
||||
offset = end; number += 1
|
||||
}
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><CompleteMultipartUpload>" + parts.map { "<Part><PartNumber>\($0.0)</PartNumber><ETag>\"\($0.1)\"</ETag></Part>" }.joined() + "</CompleteMultipartUpload>"
|
||||
let result = try await ossRequest(method: "POST", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, content: Data(xml.utf8), contentType: "application/xml", contentMD5: Data(Insecure.MD5.hash(data: Data(xml.utf8))).base64EncodedString(), subResources: ["uploadId": uploadID])
|
||||
return Self.xmlValue(result.body, tag: "ETag") ?? ""
|
||||
}
|
||||
|
||||
private func ossRequest(method: String, url: String, bucket: String, objectKey: String, accessKeyID: String, secret: String, sessionToken: String, content: Data = Data(), contentType: String = "", contentMD5: String = "", subResources: [String: String] = [:]) async throws -> (body: String, headers: [String: String]) {
|
||||
let date = Self.httpDate()
|
||||
var canonical = [method.uppercased(), contentMD5, contentType, date, "x-oss-date:\(date)", "x-oss-security-token:\(sessionToken.trimmingCharacters(in: .whitespacesAndNewlines))", "/\(bucket)/\(objectKey)"]
|
||||
if !subResources.isEmpty { canonical[6] += "?" + subResources.keys.sorted().map { key in let value = subResources[key] ?? ""; return value.isEmpty ? key : "\(key)=\(value)" }.joined(separator: "&") }
|
||||
let signature = Data(HMAC<Insecure.SHA1>.authenticationCode(for: Data(canonical.joined(separator: "\n").utf8), using: SymmetricKey(data: Data(secret.utf8)))).base64EncodedString()
|
||||
var request = URLRequest(url: URL(string: url)!)
|
||||
request.httpMethod = method
|
||||
request.httpBody = content
|
||||
request.setValue("OSS \(accessKeyID):\(signature)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(date, forHTTPHeaderField: "x-oss-date")
|
||||
request.setValue(sessionToken, forHTTPHeaderField: "x-oss-security-token")
|
||||
if !contentType.isEmpty { request.setValue(contentType, forHTTPHeaderField: "Content-Type") }
|
||||
if !contentMD5.isEmpty { request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") }
|
||||
subResources.forEach { request.url = URL(string: "\(request.url!.absoluteString)\($0.key == subResources.keys.sorted().first ? "?" : "&")\($0.key)=\($0.value)") }
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse }
|
||||
guard (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: http.statusCode, message: String(data: data, encoding: .utf8) ?? "OSS 请求失败") }
|
||||
return (String(data: data, encoding: .utf8) ?? "", http.allHeaderFields.reduce(into: [:]) { result, item in result[String(describing: item.key).lowercased()] = String(describing: item.value) })
|
||||
}
|
||||
|
||||
private static func multipartBody(parts: [(String, String, String, Data)], boundary: String) -> Data {
|
||||
var body = Data()
|
||||
for (name, filename, contentType, data) in parts {
|
||||
body.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\nContent-Type: \(contentType)\r\n\r\n".utf8)); body.append(data); body.append(Data("\r\n".utf8))
|
||||
}
|
||||
body.append(Data("--\(boundary)--\r\n".utf8)); return body
|
||||
}
|
||||
|
||||
private static func xmlValue(_ xml: String, tag: String) -> String? { let pattern = "<\(tag)>(.*?)</\(tag)>"; guard let range = xml.range(of: pattern, options: .regularExpression) else { return nil }; let value = String(xml[range]); return value.replacingOccurrences(of: "<\(tag)>", with: "").replacingOccurrences(of: "</\(tag)>", with: "") }
|
||||
private func object(_ values: [String: Any]) -> JSONValue { .object(values.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) }) }
|
||||
private func object(_ values: (String, Any)...) -> JSONValue { object(values.reduce(into: [:]) { $0[$1.0] = JSONValue(any: $1.1) }) }
|
||||
private static func generateDeviceID() -> String { Insecure.MD5.hash(data: Data((0..<16).map { _ in UInt8.random(in: 0...255) })).map { String(format: "%02x", $0) }.joined() }
|
||||
private static func randomHex(_ count: Int) -> String { (0..<count).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined() }
|
||||
private static func generateTraceparent() -> String { "00-\(randomHex(16))-\(randomHex(8))-01" }
|
||||
private static func httpDate() -> String { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.timeZone = TimeZone(secondsFromGMT: 0); formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; return formatter.string(from: Date()) }
|
||||
private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset..<end))); offset = end }; return Insecure.SHA1.hash(data: hashes).map { String(format: "%02X", $0) }.joined() }
|
||||
}
|
||||
|
||||
private extension JSONValue {
|
||||
var boolValue: Bool? { if case .bool(let value) = self { return value }; return nil }
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import Foundation
|
||||
|
||||
enum JSONValue: Codable, Sendable, Equatable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case null
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() { self = .null; return }
|
||||
if let value = try? container.decode(Bool.self) { self = .bool(value); return }
|
||||
if let value = try? container.decode(Double.self) { self = .number(value); return }
|
||||
if let value = try? container.decode(String.self) { self = .string(value); return }
|
||||
if let value = try? container.decode([String: JSONValue].self) { self = .object(value); return }
|
||||
if let value = try? container.decode([JSONValue].self) { self = .array(value); return }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let value): try container.encode(value)
|
||||
case .number(let value): try container.encode(value)
|
||||
case .bool(let value): try container.encode(value)
|
||||
case .object(let value): try container.encode(value)
|
||||
case .array(let value): try container.encode(value)
|
||||
case .null: try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
static func object(_ values: [String: Any]) -> JSONValue {
|
||||
.object(values.reduce(into: [:]) { result, item in result[item.key] = JSONValue(any: item.value) })
|
||||
}
|
||||
|
||||
init(any value: Any) {
|
||||
switch value {
|
||||
case let value as String: self = .string(value)
|
||||
case let value as Int: self = .number(Double(value))
|
||||
case let value as Int64: self = .number(Double(value))
|
||||
case let value as Double: self = .number(value)
|
||||
case let value as Bool: self = .bool(value)
|
||||
case let value as [Any]: self = .array(value.map(JSONValue.init(any:)))
|
||||
case let value as [String: Any]: self = .object(value.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) })
|
||||
default: self = .null
|
||||
}
|
||||
}
|
||||
|
||||
var objectValue: [String: JSONValue]? { if case .object(let value) = self { return value }; return nil }
|
||||
var arrayValue: [JSONValue]? { if case .array(let value) = self { return value }; return nil }
|
||||
var stringValue: String? { if case .string(let value) = self { return value }; return nil }
|
||||
var doubleValue: Double? { if case .number(let value) = self { return value }; return nil }
|
||||
var intValue: Int? { doubleValue.map(Int.init) }
|
||||
var int64Value: Int64? { doubleValue.map(Int64.init) }
|
||||
|
||||
subscript(_ key: String) -> JSONValue? { objectValue?[key] }
|
||||
|
||||
func firstString(_ keys: [String]) -> String? {
|
||||
for key in keys where self[key]?.stringValue != nil { return self[key]?.stringValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
// API responses are not consistent about whether payload fields are nested in `data`.
|
||||
func firstStringDeep(_ keys: [String]) -> String? {
|
||||
if let value = firstString(keys) { return value }
|
||||
guard let object = objectValue else { return nil }
|
||||
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
||||
for key in preferredKeys {
|
||||
if let value = object[key]?.firstStringDeep(keys) { return value }
|
||||
}
|
||||
for (key, value) in object where !preferredKeys.contains(key) {
|
||||
if let found = value.firstStringDeep(keys) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstInt(_ keys: [String]) -> Int? {
|
||||
for key in keys where self[key]?.intValue != nil { return self[key]?.intValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstIntDeep(_ keys: [String]) -> Int? {
|
||||
if let value = firstInt(keys) { return value }
|
||||
guard let object = objectValue else { return nil }
|
||||
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
||||
for key in preferredKeys {
|
||||
if let value = object[key]?.firstIntDeep(keys) { return value }
|
||||
}
|
||||
for (key, value) in object where !preferredKeys.contains(key) {
|
||||
if let found = value.firstIntDeep(keys) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstInt64Deep(_ keys: [String]) -> Int64? {
|
||||
if let value = keys.compactMap({ self[$0]?.int64Value }).first { return value }
|
||||
guard let object = objectValue else { return nil }
|
||||
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
||||
for key in preferredKeys {
|
||||
if let value = object[key]?.firstInt64Deep(keys) { return value }
|
||||
}
|
||||
for (key, value) in object where !preferredKeys.contains(key) {
|
||||
if let found = value.firstInt64Deep(keys) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func formatFileDate(_ epoch: Int64) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch)))
|
||||
}
|
||||
|
||||
struct CloudFile: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let isDirectory: Bool
|
||||
var size: Int64?
|
||||
var gcid: String?
|
||||
var modifiedAt: String
|
||||
let fileType: Int
|
||||
|
||||
var icon: String {
|
||||
if isDirectory { return "folder.fill" }
|
||||
switch fileType {
|
||||
case 1: return "photo.fill"
|
||||
case 2: return "play.rectangle.fill"
|
||||
case 3: return "music.note"
|
||||
case 4: return "doc.text.fill"
|
||||
case 5, 9: return "archivebox.fill"
|
||||
default: return "doc.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var typeName: String {
|
||||
if isDirectory { return "文件夹" }
|
||||
return [1: "图片", 2: "视频", 3: "音频", 4: "文档", 5: "压缩包", 9: "BT种子"][fileType] ?? "文件"
|
||||
}
|
||||
|
||||
var formattedSize: String {
|
||||
guard let size else { return "--" }
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: size)
|
||||
}
|
||||
|
||||
init?(json: JSONValue) {
|
||||
guard let object = json.objectValue,
|
||||
let name = json.firstString(["name", "fileName", "resName"]) else { return nil }
|
||||
id = json.firstString(["fileId", "id", "resId"]) ?? UUID().uuidString
|
||||
self.name = name
|
||||
let resourceType = json.firstInt(["resType"])
|
||||
let type = json.firstInt(["fileType", "type"]) ?? 0
|
||||
if let explicitDirectory = json.firstInt(["isDir", "dir", "directoryType"]) {
|
||||
isDirectory = explicitDirectory == 1
|
||||
} else if let resourceType {
|
||||
// dirType == 1 is also present on ordinary resources; resType == 2 identifies folders.
|
||||
isDirectory = resourceType == 2
|
||||
} else {
|
||||
isDirectory = type == 0 && (object["dirName"] != nil || object["children"] != nil)
|
||||
}
|
||||
size = json.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"])
|
||||
gcid = json.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"])
|
||||
modifiedAt = json.firstString(["updateTime", "updatedAt", "modifyTime", "createTime"]) ?? json.firstInt64Deep(["utime", "ctime"]).map(formatFileDate) ?? ""
|
||||
fileType = type
|
||||
}
|
||||
}
|
||||
|
||||
struct DuplicateGroup: Identifiable, Sendable {
|
||||
let id: String
|
||||
let files: [CloudFile]
|
||||
}
|
||||
|
||||
struct TMDBMatch: Identifiable, Sendable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let mediaType: String
|
||||
let releaseDate: String
|
||||
let overview: String
|
||||
|
||||
init?(json: JSONValue) {
|
||||
guard let id = json.firstIntDeep(["id"]), let title = json.firstStringDeep(["title", "name"]) else { return nil }
|
||||
self.id = id
|
||||
self.title = title
|
||||
mediaType = json.firstStringDeep(["media_type"]) ?? "movie"
|
||||
releaseDate = json.firstStringDeep(["release_date", "first_air_date"]) ?? ""
|
||||
overview = json.firstStringDeep(["overview"]) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct FolderPath: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct UserProfile: Sendable {
|
||||
let name: String
|
||||
let phone: String
|
||||
let avatarURL: URL?
|
||||
|
||||
init(json: JSONValue) {
|
||||
name = json.firstStringDeep(["nickname", "name", "username", "phone_number"]) ?? "光鸭用户"
|
||||
phone = json.firstStringDeep(["phone", "phoneNumber", "phone_number", "username"]) ?? ""
|
||||
avatarURL = json.firstStringDeep(["avatar", "avatarUrl", "avatar_url"]).flatMap(URL.init(string:))
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthTokens: Sendable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Double?
|
||||
}
|
||||
|
||||
enum WorkspaceSection: String, CaseIterable, Identifiable {
|
||||
case files = "我的文件"
|
||||
case photos = "图片"
|
||||
case videos = "视频"
|
||||
case documents = "文档"
|
||||
case cloud = "云下载"
|
||||
case shares = "我的分享"
|
||||
case recycle = "回收站"
|
||||
|
||||
var id: String { rawValue }
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .files: return "folder"
|
||||
case .photos: return "photo"
|
||||
case .videos: return "play.rectangle"
|
||||
case .documents: return "doc.text"
|
||||
case .cloud: return "arrow.down.circle"
|
||||
case .shares: return "square.and.arrow.up"
|
||||
case .recycle: return "trash"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct guangya_macApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2940_8086)">
|
||||
<path d="M0 6.67969C0 2.9906 2.9906 0 6.67969 0H23.3203C27.0094 0 30 2.9906 30 6.67969V23.3203C30 27.0094 27.0094 30 23.3203 30H6.67969C2.9906 30 0 27.0094 0 23.3203V6.67969Z" fill="#FF6800"/>
|
||||
<path d="M0 6.67969C0 2.9906 2.9906 0 6.67969 0H23.3203C27.0094 0 30 2.9906 30 6.67969V23.3203C30 27.0094 27.0094 30 23.3203 30H6.67969C2.9906 30 0 27.0094 0 23.3203V6.67969Z" fill="url(#paint0_radial_2940_8086)" fill-opacity="0.7"/>
|
||||
<path d="M16.1077 11.6767C16.9588 11.6767 17.6487 12.3646 17.6487 13.2132C17.6487 14.0618 16.9588 14.7497 16.1077 14.7497C15.2567 14.7497 14.5667 14.0618 14.5667 13.2132C14.5667 12.3646 15.2567 11.6767 16.1077 11.6767Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.7098 6.20996C19.4506 6.20996 22.648 8.52942 23.9344 11.8046C23.9807 11.9223 24.0935 12.0018 24.2203 12.0018H26.8336C26.9492 12.0018 27.0429 12.0952 27.0429 12.2104C27.0429 12.425 27.0197 12.5258 26.9757 12.7272C26.9731 12.739 26.9705 12.7509 26.9677 12.7627C26.965 12.7745 26.9622 12.7862 26.9593 12.798C26.9536 12.8214 26.9476 12.8448 26.9412 12.8681C26.9349 12.8913 26.9284 12.9145 26.9215 12.9375C26.9043 12.995 26.8854 13.0518 26.8648 13.1078C26.8607 13.119 26.8566 13.1302 26.8523 13.1413C26.7971 13.2861 26.7307 13.4254 26.6541 13.5581C26.6483 13.5683 26.6423 13.5785 26.6363 13.5886C26.6183 13.619 26.5998 13.649 26.5807 13.6787C26.5616 13.7084 26.542 13.7377 26.5219 13.7666C26.5003 13.7976 26.4781 13.8281 26.4554 13.8582C26.4319 13.8895 26.4077 13.9202 26.3829 13.9505C26.3741 13.9612 26.3653 13.9718 26.3564 13.9824C26.3242 14.0207 26.291 14.0582 26.2569 14.0947C26.2249 14.129 26.1921 14.1625 26.1585 14.1951C26.1294 14.2235 26.0996 14.2513 26.0693 14.2784C26.0337 14.3103 25.9973 14.3413 25.9602 14.3715C25.9159 14.4075 25.8705 14.4422 25.824 14.4756C25.685 14.5756 25.5368 14.6639 25.381 14.7388C25.3703 14.7439 25.3596 14.749 25.3488 14.754C25.2764 14.7877 25.2024 14.8185 25.1269 14.8463C25.0652 14.8691 25.0026 14.8898 24.9391 14.9085C24.8955 14.9213 24.8515 14.9332 24.807 14.944C24.7599 14.9554 24.7124 14.9658 24.6644 14.9749C24.6231 14.9828 24.5813 14.9898 24.5394 14.996C24.4818 15.0044 24.1958 15.0137 24.1078 15.0137H21.1783L21.1784 15.0145H21.6319C21.2296 15.033 21.1747 15.3776 21.1556 15.5534L21.1555 15.5522C20.8764 20.1635 17.0373 23.8174 12.3422 23.8174C7.56694 23.8174 3.67701 20.0377 3.51765 15.3151C3.51205 15.149 3.64764 15.0137 3.81424 15.0137H6.40724C6.69898 14.9919 6.85338 14.8742 6.8976 14.4326V14.4572C7.18557 9.8543 11.0208 6.20998 15.7098 6.20996ZM15.7098 9.57932C12.8009 9.57934 10.4243 11.8514 10.2677 14.7128C10.2586 14.8787 10.1244 15.0137 9.95786 15.0137H7.20923C6.96876 15.0311 6.91167 15.2161 6.90191 15.3466C7.07445 18.1929 9.44419 20.448 12.3422 20.448C15.2511 20.448 17.6277 18.1759 17.7843 15.3145C17.7934 15.1486 17.9276 15.0137 18.0942 15.0137H20.7688C21.0668 14.9585 21.1412 14.7026 21.1394 14.5358C20.8967 11.7583 18.5585 9.57933 15.7098 9.57932Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_2940_8086" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(25.166 0.55664) rotate(127.312) scale(22.7643)">
|
||||
<stop stop-color="#FFC30E"/>
|
||||
<stop offset="1" stop-color="#FFA70E" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_2940_8086">
|
||||
<rect width="30" height="30" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |