对齐有线传图页 Android UI,并改用相对路径持久化本地照片。
还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user