对齐有线传图页 Android UI,并改用相对路径持久化本地照片。
还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
11
suixinkan/Assets.xcassets/wired_transfer_empty.imageset/Contents.json
vendored
Normal file
11
suixinkan/Assets.xcassets/wired_transfer_empty.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
1. `SonyCameraService.connect()` 启动 USB 设备搜索。
|
1. `SonyCameraService.connect()` 启动 USB 设备搜索。
|
||||||
2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。
|
2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。
|
||||||
3. 拍摄事件触发 PTP 下载,文件写入 `Documents/CameraDownloads/`。
|
3. 拍摄事件触发 PTP 下载,文件写入 `Documents/CameraDownloads/`,持久化路径为相对路径(如 `CameraDownloads/1730_DSC.JPG`)。
|
||||||
4. 通过 `onNewAsset` 回调通知上层有新照片。
|
4. 通过 `onNewAsset` 回调通知上层有新照片。
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|||||||
@ -7,16 +7,74 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 相机下载文件本地存储工具,负责目录创建与安全文件名处理。
|
/// 相机下载文件本地存储工具,负责目录创建、相对路径持久化与安全文件名处理。
|
||||||
enum CameraDownloadStorage {
|
enum CameraDownloadStorage {
|
||||||
|
static let cameraDownloadsFolderName = "CameraDownloads"
|
||||||
|
|
||||||
|
/// Documents 根目录。
|
||||||
|
static var documentsDirectory: URL {
|
||||||
|
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
}
|
||||||
|
|
||||||
/// 相机照片下载根目录。
|
/// 相机照片下载根目录。
|
||||||
static var downloadsDirectory: URL {
|
static var downloadsDirectory: URL {
|
||||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
let directory = documentsDirectory.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
||||||
let directory = documents.appendingPathComponent("CameraDownloads", isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/1730_DSC.JPG`。
|
||||||
|
static func relativePath(for url: URL) -> String {
|
||||||
|
"\(cameraDownloadsFolderName)/\(url.lastPathComponent)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将持久化的相对或旧版绝对路径解析为当前沙盒内的文件 URL。
|
||||||
|
static func resolveLocalURL(from storedPath: String) -> URL? {
|
||||||
|
guard !storedPath.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if storedPath.hasPrefix("http://") || storedPath.hasPrefix("https://") {
|
||||||
|
return URL(string: storedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if storedPath.hasPrefix("/") {
|
||||||
|
let legacyURL = URL(fileURLWithPath: storedPath)
|
||||||
|
if FileManager.default.fileExists(atPath: legacyURL.path) {
|
||||||
|
return legacyURL
|
||||||
|
}
|
||||||
|
let candidate = downloadsDirectory.appendingPathComponent(legacyURL.lastPathComponent)
|
||||||
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
return legacyURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentsDirectory.appendingPathComponent(storedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将旧版绝对路径迁移为相对路径;若文件已在当前 CameraDownloads 中则更新引用。
|
||||||
|
static func migrateStoredPath(_ storedPath: String) -> String? {
|
||||||
|
guard storedPath.hasPrefix("/") else { return storedPath }
|
||||||
|
|
||||||
|
let legacyURL = URL(fileURLWithPath: storedPath)
|
||||||
|
if FileManager.default.fileExists(atPath: legacyURL.path) {
|
||||||
|
return relativePath(for: legacyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = downloadsDirectory.appendingPathComponent(legacyURL.lastPathComponent)
|
||||||
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
return relativePath(for: candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativePath(for: legacyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断文件是否位于 CameraDownloads 目录内。
|
||||||
|
static func isInDownloadsDirectory(_ url: URL) -> Bool {
|
||||||
|
let downloadsPath = downloadsDirectory.standardizedFileURL.path
|
||||||
|
let filePath = url.standardizedFileURL.path
|
||||||
|
return filePath.hasPrefix(downloadsPath) || filePath.hasPrefix("/private" + downloadsPath)
|
||||||
|
}
|
||||||
|
|
||||||
/// 生成带时间戳的唯一本地文件 URL,避免文件名冲突。
|
/// 生成带时间戳的唯一本地文件 URL,避免文件名冲突。
|
||||||
static func uniqueLocalURL(for filename: String) -> URL {
|
static func uniqueLocalURL(for filename: String) -> URL {
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
@ -44,4 +102,13 @@ enum CameraDownloadStorage {
|
|||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将本地存储路径转为可用于 UI 预览的 file URL 字符串。
|
||||||
|
static func previewURLString(from storedPath: String) -> String {
|
||||||
|
guard let url = resolveLocalURL(from: storedPath),
|
||||||
|
FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return url.absoluteString
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,7 +164,7 @@ final class CameraTransferPipeline {
|
|||||||
let localURL = try await cameraService.downloadAsset(resolvedAsset)
|
let localURL = try await cameraService.downloadAsset(resolvedAsset)
|
||||||
let destination: URL
|
let destination: URL
|
||||||
|
|
||||||
if localURL.path.hasPrefix(CameraDownloadStorage.downloadsDirectory.path) {
|
if CameraDownloadStorage.isInDownloadsDirectory(localURL) {
|
||||||
destination = localURL
|
destination = localURL
|
||||||
} else {
|
} else {
|
||||||
destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename)
|
destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename)
|
||||||
@ -174,7 +174,7 @@ final class CameraTransferPipeline {
|
|||||||
try FileManager.default.moveItem(at: localURL, to: destination)
|
try FileManager.default.moveItem(at: localURL, to: destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks[index].localPath = destination.path
|
tasks[index].localPath = CameraDownloadStorage.relativePath(for: destination)
|
||||||
tasks[index].status = .downloaded
|
tasks[index].status = .downloaded
|
||||||
tasks[index].progress = 0
|
tasks[index].progress = 0
|
||||||
notify()
|
notify()
|
||||||
|
|||||||
@ -48,9 +48,9 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
|
|||||||
self.errorMessage = errorMessage
|
self.errorMessage = errorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 本地文件 URL,仅在路径有效时返回。
|
/// 本地文件 URL,支持相对路径与旧版绝对路径。
|
||||||
var localURL: URL? {
|
var localURL: URL? {
|
||||||
guard let localPath, !localPath.isEmpty else { return nil }
|
guard let localPath, !localPath.isEmpty else { return nil }
|
||||||
return URL(fileURLWithPath: localPath)
|
return CameraDownloadStorage.resolveLocalURL(from: localPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -281,6 +281,28 @@ enum WiredTransferUploadStatus: String, Codable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 有线传图时间侧栏时间段。
|
||||||
|
struct WiredTransferTimeSlot: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let dateLabel: String
|
||||||
|
let slotStartLabel: String
|
||||||
|
let photoCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有线传图时间侧栏日期分组。
|
||||||
|
struct WiredTransferDateGroup: Equatable {
|
||||||
|
let dateLabel: String
|
||||||
|
let slots: [WiredTransferTimeSlot]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有线传图列表分组(30 分钟时间段)。
|
||||||
|
struct WiredTransferPhotoSection: Identifiable, Equatable {
|
||||||
|
var id: String { slotId }
|
||||||
|
let slotId: String
|
||||||
|
let headerTitle: String
|
||||||
|
let photos: [WiredTransferPhotoItem]
|
||||||
|
}
|
||||||
|
|
||||||
/// 有线传图列表项。
|
/// 有线传图列表项。
|
||||||
struct WiredTransferPhotoItem: Identifiable, Equatable {
|
struct WiredTransferPhotoItem: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
@ -289,6 +311,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
|||||||
let thumbnailURL: String
|
let thumbnailURL: String
|
||||||
let capturedAt: String
|
let capturedAt: String
|
||||||
let fileSizeText: String
|
let fileSizeText: String
|
||||||
|
let resolutionText: String
|
||||||
let status: WiredTransferUploadStatus
|
let status: WiredTransferUploadStatus
|
||||||
let progress: Int
|
let progress: Int
|
||||||
let errorMessage: String?
|
let errorMessage: String?
|
||||||
@ -300,6 +323,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
|||||||
thumbnailURL: String,
|
thumbnailURL: String,
|
||||||
capturedAt: String,
|
capturedAt: String,
|
||||||
fileSizeText: String,
|
fileSizeText: String,
|
||||||
|
resolutionText: String = "",
|
||||||
status: WiredTransferUploadStatus,
|
status: WiredTransferUploadStatus,
|
||||||
progress: Int = 0,
|
progress: Int = 0,
|
||||||
errorMessage: String? = nil
|
errorMessage: String? = nil
|
||||||
@ -310,6 +334,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
|||||||
self.thumbnailURL = thumbnailURL
|
self.thumbnailURL = thumbnailURL
|
||||||
self.capturedAt = capturedAt
|
self.capturedAt = capturedAt
|
||||||
self.fileSizeText = fileSizeText
|
self.fileSizeText = fileSizeText
|
||||||
|
self.resolutionText = resolutionText
|
||||||
self.status = status
|
self.status = status
|
||||||
self.progress = progress
|
self.progress = progress
|
||||||
self.errorMessage = errorMessage
|
self.errorMessage = errorMessage
|
||||||
@ -322,6 +347,24 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
|
|||||||
var isNotUploaded: Bool {
|
var isNotUploaded: Bool {
|
||||||
status == .pending || status == .failed
|
status == .pending || status == .failed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析拍摄时间。
|
||||||
|
func capturedDate() -> Date? {
|
||||||
|
Self.capturedAtFormatter.date(from: capturedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否为今日拍摄。
|
||||||
|
func isCapturedToday(calendar: Calendar = .current) -> Bool {
|
||||||
|
guard let date = capturedDate() else { return false }
|
||||||
|
return calendar.isDateInToday(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let capturedAtFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有线传图持久化记录。
|
/// 有线传图持久化记录。
|
||||||
@ -342,18 +385,14 @@ struct WiredTransferPhotoRecord: Codable, Equatable {
|
|||||||
let updatedAt: TimeInterval
|
let updatedAt: TimeInterval
|
||||||
|
|
||||||
func toPhotoItem() -> WiredTransferPhotoItem {
|
func toPhotoItem() -> WiredTransferPhotoItem {
|
||||||
let previewPath = [thumbnailPath, localPath, remoteURL]
|
let previewCandidates = [thumbnailPath, localPath, remoteURL]
|
||||||
.first { path in
|
let thumbnailURL = previewCandidates.compactMap { path -> String? in
|
||||||
!path.isEmpty && (path.hasPrefix("http") || FileManager.default.fileExists(atPath: path))
|
guard !path.isEmpty else { return nil }
|
||||||
} ?? ""
|
if path.hasPrefix("http") { return path }
|
||||||
let thumbnailURL: String
|
let preview = CameraDownloadStorage.previewURLString(from: path)
|
||||||
if previewPath.hasPrefix("http") {
|
return preview.isEmpty ? nil : preview
|
||||||
thumbnailURL = previewPath
|
}.first ?? ""
|
||||||
} else if !previewPath.isEmpty {
|
|
||||||
thumbnailURL = "file://\(previewPath)"
|
|
||||||
} else {
|
|
||||||
thumbnailURL = ""
|
|
||||||
}
|
|
||||||
return WiredTransferPhotoItem(
|
return WiredTransferPhotoItem(
|
||||||
id: id,
|
id: id,
|
||||||
sourceId: sourceId,
|
sourceId: sourceId,
|
||||||
@ -425,6 +464,114 @@ extension Array where Element == WiredTransferPhotoItem {
|
|||||||
var selectablePendingPhotoIDs: Set<String> {
|
var selectablePendingPhotoIDs: Set<String> {
|
||||||
Set(filter { $0.canSelectForSpecifyUpload }.map(\.id))
|
Set(filter { $0.canSelectForSpecifyUpload }.map(\.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建 30 分钟时间段分组列表。
|
||||||
|
func buildPhotoSections() -> [WiredTransferPhotoSection] {
|
||||||
|
let grouped = Dictionary(grouping: compactMap { photo -> (Date, WiredTransferPhotoItem)? in
|
||||||
|
guard let date = photo.capturedDate() else { return nil }
|
||||||
|
return (WiredTransferTimeGrouping.halfHourSlotStart(for: date), photo)
|
||||||
|
}) { $0.0 }
|
||||||
|
|
||||||
|
return grouped.keys
|
||||||
|
.sorted(by: >)
|
||||||
|
.map { slotStart in
|
||||||
|
let photos = (grouped[slotStart] ?? [])
|
||||||
|
.map(\.1)
|
||||||
|
.sorted { ($0.capturedDate() ?? .distantPast) > ($1.capturedDate() ?? .distantPast) }
|
||||||
|
return WiredTransferPhotoSection(
|
||||||
|
slotId: WiredTransferTimeGrouping.slotID(for: slotStart),
|
||||||
|
headerTitle: WiredTransferTimeGrouping.sectionHeaderTitle(for: slotStart),
|
||||||
|
photos: photos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建时间侧栏分组。
|
||||||
|
func buildSidebarGroups() -> [WiredTransferDateGroup] {
|
||||||
|
var slotCounts: [Date: Int] = [:]
|
||||||
|
for photo in self {
|
||||||
|
guard let date = photo.capturedDate() else { continue }
|
||||||
|
let slotStart = WiredTransferTimeGrouping.halfHourSlotStart(for: date)
|
||||||
|
slotCounts[slotStart, default: 0] += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let groupedByDay = Dictionary(grouping: slotCounts.keys) { calendar.startOfDay(for: $0) }
|
||||||
|
|
||||||
|
return groupedByDay.keys
|
||||||
|
.sorted(by: >)
|
||||||
|
.map { day in
|
||||||
|
let slots = (groupedByDay[day] ?? [])
|
||||||
|
.sorted(by: >)
|
||||||
|
.compactMap { slotStart -> WiredTransferTimeSlot? in
|
||||||
|
guard let count = slotCounts[slotStart] else { return nil }
|
||||||
|
return WiredTransferTimeSlot(
|
||||||
|
id: WiredTransferTimeGrouping.slotID(for: slotStart),
|
||||||
|
dateLabel: WiredTransferTimeGrouping.dateLabel(for: slotStart),
|
||||||
|
slotStartLabel: WiredTransferTimeGrouping.timeLabel(for: slotStart),
|
||||||
|
photoCount: count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return WiredTransferDateGroup(
|
||||||
|
dateLabel: WiredTransferTimeGrouping.dateLabel(for: day),
|
||||||
|
slots: slots
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有线传图 30 分钟时间段分组工具。
|
||||||
|
enum WiredTransferTimeGrouping {
|
||||||
|
/// 计算 30 分钟时间段起点。
|
||||||
|
static func halfHourSlotStart(for date: Date, calendar: Calendar = .current) -> Date {
|
||||||
|
var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
|
||||||
|
let minute = (components.minute ?? 0) / 30 * 30
|
||||||
|
components.minute = minute
|
||||||
|
components.second = 0
|
||||||
|
components.nanosecond = 0
|
||||||
|
return calendar.date(from: components) ?? date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间段唯一标识。
|
||||||
|
static func slotID(for slotStart: Date) -> String {
|
||||||
|
slotIDFormatter.string(from: slotStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分组标题,例如「05月20日 12:00 - 12:30」。
|
||||||
|
static func sectionHeaderTitle(for slotStart: Date) -> String {
|
||||||
|
"\(dateLabel(for: slotStart)) \(timeLabel(for: slotStart)) - \(timeLabel(for: slotStart.addingTimeInterval(30 * 60)))"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日期标签,例如「05月20日」。
|
||||||
|
static func dateLabel(for date: Date) -> String {
|
||||||
|
dateLabelFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间标签,例如「12:05」。
|
||||||
|
static func timeLabel(for date: Date) -> String {
|
||||||
|
timeLabelFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let slotIDFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let dateLabelFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "MM月dd日"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let timeLabelFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension KeyedDecodingContainer {
|
private extension KeyedDecodingContainer {
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// DeviceStorageHelper.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// 读取本机型号与可用存储,对齐 Android DeviceStorageHelper。
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 设备存储快照,用于有线传图页设备信息面板。
|
||||||
|
struct DeviceStorageSnapshot: Equatable {
|
||||||
|
let modelName: String
|
||||||
|
let availableGbText: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设备存储信息读取工具。
|
||||||
|
enum DeviceStorageHelper {
|
||||||
|
/// 读取当前设备型号与 Documents 目录可用空间。
|
||||||
|
static func readStorageSnapshot() -> DeviceStorageSnapshot {
|
||||||
|
let availableBytes = availableDocumentsBytes()
|
||||||
|
let availableGb = Double(availableBytes) / 1024.0 / 1024.0 / 1024.0
|
||||||
|
return DeviceStorageSnapshot(
|
||||||
|
modelName: buildModelName(),
|
||||||
|
availableGbText: String(format: "还剩 %.1fGB 可用", availableGb)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildModelName() -> String {
|
||||||
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
let machine = withUnsafePointer(to: &systemInfo.machine) {
|
||||||
|
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||||
|
String(cString: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let marketingName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !marketingName.isEmpty, marketingName != "iPhone", !marketingName.hasPrefix("iPad") {
|
||||||
|
return marketingName
|
||||||
|
}
|
||||||
|
if !machine.isEmpty { return machine }
|
||||||
|
return UIDevice.current.model
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func availableDocumentsBytes() -> Int64 {
|
||||||
|
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||||
|
guard let documentsURL else { return 0 }
|
||||||
|
let values = try? documentsURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||||
|
if let capacity = values?.volumeAvailableCapacityForImportantUsage {
|
||||||
|
return Int64(capacity)
|
||||||
|
}
|
||||||
|
if let attributes = try? FileManager.default.attributesOfFileSystem(forPath: documentsURL.path),
|
||||||
|
let freeSize = attributes[.systemFreeSize] as? NSNumber {
|
||||||
|
return freeSize.int64Value
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,20 +60,44 @@ final class WiredTransferPhotoStore {
|
|||||||
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||||||
else { return [] }
|
else { return [] }
|
||||||
|
|
||||||
return records
|
let filteredRecords = records.filter { record in
|
||||||
.filter { record in
|
|
||||||
!deletedIDs.contains(record.id)
|
!deletedIDs.contains(record.id)
|
||||||
&& record.userId == userID
|
&& record.userId == userID
|
||||||
&& record.albumId == albumID
|
&& record.albumId == albumID
|
||||||
&& record.isDisplayable
|
|
||||||
}
|
}
|
||||||
.map { $0.normalizeInterruptedTransfer() }
|
|
||||||
|
let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() }
|
||||||
|
let migratedRecords = normalizeStoredPaths(normalizedRecords, albumID: albumID)
|
||||||
|
|
||||||
|
return migratedRecords
|
||||||
|
.filter(\.isDisplayable)
|
||||||
.sorted { $0.capturedAt > $1.capturedAt }
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
.also { items in
|
.also { items in
|
||||||
items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
|
items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将旧版绝对路径迁移为相对路径并回写持久化。
|
||||||
|
private func normalizeStoredPaths(
|
||||||
|
_ records: [WiredTransferPhotoRecord],
|
||||||
|
albumID: Int
|
||||||
|
) -> [WiredTransferPhotoRecord] {
|
||||||
|
var migrated = records
|
||||||
|
var needsPersist = false
|
||||||
|
|
||||||
|
for index in migrated.indices {
|
||||||
|
guard let normalized = migrated[index].withNormalizedStoredPaths(),
|
||||||
|
normalized != migrated[index] else { continue }
|
||||||
|
migrated[index] = normalized
|
||||||
|
needsPersist = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsPersist {
|
||||||
|
save(albumID: albumID, records: migrated)
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
/// 保存相册传图记录。
|
/// 保存相册传图记录。
|
||||||
func save(albumID: Int, records: [WiredTransferPhotoRecord]) {
|
func save(albumID: Int, records: [WiredTransferPhotoRecord]) {
|
||||||
guard albumID > 0 else { return }
|
guard albumID > 0 else { return }
|
||||||
@ -142,7 +166,7 @@ final class WiredTransferPhotoStore {
|
|||||||
|
|
||||||
private func deleteFileIfExists(_ path: String) {
|
private func deleteFileIfExists(_ path: String) {
|
||||||
guard !path.isEmpty else { return }
|
guard !path.isEmpty else { return }
|
||||||
let url = URL(fileURLWithPath: path)
|
guard let url = CameraDownloadStorage.resolveLocalURL(from: path) else { return }
|
||||||
if FileManager.default.fileExists(atPath: url.path) {
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
}
|
}
|
||||||
@ -181,7 +205,43 @@ final class WiredTransferPhotoStore {
|
|||||||
|
|
||||||
private extension WiredTransferPhotoRecord {
|
private extension WiredTransferPhotoRecord {
|
||||||
var isDisplayable: Bool {
|
var isDisplayable: Bool {
|
||||||
!localPath.isEmpty || !remoteURL.isEmpty
|
if !remoteURL.isEmpty { return true }
|
||||||
|
if let url = CameraDownloadStorage.resolveLocalURL(from: localPath),
|
||||||
|
FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let url = CameraDownloadStorage.resolveLocalURL(from: thumbnailPath),
|
||||||
|
FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 localPath/thumbnailPath 中的旧版绝对路径迁移为相对路径。
|
||||||
|
func withNormalizedStoredPaths() -> WiredTransferPhotoRecord? {
|
||||||
|
let migratedLocalPath = localPath.isEmpty ? localPath : (CameraDownloadStorage.migrateStoredPath(localPath) ?? localPath)
|
||||||
|
let migratedThumbnailPath = thumbnailPath.isEmpty
|
||||||
|
? thumbnailPath
|
||||||
|
: (CameraDownloadStorage.migrateStoredPath(thumbnailPath) ?? thumbnailPath)
|
||||||
|
|
||||||
|
guard migratedLocalPath != localPath || migratedThumbnailPath != thumbnailPath else { return nil }
|
||||||
|
|
||||||
|
return WiredTransferPhotoRecord(
|
||||||
|
id: id,
|
||||||
|
sourceId: sourceId,
|
||||||
|
fileName: fileName,
|
||||||
|
localPath: migratedLocalPath,
|
||||||
|
thumbnailPath: migratedThumbnailPath,
|
||||||
|
capturedAt: capturedAt,
|
||||||
|
fileSizeBytes: fileSizeBytes,
|
||||||
|
status: status,
|
||||||
|
progress: progress,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
albumId: albumId,
|
||||||
|
userId: userId,
|
||||||
|
remoteURL: remoteURL,
|
||||||
|
updatedAt: updatedAt
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord {
|
func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord {
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
## 入口
|
## 入口
|
||||||
|
|
||||||
- 权限 URI:`travel_album` → `TravelAlbumEntryView`
|
- 权限 URI:`travel_album` → `TravelAlbumEntryView`
|
||||||
- Android 对照:`ui/travelalbum`
|
- Android 对照:`ui/travelalbum` → `WiredCameraTransferScreen.kt`
|
||||||
|
|
||||||
## 核心页面
|
## 核心页面
|
||||||
|
|
||||||
@ -16,7 +16,31 @@
|
|||||||
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 |
|
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 |
|
||||||
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
|
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
|
||||||
| `TravelAlbumDetailView` | 相册详情与素材管理 |
|
| `TravelAlbumDetailView` | 相册详情与素材管理 |
|
||||||
| `WiredCameraTransferView` | 有线传图页 |
|
| `WiredCameraTransferView` | 有线传图页(UI 对齐 Android) |
|
||||||
|
|
||||||
|
## 有线传图 UI 结构
|
||||||
|
|
||||||
|
Android 对照:`zhiflyfollow/.../WiredCameraTransferScreen.kt`
|
||||||
|
|
||||||
|
```
|
||||||
|
WiredTransferHeaderView # 蓝色顶栏:相册名 + 手机号
|
||||||
|
WiredTransferControlCard # 浮动白卡片:设备存储 / 连接状态 / 筛选 Chip / Tab
|
||||||
|
WiredTransferSelectAllBar # 勾选模式全选栏(条件显示)
|
||||||
|
WiredTransferPhotoListPanel # 时间侧栏 + 30 分钟分组列表
|
||||||
|
WiredTransferBottomBar # 批量上传 + 指定上传
|
||||||
|
SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||||
|
```
|
||||||
|
|
||||||
|
## 有线传图交互流程
|
||||||
|
|
||||||
|
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 同步相机历史文件
|
||||||
|
2. **批量上传三态**:
|
||||||
|
- 首次点击 → 进入勾选模式
|
||||||
|
- 有选中项 → 上传选中照片
|
||||||
|
- 无选中项 → 取消选择
|
||||||
|
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
||||||
|
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||||
|
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||||
|
|
||||||
## 业务流程
|
## 业务流程
|
||||||
|
|
||||||
@ -24,6 +48,7 @@
|
|||||||
2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`)
|
2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`)
|
||||||
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
||||||
4. 素材登记:`POST .../travel-album/upload-material`
|
4. 素材登记:`POST .../travel-album/upload-material`
|
||||||
|
5. 本地照片路径以相对 Documents 的路径持久化(如 `CameraDownloads/xxx.JPG`),加载时自动迁移旧版绝对路径
|
||||||
|
|
||||||
## 解耦关系
|
## 解耦关系
|
||||||
|
|
||||||
@ -36,3 +61,9 @@
|
|||||||
- `Core/CameraTethering` — USB/PTP 相机
|
- `Core/CameraTethering` — USB/PTP 相机
|
||||||
- `Core/CameraTransfer` — 传输编排
|
- `Core/CameraTransfer` — 传输编排
|
||||||
- `Core/Upload` — OSS STS 上传
|
- `Core/Upload` — OSS STS 上传
|
||||||
|
|
||||||
|
## 二期待补齐
|
||||||
|
|
||||||
|
- RAW 预览
|
||||||
|
- 格式 Chip 扩展 RAW / JPEG+RAW,并与 `CameraTransferPipeline` 联动
|
||||||
|
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// 旅拍相册入口 ViewModel,负责列表、创建任务与小程序码。
|
/// 旅拍相册入口 ViewModel,负责列表、创建任务与小程序码。
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -229,7 +230,12 @@ final class TravelAlbumDetailViewModel: ObservableObject {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class WiredCameraTransferViewModel: ObservableObject {
|
final class WiredCameraTransferViewModel: ObservableObject {
|
||||||
static let modeLiveCapture = "边拍边传"
|
static let modeLiveCapture = "边拍边传"
|
||||||
static let modePostShootTransfer = "拍完再传"
|
static let modePostShootTransfer = "拍后传输"
|
||||||
|
/// 当前仅支持 JPG,后续再扩展 RAW 等格式。
|
||||||
|
static let formatJPG = "JPG"
|
||||||
|
static let specifyUploadAllPending = "上传所有未上传的照片"
|
||||||
|
static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片"
|
||||||
|
static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")!
|
||||||
|
|
||||||
@Published var photos: [WiredTransferPhotoItem] = []
|
@Published var photos: [WiredTransferPhotoItem] = []
|
||||||
@Published var selectedTabIndex = 0
|
@Published var selectedTabIndex = 0
|
||||||
@ -239,6 +245,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
@Published var selectedPhotoIDs: Set<String> = []
|
@Published var selectedPhotoIDs: Set<String> = []
|
||||||
@Published var transferModeOption = modeLiveCapture
|
@Published var transferModeOption = modeLiveCapture
|
||||||
@Published var showSpecifyUploadSheet = false
|
@Published var showSpecifyUploadSheet = false
|
||||||
|
@Published var showDeviceStorageAlert = false
|
||||||
|
@Published var showCameraDisconnectedAlert = false
|
||||||
|
@Published var showHelpDoc = false
|
||||||
|
@Published var isRefreshingCameraFiles = false
|
||||||
|
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
|
||||||
@Published var connectionState: CameraConnectionState = .disconnected
|
@Published var connectionState: CameraConnectionState = .disconnected
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
@ -247,6 +258,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
private var photoStore: WiredTransferPhotoStore?
|
private var photoStore: WiredTransferPhotoStore?
|
||||||
private var userIDProvider: (() -> String)?
|
private var userIDProvider: (() -> String)?
|
||||||
private var sessionNewPhotoIDs: Set<String> = []
|
private var sessionNewPhotoIDs: Set<String> = []
|
||||||
|
private var wasConnected = false
|
||||||
|
private var disconnectAlertTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// 初始化有线传图 ViewModel。
|
/// 初始化有线传图 ViewModel。
|
||||||
init(
|
init(
|
||||||
@ -258,7 +271,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
|
|
||||||
pipeline.onConnectionStateChange = { [weak self] state in
|
pipeline.onConnectionStateChange = { [weak self] state in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.connectionState = state
|
self?.handleConnectionStateChange(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pipeline.onTasksUpdated = { [weak self] tasks in
|
pipeline.onTasksUpdated = { [weak self] tasks in
|
||||||
@ -290,15 +303,53 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
let autoUpload = transferModeOption == Self.modeLiveCapture
|
let autoUpload = transferModeOption == Self.modeLiveCapture
|
||||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
||||||
|
refreshDeviceStorageInfo()
|
||||||
loadPersistedPhotos()
|
loadPersistedPhotos()
|
||||||
await pipeline.connect()
|
await pipeline.connect()
|
||||||
|
connectionState = pipeline.connectionState
|
||||||
|
wasConnected = connectionState.isConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 断开相机连接。
|
/// 断开相机连接。
|
||||||
func stop() async {
|
func stop() async {
|
||||||
|
disconnectAlertTask?.cancel()
|
||||||
await pipeline.disconnect()
|
await pipeline.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新设备存储信息。
|
||||||
|
func refreshDeviceStorageInfo() {
|
||||||
|
deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换 Tab。
|
||||||
|
func selectTab(_ index: Int) {
|
||||||
|
selectedTabIndex = index
|
||||||
|
selectedTimeSlotID = nil
|
||||||
|
if selectUploadMode {
|
||||||
|
syncSelectionWithVisiblePhotos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 折叠/展开时间侧栏。
|
||||||
|
func toggleSidebar() {
|
||||||
|
sidebarExpanded.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选中时间段并滚动定位。
|
||||||
|
func selectTimeSlot(_ slotID: String) {
|
||||||
|
selectedTimeSlotID = slotID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开帮助文档。
|
||||||
|
func openHelpDoc() {
|
||||||
|
showHelpDoc = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击设备存储面板。
|
||||||
|
func onDeviceUsageClick() {
|
||||||
|
showDeviceStorageAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换传输模式。
|
/// 切换传输模式。
|
||||||
func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
|
func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
|
||||||
guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
|
guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
|
||||||
@ -313,17 +364,113 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清除错误提示。
|
||||||
|
func clearErrorMessage() {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新相机文件列表。
|
||||||
|
func refreshCameraFiles() async {
|
||||||
|
guard connectionState.isConnected, !isRefreshingCameraFiles else { return }
|
||||||
|
isRefreshingCameraFiles = true
|
||||||
|
defer { isRefreshingCameraFiles = false }
|
||||||
|
await pipeline.syncExistingPhotos()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
||||||
|
func onBatchUploadButtonClick(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||||
|
if !selectUploadMode {
|
||||||
|
selectUploadMode = true
|
||||||
|
selectedPhotoIDs.removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !selectedPhotoIDs.isEmpty {
|
||||||
|
await uploadSelected(api: api, ossService: ossService, scenicID: scenicID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exitSelectUploadMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定上传按钮点击。
|
||||||
|
func onSpecifyUploadButtonClick() {
|
||||||
|
guard !selectUploadMode else { return }
|
||||||
|
showSpecifyUploadSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭指定上传弹层。
|
||||||
|
func dismissSpecifyUploadSheet() {
|
||||||
|
showSpecifyUploadSheet = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定上传选项确认。
|
||||||
|
func onSpecifyUploadOptionSelected(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||||
|
dismissSpecifyUploadSheet()
|
||||||
|
switch option {
|
||||||
|
case Self.specifyUploadAllPending:
|
||||||
|
await batchUploadAll(api: api, ossService: ossService, scenicID: scenicID)
|
||||||
|
case Self.specifyUploadTodayCaptured:
|
||||||
|
await uploadAllTodayCaptured(api: api, ossService: ossService, scenicID: scenicID)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退出勾选模式。
|
||||||
|
func exitSelectUploadMode() {
|
||||||
|
selectUploadMode = false
|
||||||
|
selectedPhotoIDs.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换可见 pending 全选状态。
|
||||||
|
func toggleSelectAllPendingInVisible() {
|
||||||
|
let pendingIDs = visiblePhotos.selectablePendingPhotoIDs
|
||||||
|
guard !pendingIDs.isEmpty else { return }
|
||||||
|
if pendingIDs.isSubset(of: selectedPhotoIDs) {
|
||||||
|
selectedPhotoIDs.subtract(pendingIDs)
|
||||||
|
} else {
|
||||||
|
selectedPhotoIDs.formUnion(pendingIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前 Tab 可见照片。
|
||||||
|
var visiblePhotos: [WiredTransferPhotoItem] {
|
||||||
|
photos.filtered(byTabIndex: selectedTabIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览 URL。
|
||||||
|
func previewURL(for photoID: String) -> String {
|
||||||
|
guard let photo = photos.first(where: { $0.id == photoID }) else { return "" }
|
||||||
|
if !photo.thumbnailURL.isEmpty { return photo.thumbnailURL }
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
/// 批量上传所有待上传照片。
|
/// 批量上传所有待上传照片。
|
||||||
func batchUploadAll() async {
|
func batchUploadAll(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||||
|
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||||||
let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id)
|
let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id)
|
||||||
|
guard !pendingIDs.isEmpty else {
|
||||||
|
errorMessage = "暂无未上传的照片"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await pipeline.uploadAssets(withIDs: pendingIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传今日拍摄且未上传的照片。
|
||||||
|
func uploadAllTodayCaptured(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||||
|
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||||||
|
let pendingIDs = photos.filter { $0.isCapturedToday() && $0.isNotUploaded }.map(\.id)
|
||||||
|
guard !pendingIDs.isEmpty else {
|
||||||
|
errorMessage = "暂无今日拍摄且未上传的照片"
|
||||||
|
return
|
||||||
|
}
|
||||||
await pipeline.uploadAssets(withIDs: pendingIDs)
|
await pipeline.uploadAssets(withIDs: pendingIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 上传选中照片。
|
/// 上传选中照片。
|
||||||
func uploadSelected() async {
|
func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async {
|
||||||
|
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
|
||||||
await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs))
|
await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs))
|
||||||
selectUploadMode = false
|
exitSelectUploadMode()
|
||||||
selectedPhotoIDs.removeAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重试单张照片上传。
|
/// 重试单张照片上传。
|
||||||
@ -339,6 +486,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// 切换照片选中状态。
|
/// 切换照片选中状态。
|
||||||
func togglePhotoSelection(id: String) {
|
func togglePhotoSelection(id: String) {
|
||||||
|
guard selectUploadMode else { return }
|
||||||
|
guard let photo = photos.first(where: { $0.id == id }), photo.canSelectForSpecifyUpload else { return }
|
||||||
if selectedPhotoIDs.contains(id) {
|
if selectedPhotoIDs.contains(id) {
|
||||||
selectedPhotoIDs.remove(id)
|
selectedPhotoIDs.remove(id)
|
||||||
} else {
|
} else {
|
||||||
@ -348,7 +497,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// 同步相机历史照片。
|
/// 同步相机历史照片。
|
||||||
func syncExistingPhotos() async {
|
func syncExistingPhotos() async {
|
||||||
await pipeline.syncExistingPhotos()
|
await refreshCameraFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重试失败上传。
|
/// 重试失败上传。
|
||||||
@ -356,6 +505,51 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
await pipeline.retryFailedUploads()
|
await pipeline.retryFailedUploads()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func ensureUploadEnabled(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
|
||||||
|
let uploader = TravelAlbumMaterialUploader(
|
||||||
|
api: api,
|
||||||
|
ossService: ossService,
|
||||||
|
albumID: context.albumId,
|
||||||
|
scenicID: scenicID
|
||||||
|
)
|
||||||
|
pipeline.configure(uploadSink: uploader, uploadEnabled: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncSelectionWithVisiblePhotos() {
|
||||||
|
let visibleIDs = Set(visiblePhotos.map(\.id))
|
||||||
|
selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleConnectionStateChange(_ state: CameraConnectionState) {
|
||||||
|
if wasConnected, !state.isConnected {
|
||||||
|
triggerCameraDisconnectedAlert()
|
||||||
|
}
|
||||||
|
wasConnected = state.isConnected
|
||||||
|
connectionState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerCameraDisconnectedAlert() {
|
||||||
|
disconnectAlertTask?.cancel()
|
||||||
|
showCameraDisconnectedAlert = true
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
||||||
|
disconnectAlertTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
showCameraDisconnectedAlert = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相机设备名称。
|
||||||
|
var cameraDeviceName: String {
|
||||||
|
if case .connected(let name) = connectionState { return name }
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否已连接相机。
|
||||||
|
var isCameraConnected: Bool {
|
||||||
|
connectionState.isConnected
|
||||||
|
}
|
||||||
|
|
||||||
private func loadPersistedPhotos() {
|
private func loadPersistedPhotos() {
|
||||||
guard context.albumId > 0, let photoStore else { return }
|
guard context.albumId > 0, let photoStore else { return }
|
||||||
let records = photoStore.load(albumID: context.albumId)
|
let records = photoStore.load(albumID: context.albumId)
|
||||||
@ -388,9 +582,15 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let capturedAt = Self.captureTimestampFormatter.string(from: Date())
|
let capturedAt = Self.captureTimestampFormatter.string(from: Date())
|
||||||
let localPath = task.localPath ?? ""
|
let storedLocalPath = task.localPath ?? ""
|
||||||
let fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0
|
let localURL = task.localURL
|
||||||
let thumbnailURL = localPath.isEmpty ? "" : "file://\(localPath)"
|
let fileSizeBytes: Int64
|
||||||
|
if let localURL {
|
||||||
|
fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0
|
||||||
|
} else {
|
||||||
|
fileSizeBytes = 0
|
||||||
|
}
|
||||||
|
let thumbnailURL = localURL.map(\.absoluteString) ?? ""
|
||||||
|
|
||||||
let item = WiredTransferPhotoItem(
|
let item = WiredTransferPhotoItem(
|
||||||
id: task.assetID,
|
id: task.assetID,
|
||||||
@ -409,7 +609,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
let record = item.toRecord(
|
let record = item.toRecord(
|
||||||
albumId: context.albumId,
|
albumId: context.albumId,
|
||||||
userId: userID,
|
userId: userID,
|
||||||
localPath: localPath,
|
localPath: storedLocalPath,
|
||||||
fileSizeBytes: fileSizeBytes,
|
fileSizeBytes: fileSizeBytes,
|
||||||
remoteURL: task.remoteURL ?? ""
|
remoteURL: task.remoteURL ?? ""
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
|
||||||
|
|
||||||
/// 有线传图页,连接相机并上传照片到旅拍相册。
|
/// 有线传图页,连接相机并上传照片到旅拍相册。
|
||||||
struct WiredCameraTransferView: View {
|
struct WiredCameraTransferView: View {
|
||||||
@ -16,8 +15,11 @@ struct WiredCameraTransferView: View {
|
|||||||
@Environment(\.travelAlbumAPI) private var travelAlbumAPI
|
@Environment(\.travelAlbumAPI) private var travelAlbumAPI
|
||||||
@Environment(\.ossUploadService) private var ossUploadService
|
@Environment(\.ossUploadService) private var ossUploadService
|
||||||
@EnvironmentObject private var toastCenter: ToastCenter
|
@EnvironmentObject private var toastCenter: ToastCenter
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@StateObject private var viewModel: WiredCameraTransferViewModel
|
@StateObject private var viewModel: WiredCameraTransferViewModel
|
||||||
|
@State private var previewSources: [String]?
|
||||||
|
@State private var previewStartIndex = 0
|
||||||
|
|
||||||
init(context: WiredTransferContext) {
|
init(context: WiredTransferContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@ -30,22 +32,73 @@ struct WiredCameraTransferView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private let tabTitles = ["全部", "已上传", "失败"]
|
private let tabTitles = ["全部", "已上传", "失败"]
|
||||||
|
private var scenicID: Int { accountContext.currentScenic?.id ?? 0 }
|
||||||
|
private var scenicSpotLabel: String? {
|
||||||
|
accountContext.currentScenic?.name
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
headerSection
|
WiredTransferHeaderView(
|
||||||
tabSection
|
albumTitle: context.albumName,
|
||||||
photoListSection
|
headerPhone: context.phone,
|
||||||
bottomBar
|
onBack: { dismiss() }
|
||||||
|
)
|
||||||
|
|
||||||
|
WiredTransferControlCard(
|
||||||
|
scenicSpotLabel: scenicSpotLabel,
|
||||||
|
deviceStorageInfo: viewModel.deviceStorageInfo,
|
||||||
|
isCameraConnected: viewModel.isCameraConnected,
|
||||||
|
isRefreshingCameraFiles: viewModel.isRefreshingCameraFiles,
|
||||||
|
cameraDeviceName: viewModel.cameraDeviceName,
|
||||||
|
transferModeOption: viewModel.transferModeOption,
|
||||||
|
tabTitles: tabTitles,
|
||||||
|
tabCounts: viewModel.photos.transferTabCounts,
|
||||||
|
selectedTabIndex: viewModel.selectedTabIndex,
|
||||||
|
onDeviceUsageClick: viewModel.onDeviceUsageClick,
|
||||||
|
onRefreshCameraFiles: {
|
||||||
|
Task { await viewModel.refreshCameraFiles() }
|
||||||
|
},
|
||||||
|
onHelpClick: viewModel.openHelpDoc,
|
||||||
|
onTransferModeSelected: { option in
|
||||||
|
viewModel.selectTransferMode(
|
||||||
|
option,
|
||||||
|
api: travelAlbumAPI,
|
||||||
|
ossService: ossUploadService,
|
||||||
|
scenicID: scenicID
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onTabSelected: viewModel.selectTab
|
||||||
|
)
|
||||||
|
|
||||||
|
if viewModel.selectUploadMode, !visiblePhotos.isEmpty {
|
||||||
|
selectAllBar
|
||||||
}
|
}
|
||||||
.background(Color(hex: 0xF5F7FA))
|
|
||||||
.navigationTitle("有线传图")
|
contentSection
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
|
WiredTransferBottomBar(
|
||||||
|
selectUploadMode: viewModel.selectUploadMode,
|
||||||
|
selectedCount: viewModel.selectedPhotoIDs.count,
|
||||||
|
onBatchUploadClick: {
|
||||||
|
Task {
|
||||||
|
await viewModel.onBatchUploadButtonClick(
|
||||||
|
api: travelAlbumAPI,
|
||||||
|
ossService: ossUploadService,
|
||||||
|
scenicID: scenicID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
.task {
|
.task {
|
||||||
await viewModel.start(
|
await viewModel.start(
|
||||||
api: travelAlbumAPI,
|
api: travelAlbumAPI,
|
||||||
ossService: ossUploadService,
|
ossService: ossUploadService,
|
||||||
scenicID: accountContext.currentScenic?.id ?? 0,
|
scenicID: scenicID,
|
||||||
accountContext: accountContext
|
accountContext: accountContext
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -55,247 +108,140 @@ struct WiredCameraTransferView: View {
|
|||||||
.onChange(of: viewModel.errorMessage) { message in
|
.onChange(of: viewModel.errorMessage) { message in
|
||||||
guard let message, !message.isEmpty else { return }
|
guard let message, !message.isEmpty else { return }
|
||||||
toastCenter.show(message)
|
toastCenter.show(message)
|
||||||
|
viewModel.clearErrorMessage()
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.connectionState) { state in
|
.onChange(of: viewModel.connectionState) { state in
|
||||||
switch state {
|
switch state {
|
||||||
case .connected(let name):
|
case .connected(let name):
|
||||||
toastCenter.show("已连接 \(name)")
|
toastCenter.show("已连接 \(name)")
|
||||||
case .disconnected:
|
|
||||||
break
|
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
toastCenter.show(message)
|
toastCenter.show(message)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog("指定上传", isPresented: $viewModel.showSpecifyUploadSheet, titleVisibility: .visible) {
|
.alert("手机内存不足", isPresented: $viewModel.showDeviceStorageAlert) {
|
||||||
Button("上传全部待上传") {
|
Button("我知道了", role: .cancel) {}
|
||||||
Task { await viewModel.batchUploadAll() }
|
} message: {
|
||||||
|
Text("请及时清理内存")
|
||||||
}
|
}
|
||||||
Button("上传选中项") {
|
.alert("相机已断开", isPresented: $viewModel.showCameraDisconnectedAlert) {
|
||||||
Task { await viewModel.uploadSelected() }
|
Button("我知道了", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("相机已断开,请检查 USB 连接。")
|
||||||
}
|
}
|
||||||
Button("取消", role: .cancel) {}
|
.sheet(isPresented: $viewModel.showSpecifyUploadSheet) {
|
||||||
}
|
SpecifyUploadBottomSheet(isPresented: $viewModel.showSpecifyUploadSheet) { option in
|
||||||
}
|
Task {
|
||||||
|
await viewModel.onSpecifyUploadOptionSelected(
|
||||||
private var headerSection: some View {
|
option,
|
||||||
VStack(spacing: AppMetrics.Spacing.small) {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(context.albumName)
|
|
||||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
|
||||||
if !context.phone.isEmpty {
|
|
||||||
Text(context.phone)
|
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
|
||||||
.foregroundStyle(AppDesign.textSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
connectionBadge
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker("传输模式", selection: Binding(
|
|
||||||
get: { viewModel.transferModeOption },
|
|
||||||
set: { newValue in
|
|
||||||
viewModel.selectTransferMode(
|
|
||||||
newValue,
|
|
||||||
api: travelAlbumAPI,
|
api: travelAlbumAPI,
|
||||||
ossService: ossUploadService,
|
ossService: ossUploadService,
|
||||||
scenicID: accountContext.currentScenic?.id ?? 0
|
scenicID: scenicID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)) {
|
|
||||||
Text(WiredCameraTransferViewModel.modeLiveCapture).tag(WiredCameraTransferViewModel.modeLiveCapture)
|
|
||||||
Text(WiredCameraTransferViewModel.modePostShootTransfer).tag(WiredCameraTransferViewModel.modePostShootTransfer)
|
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.presentationDetents([.height(340)])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
}
|
}
|
||||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
.sheet(isPresented: $viewModel.showHelpDoc) {
|
||||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
NavigationStack {
|
||||||
.background(Color.white)
|
WiredTransferHelpDocView(url: WiredCameraTransferViewModel.helpDocURL)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("关闭") { viewModel.showHelpDoc = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connectionBadge: some View {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Circle()
|
|
||||||
.stroke(connectionColor, lineWidth: 3)
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.overlay {
|
|
||||||
Image(systemName: "camera.fill")
|
|
||||||
.foregroundStyle(connectionColor)
|
|
||||||
}
|
}
|
||||||
Text(connectionText)
|
}
|
||||||
.font(.system(size: 10))
|
}
|
||||||
.foregroundStyle(AppDesign.textSecondary)
|
.sheet(item: previewBinding) { item in
|
||||||
.multilineTextAlignment(.center)
|
WiredTransferPhotoPreviewSheet(
|
||||||
.frame(width: 72)
|
imageSources: item.sources,
|
||||||
|
startIndex: item.startIndex,
|
||||||
|
onDismiss: { previewSources = nil }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connectionColor: Color {
|
private var visiblePhotos: [WiredTransferPhotoItem] {
|
||||||
switch viewModel.connectionState {
|
viewModel.visiblePhotos
|
||||||
case .connected: AppDesign.success
|
|
||||||
case .error: Color.red
|
|
||||||
case .searching, .connecting: AppDesign.warning
|
|
||||||
case .disconnected: AppDesign.placeholder
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connectionText: String {
|
private var sidebarGroups: [WiredTransferDateGroup] {
|
||||||
switch viewModel.connectionState {
|
visiblePhotos.buildSidebarGroups()
|
||||||
case .connected(let name):
|
|
||||||
name
|
|
||||||
default:
|
|
||||||
viewModel.connectionState.displayText
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tabSection: some View {
|
private var photoSections: [WiredTransferPhotoSection] {
|
||||||
let counts = viewModel.photos.transferTabCounts
|
visiblePhotos.buildPhotoSections()
|
||||||
return HStack(spacing: 0) {
|
|
||||||
ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in
|
|
||||||
Button {
|
|
||||||
viewModel.selectedTabIndex = index
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Text(title)
|
|
||||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedTabIndex == index ? .semibold : .regular))
|
|
||||||
Text("\(counts[index])")
|
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
|
||||||
}
|
|
||||||
.foregroundStyle(viewModel.selectedTabIndex == index ? AppDesign.primary : AppDesign.textSecondary)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(viewModel.selectedTabIndex == index ? AppDesign.primarySoft : Color.clear)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color.white)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var photoListSection: some View {
|
private var selectAllBar: some View {
|
||||||
let visiblePhotos = viewModel.photos.filtered(byTabIndex: viewModel.selectedTabIndex)
|
let pendingIDs = visiblePhotos.selectablePendingPhotoIDs
|
||||||
return ScrollView {
|
let allSelected = !pendingIDs.isEmpty && pendingIDs.isSubset(of: viewModel.selectedPhotoIDs)
|
||||||
if visiblePhotos.isEmpty {
|
return WiredTransferSelectAllBar(
|
||||||
AppContentUnavailableView("暂无照片", systemImage: "photo")
|
selectedCount: viewModel.selectedPhotoIDs.count,
|
||||||
.frame(maxWidth: .infinity, minHeight: 260)
|
selectableCount: pendingIDs.count,
|
||||||
} else {
|
allSelected: allSelected,
|
||||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
onToggleSelectAll: viewModel.toggleSelectAllPendingInVisible
|
||||||
ForEach(visiblePhotos) { photo in
|
)
|
||||||
photoRow(photo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
|
||||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func photoRow(_ photo: WiredTransferPhotoItem) -> some View {
|
|
||||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
|
||||||
if viewModel.selectUploadMode, photo.canSelectForSpecifyUpload {
|
|
||||||
Button {
|
|
||||||
viewModel.togglePhotoSelection(id: photo.id)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: viewModel.selectedPhotoIDs.contains(photo.id) ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundStyle(viewModel.selectedPhotoIDs.contains(photo.id) ? AppDesign.primary : AppDesign.placeholder)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnailView(for: photo)
|
|
||||||
.frame(width: 64, height: 64)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(photo.fileName)
|
|
||||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
|
||||||
.lineLimit(1)
|
|
||||||
Text(photo.capturedAt)
|
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
|
||||||
.foregroundStyle(AppDesign.textSecondary)
|
|
||||||
HStack {
|
|
||||||
Text(photo.status.displayLabel)
|
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
|
||||||
.foregroundStyle(statusColor(photo.status))
|
|
||||||
if photo.status == .uploading || photo.status == .transferring {
|
|
||||||
Text("\(photo.progress)%")
|
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
|
||||||
.foregroundStyle(AppDesign.textSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if photo.status == .failed {
|
|
||||||
Button("重试") {
|
|
||||||
Task { await viewModel.retryPhoto(id: photo.id) }
|
|
||||||
}
|
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(AppMetrics.Spacing.medium)
|
|
||||||
.background(Color.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func thumbnailView(for photo: WiredTransferPhotoItem) -> some View {
|
private var contentSection: some View {
|
||||||
if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) {
|
if visiblePhotos.isEmpty {
|
||||||
if let uiImage = UIImage(contentsOfFile: url.path) {
|
WiredTransferEmptyState()
|
||||||
Image(uiImage: uiImage)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
} else {
|
} else {
|
||||||
placeholderThumbnail
|
WiredTransferPhotoListPanel(
|
||||||
}
|
sidebarExpanded: viewModel.sidebarExpanded,
|
||||||
} else {
|
sidebarGroups: sidebarGroups,
|
||||||
RemoteImage(urlString: photo.thumbnailURL) {
|
photoSections: photoSections,
|
||||||
placeholderThumbnail
|
selectedTimeSlotID: viewModel.selectedTimeSlotID,
|
||||||
}
|
selectUploadMode: viewModel.selectUploadMode,
|
||||||
}
|
selectedPhotoIDs: viewModel.selectedPhotoIDs,
|
||||||
}
|
onToggleSidebar: viewModel.toggleSidebar,
|
||||||
|
onTimeSlotSelected: viewModel.selectTimeSlot,
|
||||||
private var placeholderThumbnail: some View {
|
onPhotoClick: openPhotoPreview,
|
||||||
Color(hex: 0xE5E7EB)
|
onRetry: { id in Task { await viewModel.retryPhoto(id: id) } },
|
||||||
.overlay {
|
onDelete: viewModel.deletePhoto,
|
||||||
Image(systemName: "photo")
|
onTogglePhotoSelection: viewModel.togglePhotoSelection
|
||||||
.foregroundStyle(AppDesign.placeholder)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func statusColor(_ status: WiredTransferUploadStatus) -> Color {
|
private var previewBinding: Binding<PreviewPayload?> {
|
||||||
switch status {
|
Binding(
|
||||||
case .uploaded: AppDesign.success
|
get: {
|
||||||
case .failed: Color.red
|
guard let previewSources else { return nil }
|
||||||
case .uploading, .transferring: AppDesign.warning
|
return PreviewPayload(sources: previewSources, startIndex: previewStartIndex)
|
||||||
case .pending: AppDesign.textSecondary
|
},
|
||||||
|
set: { newValue in
|
||||||
|
if newValue == nil { previewSources = nil }
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var bottomBar: some View {
|
private func openPhotoPreview(photoID: String) {
|
||||||
HStack(spacing: AppMetrics.Spacing.small) {
|
let entries = visiblePhotos.compactMap { photo -> String? in
|
||||||
Button("同步历史") {
|
let url = viewModel.previewURL(for: photo.id)
|
||||||
Task { await viewModel.syncExistingPhotos() }
|
return url.isEmpty ? nil : url
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
guard let startIndex = visiblePhotos.firstIndex(where: { $0.id == photoID }),
|
||||||
|
!viewModel.previewURL(for: photoID).isEmpty else {
|
||||||
Button("批量上传") {
|
toastCenter.show("暂无法预览该照片")
|
||||||
Task { await viewModel.batchUploadAll() }
|
return
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
let mappedIndex = visiblePhotos.prefix(startIndex + 1).filter {
|
||||||
|
!viewModel.previewURL(for: $0.id).isEmpty
|
||||||
Button("指定上传") {
|
}.count - 1
|
||||||
viewModel.selectUploadMode = true
|
previewStartIndex = max(mappedIndex, 0)
|
||||||
viewModel.showSpecifyUploadSheet = true
|
previewSources = entries
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
|
||||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
|
||||||
.background(Color.white)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 预览 Sheet 载荷。
|
||||||
|
private struct PreviewPayload: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let sources: [String]
|
||||||
|
let startIndex: Int
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// SpecifyUploadBottomSheet.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 指定上传 Bottom Sheet,对齐 Android SpecifyUploadBottomSheet。
|
||||||
|
struct SpecifyUploadBottomSheet: View {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
let onConfirm: (String) -> Void
|
||||||
|
|
||||||
|
@State private var selectedOption = WiredCameraTransferViewModel.specifyUploadAllPending
|
||||||
|
|
||||||
|
private let options = [
|
||||||
|
WiredCameraTransferViewModel.specifyUploadAllPending,
|
||||||
|
WiredCameraTransferViewModel.specifyUploadTodayCaptured
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZStack {
|
||||||
|
Button("取消") { isPresented = false }
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("指定上传")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text333)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(height: 52)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
optionRow(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
Spacer(minLength: 20)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("取消") { isPresented = false }
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(Color.white)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(AppDesign.primary, lineWidth: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("确认上传") {
|
||||||
|
onConfirm(selectedOption)
|
||||||
|
}
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(AppDesign.primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.background(WiredTransferDesign.pageBackground)
|
||||||
|
.onAppear {
|
||||||
|
selectedOption = options.first ?? WiredCameraTransferViewModel.specifyUploadAllPending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionRow(_ option: String) -> some View {
|
||||||
|
Button {
|
||||||
|
selectedOption = option
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(option)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text333)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Image(systemName: selectedOption == option ? "largecircle.fill.circle" : "circle")
|
||||||
|
.foregroundStyle(selectedOption == option ? WiredTransferDesign.text333 : WiredTransferDesign.borderLight)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(WiredTransferDesign.borderLight, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferBottomBar.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图底部操作栏。
|
||||||
|
struct WiredTransferBottomBar: View {
|
||||||
|
let selectUploadMode: Bool
|
||||||
|
let selectedCount: Int
|
||||||
|
let onBatchUploadClick: () -> Void
|
||||||
|
let onSpecifyUploadClick: () -> Void
|
||||||
|
|
||||||
|
private var batchButtonTitle: String {
|
||||||
|
if !selectUploadMode { return "批量上传" }
|
||||||
|
if selectedCount > 0 { return "上传选中照片" }
|
||||||
|
return "取消选择"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Divider().background(WiredTransferDesign.borderLight)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onBatchUploadClick) {
|
||||||
|
Text(batchButtonTitle)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: WiredTransferDesign.bottomButtonHeight)
|
||||||
|
.background(AppDesign.primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.bottomButtonCornerRadius))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button(action: onSpecifyUploadClick) {
|
||||||
|
Text("指定上传")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(selectUploadMode ? WiredTransferDesign.text999 : AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: WiredTransferDesign.bottomButtonHeight)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: WiredTransferDesign.bottomButtonCornerRadius)
|
||||||
|
.stroke(AppDesign.primary, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(selectUploadMode)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.background(Color.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferControlCard.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图浮动控制卡片:连接区 + 筛选 + Tab。
|
||||||
|
struct WiredTransferControlCard: View {
|
||||||
|
let scenicSpotLabel: String?
|
||||||
|
let deviceStorageInfo: DeviceStorageSnapshot
|
||||||
|
let isCameraConnected: Bool
|
||||||
|
let isRefreshingCameraFiles: Bool
|
||||||
|
let cameraDeviceName: String
|
||||||
|
let transferModeOption: String
|
||||||
|
let tabTitles: [String]
|
||||||
|
let tabCounts: [Int]
|
||||||
|
let selectedTabIndex: Int
|
||||||
|
let onDeviceUsageClick: () -> Void
|
||||||
|
let onRefreshCameraFiles: () -> Void
|
||||||
|
let onHelpClick: () -> Void
|
||||||
|
let onTransferModeSelected: (String) -> Void
|
||||||
|
let onTabSelected: (Int) -> Void
|
||||||
|
|
||||||
|
private let modeOptions = [
|
||||||
|
WiredCameraTransferViewModel.modeLiveCapture,
|
||||||
|
WiredCameraTransferViewModel.modePostShootTransfer
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
connectionStatusRow
|
||||||
|
filterChipRow
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
|
||||||
|
Divider().background(WiredTransferDesign.borderLight)
|
||||||
|
|
||||||
|
WiredTransferStatsTabRow(
|
||||||
|
tabTitles: tabTitles,
|
||||||
|
counts: tabCounts,
|
||||||
|
selectedTabIndex: selectedTabIndex,
|
||||||
|
onTabSelected: onTabSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.cardCornerRadius))
|
||||||
|
.shadow(color: WiredTransferDesign.cardShadow, radius: 6, x: 0, y: 2)
|
||||||
|
.padding(.horizontal, WiredTransferDesign.cardHorizontalPadding)
|
||||||
|
.offset(y: -WiredTransferDesign.cardOverlap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionStatusRow: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
deviceUsagePanel
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
connectionChip
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
refreshButton
|
||||||
|
}
|
||||||
|
if !isCameraConnected {
|
||||||
|
helpDocLine
|
||||||
|
}
|
||||||
|
if let scenicSpotLabel, !scenicSpotLabel.isEmpty {
|
||||||
|
Text(scenicSpotLabel)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text999)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deviceUsagePanel: some View {
|
||||||
|
Button(action: onDeviceUsageClick) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(deviceStorageInfo.modelName)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text333)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(maxWidth: 88)
|
||||||
|
Text(deviceStorageInfo.availableGbText)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(minWidth: WiredTransferDesign.statusRingMinWidth)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(WiredTransferDesign.borderLight, lineWidth: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionChip: some View {
|
||||||
|
Text(connectionChipText)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(isCameraConnected ? AppDesign.primary : WiredTransferDesign.danger)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background((isCameraConnected ? AppDesign.primary : WiredTransferDesign.danger).opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionChipText: String {
|
||||||
|
if isCameraConnected {
|
||||||
|
let name = cameraDeviceName.isEmpty ? "已连接相机" : cameraDeviceName
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "未连接 USB"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshButton: some View {
|
||||||
|
Button(action: onRefreshCameraFiles) {
|
||||||
|
Text(refreshButtonTitle)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(minWidth: 64)
|
||||||
|
.frame(height: 34)
|
||||||
|
.background(refreshButtonColor)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!isCameraConnected || isRefreshingCameraFiles)
|
||||||
|
.opacity(isCameraConnected && !isRefreshingCameraFiles ? 1 : 0.85)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshButtonTitle: String {
|
||||||
|
if isRefreshingCameraFiles { return "读取中" }
|
||||||
|
if isCameraConnected { return "刷新" }
|
||||||
|
return "等待连接"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshButtonColor: Color {
|
||||||
|
if !isCameraConnected { return WiredTransferDesign.danger }
|
||||||
|
if isRefreshingCameraFiles { return WiredTransferDesign.text999 }
|
||||||
|
return AppDesign.primary
|
||||||
|
}
|
||||||
|
|
||||||
|
private var helpDocLine: some View {
|
||||||
|
Button(action: onHelpClick) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("未连接,查看")
|
||||||
|
.foregroundStyle(WiredTransferDesign.text666)
|
||||||
|
Text("《帮助文档》")
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filterChipRow: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let totalWeight: CGFloat = 1 + 0.72 + 1.15
|
||||||
|
let spacing: CGFloat = 6
|
||||||
|
let available = geometry.size.width - spacing * 2
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
WiredTransferFilterChip(label: "不修图", options: [], enabled: false, onSelected: { _ in })
|
||||||
|
.frame(width: available * 1 / totalWeight)
|
||||||
|
WiredTransferFilterChip(
|
||||||
|
label: WiredCameraTransferViewModel.formatJPG,
|
||||||
|
options: [],
|
||||||
|
enabled: false,
|
||||||
|
onSelected: { _ in }
|
||||||
|
)
|
||||||
|
.frame(width: available * 0.72 / totalWeight)
|
||||||
|
WiredTransferFilterChip(
|
||||||
|
label: transferModeOption,
|
||||||
|
options: modeOptions,
|
||||||
|
onSelected: onTransferModeSelected
|
||||||
|
)
|
||||||
|
.frame(width: available * 1.15 / totalWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: WiredTransferDesign.filterChipHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计 Tab 行,对齐 Android TransferStatsTabRow。
|
||||||
|
struct WiredTransferStatsTabRow: View {
|
||||||
|
let tabTitles: [String]
|
||||||
|
let counts: [Int]
|
||||||
|
let selectedTabIndex: Int
|
||||||
|
let onTabSelected: (Int) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in
|
||||||
|
if index > 0 {
|
||||||
|
Divider()
|
||||||
|
.frame(height: 32)
|
||||||
|
.background(WiredTransferDesign.borderLight)
|
||||||
|
}
|
||||||
|
tabItem(index: index, title: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: WiredTransferDesign.tabRowHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tabItem(index: Int, title: String) -> some View {
|
||||||
|
Button {
|
||||||
|
onTabSelected(index)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(counts.indices.contains(index) ? counts[index] : 0)")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(countColor(for: index))
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(selectedTabIndex == index ? AppDesign.primary : WiredTransferDesign.text666)
|
||||||
|
RoundedRectangle(cornerRadius: 1)
|
||||||
|
.fill(selectedTabIndex == index ? AppDesign.primary : Color.clear)
|
||||||
|
.frame(width: 24, height: 2)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func countColor(for index: Int) -> Color {
|
||||||
|
if selectedTabIndex == index { return AppDesign.primary }
|
||||||
|
if index == 2 { return WiredTransferDesign.danger }
|
||||||
|
return WiredTransferDesign.text333
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferDesign.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// 有线传图页设计 Token,对齐 Android WiredCameraTransferScreen。
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图页专用色值与尺寸,对齐 Android zhiflyfollow 实现。
|
||||||
|
enum WiredTransferDesign {
|
||||||
|
static let pageBackground = Color(hex: 0xF4F4F4)
|
||||||
|
static let text333 = Color(hex: 0x333333)
|
||||||
|
static let text666 = Color(hex: 0x666666)
|
||||||
|
static let text999 = Color(hex: 0x999999)
|
||||||
|
static let borderLight = Color(hex: 0xEEEEEE)
|
||||||
|
static let danger = Color(hex: 0xFF2B2B)
|
||||||
|
static let success = Color(hex: 0x09BE4F)
|
||||||
|
static let sidebarBackground = Color(hex: 0xF5F6F8)
|
||||||
|
static let cardShadow = Color.black.opacity(0.08)
|
||||||
|
|
||||||
|
static let statusRingMinWidth: CGFloat = 76
|
||||||
|
static let sidebarWidth: CGFloat = 76
|
||||||
|
static let cardCornerRadius: CGFloat = 12
|
||||||
|
static let filterChipHeight: CGFloat = 34
|
||||||
|
static let filterChipCornerRadius: CGFloat = 8
|
||||||
|
static let tabRowHeight: CGFloat = 68
|
||||||
|
static let thumbnailSize: CGFloat = 48
|
||||||
|
static let thumbnailCornerRadius: CGFloat = 6
|
||||||
|
static let bottomButtonHeight: CGFloat = 44
|
||||||
|
static let bottomButtonCornerRadius: CGFloat = 10
|
||||||
|
static let progressBarHeight: CGFloat = 2
|
||||||
|
static let cardHorizontalPadding: CGFloat = 16
|
||||||
|
static let cardOverlap: CGFloat = 10
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferEmptyState.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图空态,对齐 Android TransferEmptyState。
|
||||||
|
struct WiredTransferEmptyState: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
WiredTransferEmptyIllustration()
|
||||||
|
.frame(width: 200, height: 150)
|
||||||
|
Text("暂无传输文件")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text999)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空态插图,对齐 Android ic_img_transfer 布局尺寸。
|
||||||
|
private struct WiredTransferEmptyIllustration: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Ellipse()
|
||||||
|
.fill(Color(hex: 0xE7E9EB))
|
||||||
|
.frame(width: 180, height: 28)
|
||||||
|
.offset(y: 52)
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color.white)
|
||||||
|
.frame(width: 56, height: 96)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(hex: 0xE7E9EB))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.offset(y: -16)
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(Color(hex: 0xDDE1E5), lineWidth: 2)
|
||||||
|
}
|
||||||
|
.offset(x: -36, y: -8)
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(WiredTransferDesign.text333)
|
||||||
|
.frame(width: 52, height: 34)
|
||||||
|
.overlay {
|
||||||
|
Circle()
|
||||||
|
.fill(AppDesign.primary)
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.offset(x: 8)
|
||||||
|
}
|
||||||
|
.offset(x: 42, y: 4)
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppDesign.primary)
|
||||||
|
.frame(width: 18, height: 4)
|
||||||
|
Image(systemName: "arrowtriangle.right.fill")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
.offset(x: 4, y: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferFilterChip.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图筛选 Chip,对齐 Android FilterChipDropdown。
|
||||||
|
struct WiredTransferFilterChip: View {
|
||||||
|
let label: String
|
||||||
|
let options: [String]
|
||||||
|
var enabled: Bool = true
|
||||||
|
let onSelected: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
if options.isEmpty {
|
||||||
|
Button(label) {}
|
||||||
|
.disabled(true)
|
||||||
|
} else {
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
Button(option) { onSelected(option) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(enabled && !options.isEmpty ? WiredTransferDesign.text666 : WiredTransferDesign.text999)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
if enabled, !options.isEmpty {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text999)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, enabled && !options.isEmpty ? 8 : 8)
|
||||||
|
.frame(height: WiredTransferDesign.filterChipHeight)
|
||||||
|
.background(WiredTransferDesign.pageBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.filterChipCornerRadius))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: WiredTransferDesign.filterChipCornerRadius)
|
||||||
|
.stroke(WiredTransferDesign.borderLight, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!enabled || options.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferHeaderView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图页蓝色顶栏,展示相册名与手机号。
|
||||||
|
struct WiredTransferHeaderView: View {
|
||||||
|
let albumTitle: String
|
||||||
|
let headerPhone: String
|
||||||
|
let onBack: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(alignment: .center, spacing: 0) {
|
||||||
|
Button(action: onBack) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(albumTitle)
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
if !headerPhone.isEmpty {
|
||||||
|
Text(headerPhone)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.white.opacity(0.92))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 22)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(AppDesign.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferHelpDocView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// 有线传图帮助文档 H5 页。
|
||||||
|
struct WiredTransferHelpDocView: View {
|
||||||
|
let url: URL
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
WiredTransferHelpWebView(url: url, isLoading: $isLoading)
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.large)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.white.opacity(0.4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||||
|
.navigationTitle("帮助文档")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WiredTransferHelpWebView: UIViewRepresentable {
|
||||||
|
let url: URL
|
||||||
|
@Binding var isLoading: Bool
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(isLoading: $isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
|
let webView = WKWebView()
|
||||||
|
webView.navigationDelegate = context.coordinator
|
||||||
|
return webView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
|
guard webView.url != url else { return }
|
||||||
|
isLoading = true
|
||||||
|
webView.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
|
@Binding var isLoading: Bool
|
||||||
|
|
||||||
|
init(isLoading: Binding<Bool>) {
|
||||||
|
_isLoading = isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferPhotoListPanel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图照片列表面板(侧栏 + 分组列表)。
|
||||||
|
struct WiredTransferPhotoListPanel: View {
|
||||||
|
let sidebarExpanded: Bool
|
||||||
|
let sidebarGroups: [WiredTransferDateGroup]
|
||||||
|
let photoSections: [WiredTransferPhotoSection]
|
||||||
|
let selectedTimeSlotID: String?
|
||||||
|
let selectUploadMode: Bool
|
||||||
|
let selectedPhotoIDs: Set<String>
|
||||||
|
let onToggleSidebar: () -> Void
|
||||||
|
let onTimeSlotSelected: (String) -> Void
|
||||||
|
let onPhotoClick: (String) -> Void
|
||||||
|
let onRetry: (String) -> Void
|
||||||
|
let onDelete: (String) -> Void
|
||||||
|
let onTogglePhotoSelection: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
if sidebarExpanded, !sidebarGroups.isEmpty {
|
||||||
|
WiredTransferTimeSidebar(
|
||||||
|
groups: sidebarGroups,
|
||||||
|
selectedSlotID: selectedTimeSlotID,
|
||||||
|
onToggleSidebar: onToggleSidebar,
|
||||||
|
onTimeSlotSelected: onTimeSlotSelected
|
||||||
|
)
|
||||||
|
Divider().background(WiredTransferDesign.borderLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||||
|
ForEach(photoSections) { section in
|
||||||
|
Section {
|
||||||
|
ForEach(section.photos) { photo in
|
||||||
|
WiredTransferPhotoRow(
|
||||||
|
photo: photo,
|
||||||
|
selectUploadMode: selectUploadMode,
|
||||||
|
selected: selectedPhotoIDs.contains(photo.id),
|
||||||
|
onToggleSelection: { onTogglePhotoSelection(photo.id) },
|
||||||
|
onPhotoClick: { onPhotoClick(photo.id) },
|
||||||
|
onRetry: { onRetry(photo.id) },
|
||||||
|
onDelete: { onDelete(photo.id) }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
.background(WiredTransferDesign.borderLight)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
sectionHeader(section.headerTitle)
|
||||||
|
.id(section.slotId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
.onChange(of: selectedTimeSlotID) { slotID in
|
||||||
|
guard let slotID else { return }
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(slotID, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sidebarExpanded, !sidebarGroups.isEmpty {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
WiredTransferSidebarExpandTab(onExpand: onToggleSidebar)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.background(Color.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ title: String) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text666)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(WiredTransferDesign.pageBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferPhotoPreviewSheet.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
/// 有线传图照片预览,支持本地 file:// 与远程 URL。
|
||||||
|
struct WiredTransferPhotoPreviewSheet: View {
|
||||||
|
let imageSources: [String]
|
||||||
|
let startIndex: Int
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@State private var currentIndex: Int
|
||||||
|
|
||||||
|
init(imageSources: [String], startIndex: Int, onDismiss: @escaping () -> Void) {
|
||||||
|
self.imageSources = imageSources
|
||||||
|
self.startIndex = startIndex
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
_currentIndex = State(initialValue: startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
TabView(selection: $currentIndex) {
|
||||||
|
ForEach(Array(imageSources.enumerated()), id: \.offset) { index, source in
|
||||||
|
previewImage(source)
|
||||||
|
.tag(index)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: imageSources.count > 1 ? .automatic : .never))
|
||||||
|
.background(Color.black.ignoresSafeArea())
|
||||||
|
.navigationTitle("图片预览")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("关闭", action: onDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func previewImage(_ source: String) -> some View {
|
||||||
|
if source.hasPrefix("file://"), let url = URL(string: source),
|
||||||
|
let uiImage = UIImage(contentsOfFile: url.path) {
|
||||||
|
Image(uiImage: uiImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
} else if let url = URL(string: source), !source.isEmpty {
|
||||||
|
KFImage(url)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
} else {
|
||||||
|
Text("暂无法预览该照片")
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferPhotoRow.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 有线传图照片列表行。
|
||||||
|
struct WiredTransferPhotoRow: View {
|
||||||
|
let photo: WiredTransferPhotoItem
|
||||||
|
let selectUploadMode: Bool
|
||||||
|
let selected: Bool
|
||||||
|
let onToggleSelection: () -> Void
|
||||||
|
let onPhotoClick: () -> Void
|
||||||
|
let onRetry: () -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
|
||||||
|
private var canSelect: Bool { photo.canSelectForSpecifyUpload }
|
||||||
|
private var canRetry: Bool { photo.status == .failed || photo.status == .pending }
|
||||||
|
private var rowOpacity: Double { selectUploadMode && !canSelect ? 0.45 : 1 }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
if selectUploadMode {
|
||||||
|
Button(action: onToggleSelection) {
|
||||||
|
WiredTransferSelectIndicator(selected: selected, enabled: canSelect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailView
|
||||||
|
.frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius))
|
||||||
|
.opacity(rowOpacity)
|
||||||
|
.onTapGesture {
|
||||||
|
if !selectUploadMode { onPhotoClick() }
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(photo.fileName)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
WiredTransferStatusBadge(status: photo.status)
|
||||||
|
.opacity(rowOpacity)
|
||||||
|
}
|
||||||
|
Text(metaText)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !selectUploadMode {
|
||||||
|
Menu {
|
||||||
|
Button("重传") { onRetry() }
|
||||||
|
.disabled(!canRetry)
|
||||||
|
Button("删除", role: .destructive) { onDelete() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text666)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.rotationEffect(.degrees(90))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
if selectUploadMode, canSelect { onToggleSelection() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if photo.status == .transferring || photo.status == .uploading {
|
||||||
|
ProgressView(value: Double(photo.progress), total: 100)
|
||||||
|
.tint(AppDesign.primary)
|
||||||
|
.frame(height: WiredTransferDesign.progressBarHeight)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var metaText: String {
|
||||||
|
if photo.resolutionText.isEmpty { return photo.fileSizeText }
|
||||||
|
return "\(photo.fileSizeText) \(photo.resolutionText)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var thumbnailView: some View {
|
||||||
|
if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) {
|
||||||
|
if let uiImage = UIImage(contentsOfFile: url.path) {
|
||||||
|
Image(uiImage: uiImage)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} else {
|
||||||
|
thumbnailPlaceholder
|
||||||
|
}
|
||||||
|
} else if !photo.thumbnailURL.isEmpty {
|
||||||
|
RemoteImage(urlString: photo.thumbnailURL) {
|
||||||
|
thumbnailPlaceholder
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
thumbnailPlaceholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var thumbnailPlaceholder: some View {
|
||||||
|
WiredTransferDesign.pageBackground
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.foregroundStyle(WiredTransferDesign.text999)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferSelectAllBar.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 指定上传勾选模式下的全选栏。
|
||||||
|
struct WiredTransferSelectAllBar: View {
|
||||||
|
let selectedCount: Int
|
||||||
|
let selectableCount: Int
|
||||||
|
let allSelected: Bool
|
||||||
|
let onToggleSelectAll: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Button(action: onToggleSelectAll) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
WiredTransferSelectIndicator(selected: allSelected, enabled: selectableCount > 0)
|
||||||
|
Text("全选")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(selectableCount > 0 ? WiredTransferDesign.text333 : WiredTransferDesign.text999)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("已选 \(selectedCount) / \(selectableCount)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text666)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Divider().background(WiredTransferDesign.borderLight)
|
||||||
|
}
|
||||||
|
.background(Color.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 勾选指示器。
|
||||||
|
struct WiredTransferSelectIndicator: View {
|
||||||
|
let selected: Bool
|
||||||
|
var enabled: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(enabled ? (selected ? AppDesign.primary : WiredTransferDesign.text999) : WiredTransferDesign.text999.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferStatusBadge.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图上传状态 Badge。
|
||||||
|
struct WiredTransferStatusBadge: View {
|
||||||
|
let status: WiredTransferUploadStatus
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(status.displayLabel)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(textColor)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textColor: Color {
|
||||||
|
switch status {
|
||||||
|
case .uploaded: WiredTransferDesign.success
|
||||||
|
case .pending: WiredTransferDesign.text666
|
||||||
|
case .transferring, .uploading: AppDesign.primary
|
||||||
|
case .failed: WiredTransferDesign.danger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
switch status {
|
||||||
|
case .uploaded: WiredTransferDesign.success.opacity(0.12)
|
||||||
|
case .pending: WiredTransferDesign.pageBackground
|
||||||
|
case .transferring, .uploading: AppDesign.primary.opacity(0.12)
|
||||||
|
case .failed: WiredTransferDesign.danger.opacity(0.12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferTimeSidebar.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 有线传图时间侧栏。
|
||||||
|
struct WiredTransferTimeSidebar: View {
|
||||||
|
let groups: [WiredTransferDateGroup]
|
||||||
|
let selectedSlotID: String?
|
||||||
|
let onToggleSidebar: () -> Void
|
||||||
|
let onTimeSlotSelected: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Button(action: onToggleSidebar) {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text("收起")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
.foregroundStyle(WiredTransferDesign.text666)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(groups, id: \.dateLabel) { group in
|
||||||
|
Text(group.dateLabel)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text999)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
ForEach(group.slots) { slot in
|
||||||
|
timeSlotButton(slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: WiredTransferDesign.sidebarWidth)
|
||||||
|
.background(WiredTransferDesign.sidebarBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeSlotButton(_ slot: WiredTransferTimeSlot) -> some View {
|
||||||
|
let selected = slot.id == selectedSlotID
|
||||||
|
return Button {
|
||||||
|
onTimeSlotSelected(slot.id)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(selected ? AppDesign.primary : WiredTransferDesign.text333)
|
||||||
|
Text(slot.slotStartLabel)
|
||||||
|
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(selected ? AppDesign.primary : WiredTransferDesign.text333)
|
||||||
|
}
|
||||||
|
Text("\(slot.photoCount)张")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text999)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(selected ? Color.white : Color.clear)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 侧栏收起后的展开按钮。
|
||||||
|
struct WiredTransferSidebarExpandTab: View {
|
||||||
|
let onExpand: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onExpand) {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(WiredTransferDesign.text666)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(WiredTransferDesign.sidebarBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// CameraDownloadStorageTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// CameraDownloadStorage 相对路径持久化测试。
|
||||||
|
final class CameraDownloadStorageTests: XCTestCase {
|
||||||
|
/// 测试相对路径前缀。
|
||||||
|
func testRelativePathUsesCameraDownloadsPrefix() {
|
||||||
|
let fileURL = CameraDownloadStorage.downloadsDirectory.appendingPathComponent("1730_test.JPG")
|
||||||
|
XCTAssertEqual(CameraDownloadStorage.relativePath(for: fileURL), "CameraDownloads/1730_test.JPG")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试相对路径解析。
|
||||||
|
func testResolveLocalURLFromRelativePath() {
|
||||||
|
let stored = "CameraDownloads/1730_test.JPG"
|
||||||
|
let resolved = CameraDownloadStorage.resolveLocalURL(from: stored)
|
||||||
|
let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored)
|
||||||
|
XCTAssertEqual(resolved, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试旧版绝对路径迁移。
|
||||||
|
func testMigrateStoredPathFromLegacyAbsolutePath() throws {
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy.jpg")
|
||||||
|
try Data("jpeg".utf8).write(to: fileURL)
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
|
||||||
|
let migrated = CameraDownloadStorage.migrateStoredPath(fileURL.path)
|
||||||
|
XCTAssertEqual(migrated, "CameraDownloads/\(fileURL.lastPathComponent)")
|
||||||
|
XCTAssertEqual(CameraDownloadStorage.resolveLocalURL(from: migrated ?? "")?.path, fileURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试预览 URL 字符串生成。
|
||||||
|
func testPreviewURLStringFromRelativePath() throws {
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg")
|
||||||
|
try Data("jpeg".utf8).write(to: fileURL)
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
|
||||||
|
let stored = CameraDownloadStorage.relativePath(for: fileURL)
|
||||||
|
let preview = CameraDownloadStorage.previewURLString(from: stored)
|
||||||
|
XCTAssertFalse(preview.isEmpty)
|
||||||
|
XCTAssertTrue(preview.hasPrefix("file://"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,13 +11,16 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 相机传输管道测试,使用 Mock 相机与上传 Sink。
|
/// 相机传输管道测试,使用 Mock 相机与上传 Sink。
|
||||||
final class CameraTransferPipelineTests: XCTestCase {
|
final class CameraTransferPipelineTests: XCTestCase {
|
||||||
/// 测试新照片下载后触发上传 Sink。
|
/// 测试新照片下载后触发上传 Sink,并持久化相对路径。
|
||||||
func testNewAssetTriggersUploadSink() async {
|
func testNewAssetTriggersUploadSink() async {
|
||||||
let camera = MockCameraService()
|
let camera = MockCameraService()
|
||||||
let sink = MockUploadSink()
|
let sink = MockUploadSink()
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
||||||
|
|
||||||
|
var latestTasks: [CameraTransferTask] = []
|
||||||
|
pipeline.onTasksUpdated = { latestTasks = $0 }
|
||||||
|
|
||||||
let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024)
|
let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024)
|
||||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG")
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG")
|
||||||
try? Data(repeating: 0xFF, count: 8).write(to: tempURL)
|
try? Data(repeating: 0xFF, count: 8).write(to: tempURL)
|
||||||
@ -26,6 +29,12 @@ final class CameraTransferPipelineTests: XCTestCase {
|
|||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sink.uploadCount, 1)
|
XCTAssertEqual(sink.uploadCount, 1)
|
||||||
|
let savedTask = latestTasks.first { $0.assetID == asset.id }
|
||||||
|
XCTAssertEqual(savedTask?.localPath?.hasPrefix("CameraDownloads/"), true)
|
||||||
|
XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false)
|
||||||
|
if let localURL = savedTask?.localURL {
|
||||||
|
try? FileManager.default.removeItem(at: localURL)
|
||||||
|
}
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,6 +82,93 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertTrue(viewModel.photos.isEmpty)
|
XCTAssertTrue(viewModel.photos.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试批量上传按钮三态流转。
|
||||||
|
func testBatchUploadButtonThreeStateFlow() async {
|
||||||
|
let viewModel = makeViewModelWithPendingPhotos()
|
||||||
|
let api = MockTravelAlbumAPIForWiredTransfer()
|
||||||
|
let oss = WiredTransferMockOSSUploadService()
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.selectUploadMode)
|
||||||
|
await viewModel.onBatchUploadButtonClick(api: api, ossService: oss, scenicID: 1)
|
||||||
|
XCTAssertTrue(viewModel.selectUploadMode)
|
||||||
|
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
|
||||||
|
|
||||||
|
viewModel.togglePhotoSelection(id: "pending-1")
|
||||||
|
XCTAssertEqual(viewModel.selectedPhotoIDs, ["pending-1"])
|
||||||
|
|
||||||
|
await viewModel.onBatchUploadButtonClick(api: api, ossService: oss, scenicID: 1)
|
||||||
|
XCTAssertFalse(viewModel.selectUploadMode)
|
||||||
|
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试全选 pending 与取消全选。
|
||||||
|
func testToggleSelectAllPendingInVisible() {
|
||||||
|
let viewModel = makeViewModelWithPendingPhotos()
|
||||||
|
viewModel.selectUploadMode = true
|
||||||
|
|
||||||
|
viewModel.toggleSelectAllPendingInVisible()
|
||||||
|
XCTAssertEqual(viewModel.selectedPhotoIDs, ["pending-1", "pending-2"])
|
||||||
|
|
||||||
|
viewModel.toggleSelectAllPendingInVisible()
|
||||||
|
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试指定上传今日拍摄筛选。
|
||||||
|
func testUploadAllTodayCapturedFiltersByDate() async {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
let today = formatter.string(from: Date())
|
||||||
|
|
||||||
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
||||||
|
cameraService: MockWiredCameraService()
|
||||||
|
)
|
||||||
|
viewModel.photos = [
|
||||||
|
makePendingPhoto(id: "today", capturedAt: today),
|
||||||
|
makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"),
|
||||||
|
]
|
||||||
|
|
||||||
|
await viewModel.onSpecifyUploadOptionSelected(
|
||||||
|
WiredCameraTransferViewModel.specifyUploadTodayCaptured,
|
||||||
|
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||||
|
ossService: WiredTransferMockOSSUploadService(),
|
||||||
|
scenicID: 1
|
||||||
|
)
|
||||||
|
XCTAssertNil(viewModel.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
|
||||||
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
||||||
|
cameraService: MockWiredCameraService()
|
||||||
|
)
|
||||||
|
viewModel.photos = [
|
||||||
|
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
|
||||||
|
makePendingPhoto(id: "pending-2", capturedAt: "2026-05-20 12:18:06"),
|
||||||
|
WiredTransferPhotoItem(
|
||||||
|
id: "uploaded-1",
|
||||||
|
fileName: "uploaded.JPG",
|
||||||
|
thumbnailURL: "",
|
||||||
|
capturedAt: "2026-05-20 12:20:00",
|
||||||
|
fileSizeText: "1 MB",
|
||||||
|
status: .uploaded
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePendingPhoto(id: String, capturedAt: String) -> WiredTransferPhotoItem {
|
||||||
|
WiredTransferPhotoItem(
|
||||||
|
id: id,
|
||||||
|
fileName: "\(id).JPG",
|
||||||
|
thumbnailURL: "",
|
||||||
|
capturedAt: capturedAt,
|
||||||
|
fileSizeText: "1 MB",
|
||||||
|
status: .pending
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// WiredTransferPhotoGroupingTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 有线传图照片分组逻辑测试。
|
||||||
|
final class WiredTransferPhotoGroupingTests: XCTestCase {
|
||||||
|
/// 测试 30 分钟时间段分组与侧栏构建。
|
||||||
|
func testBuildPhotoSectionsAndSidebarGroups() {
|
||||||
|
let photos = [
|
||||||
|
makePhoto(id: "1", capturedAt: "2026-05-20 12:05:18", status: .uploaded),
|
||||||
|
makePhoto(id: "2", capturedAt: "2026-05-20 12:18:06", status: .pending),
|
||||||
|
makePhoto(id: "3", capturedAt: "2026-05-20 11:55:51", status: .failed),
|
||||||
|
]
|
||||||
|
|
||||||
|
let sections = photos.buildPhotoSections()
|
||||||
|
XCTAssertEqual(sections.count, 2)
|
||||||
|
XCTAssertEqual(sections[0].photos.count, 2)
|
||||||
|
XCTAssertTrue(sections[0].headerTitle.contains("12:00 - 12:30"))
|
||||||
|
|
||||||
|
let sidebar = photos.buildSidebarGroups()
|
||||||
|
XCTAssertEqual(sidebar.count, 1)
|
||||||
|
XCTAssertEqual(sidebar[0].slots.count, 2)
|
||||||
|
XCTAssertEqual(sidebar[0].slots[0].photoCount, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试今日拍摄筛选。
|
||||||
|
func testIsCapturedTodayUsesLocalCalendar() {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
let today = formatter.string(from: Date())
|
||||||
|
|
||||||
|
let photo = makePhoto(id: "today", capturedAt: today, status: .pending)
|
||||||
|
XCTAssertTrue(photo.isCapturedToday())
|
||||||
|
XCTAssertFalse(makePhoto(id: "old", capturedAt: "2020-01-01 10:00:00", status: .pending).isCapturedToday())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 Tab 计数与 pending 可选集合。
|
||||||
|
func testTransferTabCountsAndSelectablePendingIDs() {
|
||||||
|
let photos = [
|
||||||
|
makePhoto(id: "1", capturedAt: "2026-05-20 12:05:18", status: .uploaded),
|
||||||
|
makePhoto(id: "2", capturedAt: "2026-05-20 12:18:06", status: .pending),
|
||||||
|
makePhoto(id: "3", capturedAt: "2026-05-20 12:35:44", status: .failed),
|
||||||
|
]
|
||||||
|
XCTAssertEqual(photos.transferTabCounts, [3, 1, 1])
|
||||||
|
XCTAssertEqual(photos.selectablePendingPhotoIDs, ["2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePhoto(
|
||||||
|
id: String,
|
||||||
|
capturedAt: String,
|
||||||
|
status: WiredTransferUploadStatus
|
||||||
|
) -> WiredTransferPhotoItem {
|
||||||
|
WiredTransferPhotoItem(
|
||||||
|
id: id,
|
||||||
|
fileName: "\(id).JPG",
|
||||||
|
thumbnailURL: "",
|
||||||
|
capturedAt: capturedAt,
|
||||||
|
fileSizeText: "1.00 MB",
|
||||||
|
resolutionText: "4032x3024",
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user