480 lines
24 KiB
Swift
480 lines
24 KiB
Swift
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
|
||
}
|
||
}
|