对齐有线传图页 Android UI,并改用相对路径持久化本地照片。

还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 11:16:28 +08:00
parent e91a46c315
commit c71b45bdfd
29 changed files with 2097 additions and 256 deletions

View File

@ -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 {
let id: String
@ -289,6 +311,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
let thumbnailURL: String
let capturedAt: String
let fileSizeText: String
let resolutionText: String
let status: WiredTransferUploadStatus
let progress: Int
let errorMessage: String?
@ -300,6 +323,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
thumbnailURL: String,
capturedAt: String,
fileSizeText: String,
resolutionText: String = "",
status: WiredTransferUploadStatus,
progress: Int = 0,
errorMessage: String? = nil
@ -310,6 +334,7 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
self.thumbnailURL = thumbnailURL
self.capturedAt = capturedAt
self.fileSizeText = fileSizeText
self.resolutionText = resolutionText
self.status = status
self.progress = progress
self.errorMessage = errorMessage
@ -322,6 +347,24 @@ struct WiredTransferPhotoItem: Identifiable, Equatable {
var isNotUploaded: Bool {
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
func toPhotoItem() -> WiredTransferPhotoItem {
let previewPath = [thumbnailPath, localPath, remoteURL]
.first { path in
!path.isEmpty && (path.hasPrefix("http") || FileManager.default.fileExists(atPath: path))
} ?? ""
let thumbnailURL: String
if previewPath.hasPrefix("http") {
thumbnailURL = previewPath
} else if !previewPath.isEmpty {
thumbnailURL = "file://\(previewPath)"
} else {
thumbnailURL = ""
}
let previewCandidates = [thumbnailPath, localPath, remoteURL]
let thumbnailURL = previewCandidates.compactMap { path -> String? in
guard !path.isEmpty else { return nil }
if path.hasPrefix("http") { return path }
let preview = CameraDownloadStorage.previewURLString(from: path)
return preview.isEmpty ? nil : preview
}.first ?? ""
return WiredTransferPhotoItem(
id: id,
sourceId: sourceId,
@ -425,6 +464,114 @@ extension Array where Element == WiredTransferPhotoItem {
var selectablePendingPhotoIDs: Set<String> {
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)
}
/// 0520 12:00 - 12:30
static func sectionHeaderTitle(for slotStart: Date) -> String {
"\(dateLabel(for: slotStart)) \(timeLabel(for: slotStart)) - \(timeLabel(for: slotStart.addingTimeInterval(30 * 60)))"
}
/// 0520
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 {

View File

@ -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
}
}

View File

@ -60,20 +60,44 @@ final class WiredTransferPhotoStore {
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
else { return [] }
return records
.filter { record in
!deletedIDs.contains(record.id)
&& record.userId == userID
&& record.albumId == albumID
&& record.isDisplayable
}
.map { $0.normalizeInterruptedTransfer() }
let filteredRecords = records.filter { record in
!deletedIDs.contains(record.id)
&& record.userId == userID
&& record.albumId == albumID
}
let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() }
let migratedRecords = normalizeStoredPaths(normalizedRecords, albumID: albumID)
return migratedRecords
.filter(\.isDisplayable)
.sorted { $0.capturedAt > $1.capturedAt }
.also { items in
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]) {
guard albumID > 0 else { return }
@ -142,7 +166,7 @@ final class WiredTransferPhotoStore {
private func deleteFileIfExists(_ path: String) {
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) {
try? FileManager.default.removeItem(at: url)
}
@ -181,7 +205,43 @@ final class WiredTransferPhotoStore {
private extension WiredTransferPhotoRecord {
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 {

View File

@ -7,7 +7,7 @@
## 入口
- 权限 URI`travel_album``TravelAlbumEntryView`
- Android 对照:`ui/travelalbum`
- Android 对照:`ui/travelalbum``WiredCameraTransferScreen.kt`
## 核心页面
@ -16,7 +16,31 @@
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 |
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
| `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`
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
4. 素材登记:`POST .../travel-album/upload-material`
5. 本地照片路径以相对 Documents 的路径持久化(如 `CameraDownloads/xxx.JPG`),加载时自动迁移旧版绝对路径
## 解耦关系
@ -36,3 +61,9 @@
- `Core/CameraTethering` — USB/PTP 相机
- `Core/CameraTransfer` — 传输编排
- `Core/Upload` — OSS STS 上传
## 二期待补齐
- RAW 预览
- 格式 Chip 扩展 RAW / JPEG+RAW并与 `CameraTransferPipeline` 联动
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)

View File

@ -7,6 +7,7 @@
import Foundation
import Combine
import UIKit
/// ViewModel
@MainActor
@ -229,7 +230,12 @@ final class TravelAlbumDetailViewModel: ObservableObject {
@MainActor
final class WiredCameraTransferViewModel: ObservableObject {
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 selectedTabIndex = 0
@ -239,6 +245,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
@Published var selectedPhotoIDs: Set<String> = []
@Published var transferModeOption = modeLiveCapture
@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 errorMessage: String?
@ -247,6 +258,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
private var photoStore: WiredTransferPhotoStore?
private var userIDProvider: (() -> String)?
private var sessionNewPhotoIDs: Set<String> = []
private var wasConnected = false
private var disconnectAlertTask: Task<Void, Never>?
/// 线 ViewModel
init(
@ -258,7 +271,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
pipeline.onConnectionStateChange = { [weak self] state in
Task { @MainActor in
self?.connectionState = state
self?.handleConnectionStateChange(state)
}
}
pipeline.onTasksUpdated = { [weak self] tasks in
@ -290,15 +303,53 @@ final class WiredCameraTransferViewModel: ObservableObject {
)
let autoUpload = transferModeOption == Self.modeLiveCapture
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
refreshDeviceStorageInfo()
loadPersistedPhotos()
await pipeline.connect()
connectionState = pipeline.connectionState
wasConnected = connectionState.isConnected
}
///
func stop() async {
disconnectAlertTask?.cancel()
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) {
guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
@ -313,17 +364,113 @@ final class WiredCameraTransferViewModel: ObservableObject {
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)
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)
}
///
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))
selectUploadMode = false
selectedPhotoIDs.removeAll()
exitSelectUploadMode()
}
///
@ -339,6 +486,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
///
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) {
selectedPhotoIDs.remove(id)
} else {
@ -348,7 +497,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
///
func syncExistingPhotos() async {
await pipeline.syncExistingPhotos()
await refreshCameraFiles()
}
///
@ -356,6 +505,51 @@ final class WiredCameraTransferViewModel: ObservableObject {
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() {
guard context.albumId > 0, let photoStore else { return }
let records = photoStore.load(albumID: context.albumId)
@ -388,9 +582,15 @@ final class WiredCameraTransferViewModel: ObservableObject {
}
let capturedAt = Self.captureTimestampFormatter.string(from: Date())
let localPath = task.localPath ?? ""
let fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0
let thumbnailURL = localPath.isEmpty ? "" : "file://\(localPath)"
let storedLocalPath = task.localPath ?? ""
let localURL = task.localURL
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(
id: task.assetID,
@ -409,7 +609,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
let record = item.toRecord(
albumId: context.albumId,
userId: userID,
localPath: localPath,
localPath: storedLocalPath,
fileSizeBytes: fileSizeBytes,
remoteURL: task.remoteURL ?? ""
)

View File

@ -6,7 +6,6 @@
//
import SwiftUI
import UIKit
/// 线
struct WiredCameraTransferView: View {
@ -16,8 +15,11 @@ struct WiredCameraTransferView: View {
@Environment(\.travelAlbumAPI) private var travelAlbumAPI
@Environment(\.ossUploadService) private var ossUploadService
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: WiredCameraTransferViewModel
@State private var previewSources: [String]?
@State private var previewStartIndex = 0
init(context: WiredTransferContext) {
self.context = context
@ -30,22 +32,73 @@ struct WiredCameraTransferView: View {
}
private let tabTitles = ["全部", "已上传", "失败"]
private var scenicID: Int { accountContext.currentScenic?.id ?? 0 }
private var scenicSpotLabel: String? {
accountContext.currentScenic?.name
}
var body: some View {
VStack(spacing: 0) {
headerSection
tabSection
photoListSection
bottomBar
WiredTransferHeaderView(
albumTitle: context.albumName,
headerPhone: context.phone,
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
}
contentSection
WiredTransferBottomBar(
selectUploadMode: viewModel.selectUploadMode,
selectedCount: viewModel.selectedPhotoIDs.count,
onBatchUploadClick: {
Task {
await viewModel.onBatchUploadButtonClick(
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: scenicID
)
}
},
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick
)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("有线传图")
.navigationBarTitleDisplayMode(.inline)
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.task {
await viewModel.start(
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: accountContext.currentScenic?.id ?? 0,
scenicID: scenicID,
accountContext: accountContext
)
}
@ -55,247 +108,140 @@ struct WiredCameraTransferView: View {
.onChange(of: viewModel.errorMessage) { message in
guard let message, !message.isEmpty else { return }
toastCenter.show(message)
viewModel.clearErrorMessage()
}
.onChange(of: viewModel.connectionState) { state in
switch state {
case .connected(let name):
toastCenter.show("已连接 \(name)")
case .disconnected:
break
case .error(let message):
toastCenter.show(message)
default:
break
}
}
.confirmationDialog("指定上传", isPresented: $viewModel.showSpecifyUploadSheet, titleVisibility: .visible) {
Button("上传全部待上传") {
Task { await viewModel.batchUploadAll() }
}
Button("上传选中项") {
Task { await viewModel.uploadSelected() }
}
Button("取消", role: .cancel) {}
.alert("手机内存不足", isPresented: $viewModel.showDeviceStorageAlert) {
Button("我知道了", role: .cancel) {}
} message: {
Text("请及时清理内存")
}
}
private var headerSection: some View {
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,
.alert("相机已断开", isPresented: $viewModel.showCameraDisconnectedAlert) {
Button("我知道了", role: .cancel) {}
} message: {
Text("相机已断开,请检查 USB 连接。")
}
.sheet(isPresented: $viewModel.showSpecifyUploadSheet) {
SpecifyUploadBottomSheet(isPresented: $viewModel.showSpecifyUploadSheet) { option in
Task {
await viewModel.onSpecifyUploadOptionSelected(
option,
api: travelAlbumAPI,
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)
.padding(.vertical, AppMetrics.Spacing.medium)
.background(Color.white)
}
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)
.multilineTextAlignment(.center)
.frame(width: 72)
}
}
private var connectionColor: Color {
switch viewModel.connectionState {
case .connected: AppDesign.success
case .error: Color.red
case .searching, .connecting: AppDesign.warning
case .disconnected: AppDesign.placeholder
}
}
private var connectionText: String {
switch viewModel.connectionState {
case .connected(let name):
name
default:
viewModel.connectionState.displayText
}
}
private var tabSection: some View {
let counts = viewModel.photos.transferTabCounts
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))
.sheet(isPresented: $viewModel.showHelpDoc) {
NavigationStack {
WiredTransferHelpDocView(url: WiredCameraTransferViewModel.helpDocURL)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { viewModel.showHelpDoc = false }
}
}
.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 {
let visiblePhotos = viewModel.photos.filtered(byTabIndex: viewModel.selectedTabIndex)
return ScrollView {
if visiblePhotos.isEmpty {
AppContentUnavailableView("暂无照片", systemImage: "photo")
.frame(maxWidth: .infinity, minHeight: 260)
} else {
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(visiblePhotos) { photo in
photoRow(photo)
}
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.sheet(item: previewBinding) { item in
WiredTransferPhotoPreviewSheet(
imageSources: item.sources,
startIndex: item.startIndex,
onDismiss: { previewSources = nil }
)
}
}
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)
}
private var visiblePhotos: [WiredTransferPhotoItem] {
viewModel.visiblePhotos
}
thumbnailView(for: photo)
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 8))
private var sidebarGroups: [WiredTransferDateGroup] {
visiblePhotos.buildSidebarGroups()
}
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)
}
}
}
private var photoSections: [WiredTransferPhotoSection] {
visiblePhotos.buildPhotoSections()
}
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))
private var selectAllBar: some View {
let pendingIDs = visiblePhotos.selectablePendingPhotoIDs
let allSelected = !pendingIDs.isEmpty && pendingIDs.isSubset(of: viewModel.selectedPhotoIDs)
return WiredTransferSelectAllBar(
selectedCount: viewModel.selectedPhotoIDs.count,
selectableCount: pendingIDs.count,
allSelected: allSelected,
onToggleSelectAll: viewModel.toggleSelectAllPendingInVisible
)
}
@ViewBuilder
private func thumbnailView(for photo: WiredTransferPhotoItem) -> 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 {
placeholderThumbnail
}
private var contentSection: some View {
if visiblePhotos.isEmpty {
WiredTransferEmptyState()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
RemoteImage(urlString: photo.thumbnailURL) {
placeholderThumbnail
}
WiredTransferPhotoListPanel(
sidebarExpanded: viewModel.sidebarExpanded,
sidebarGroups: sidebarGroups,
photoSections: photoSections,
selectedTimeSlotID: viewModel.selectedTimeSlotID,
selectUploadMode: viewModel.selectUploadMode,
selectedPhotoIDs: viewModel.selectedPhotoIDs,
onToggleSidebar: viewModel.toggleSidebar,
onTimeSlotSelected: viewModel.selectTimeSlot,
onPhotoClick: openPhotoPreview,
onRetry: { id in Task { await viewModel.retryPhoto(id: id) } },
onDelete: viewModel.deletePhoto,
onTogglePhotoSelection: viewModel.togglePhotoSelection
)
}
}
private var placeholderThumbnail: some View {
Color(hex: 0xE5E7EB)
.overlay {
Image(systemName: "photo")
.foregroundStyle(AppDesign.placeholder)
private var previewBinding: Binding<PreviewPayload?> {
Binding(
get: {
guard let previewSources else { return nil }
return PreviewPayload(sources: previewSources, startIndex: previewStartIndex)
},
set: { newValue in
if newValue == nil { previewSources = nil }
}
)
}
private func statusColor(_ status: WiredTransferUploadStatus) -> Color {
switch status {
case .uploaded: AppDesign.success
case .failed: Color.red
case .uploading, .transferring: AppDesign.warning
case .pending: AppDesign.textSecondary
private func openPhotoPreview(photoID: String) {
let entries = visiblePhotos.compactMap { photo -> String? in
let url = viewModel.previewURL(for: photo.id)
return url.isEmpty ? nil : url
}
}
private var bottomBar: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Button("同步历史") {
Task { await viewModel.syncExistingPhotos() }
}
.buttonStyle(.bordered)
Button("批量上传") {
Task { await viewModel.batchUploadAll() }
}
.buttonStyle(.borderedProminent)
Button("指定上传") {
viewModel.selectUploadMode = true
viewModel.showSpecifyUploadSheet = true
}
.buttonStyle(.bordered)
guard let startIndex = visiblePhotos.firstIndex(where: { $0.id == photoID }),
!viewModel.previewURL(for: photoID).isEmpty else {
toastCenter.show("暂无法预览该照片")
return
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
.background(Color.white)
let mappedIndex = visiblePhotos.prefix(startIndex + 1).filter {
!viewModel.previewURL(for: $0.id).isEmpty
}.count - 1
previewStartIndex = max(mappedIndex, 0)
previewSources = entries
}
}
/// Sheet
private struct PreviewPayload: Identifiable {
let id = UUID()
let sources: [String]
let startIndex: Int
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}
}

View File

@ -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))
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}