
- 修改COSManagerAdapter以支持新的TCCos组件,确保与腾讯云COS的兼容性。 - 在CreateFeedFeature中新增图片上传相关状态和Action,优化图片选择与上传逻辑。 - 更新CreateFeedView以整合图片上传功能,提升用户体验。 - 在多个视图中添加键盘状态管理,改善用户交互体验。 - 新增COS相关的测试文件,确保功能的正确性和稳定性。
417 lines
13 KiB
Swift
417 lines
13 KiB
Swift
import SwiftUI
|
||
import ComposableArchitecture
|
||
import PhotosUI
|
||
|
||
// MARK: - COS 上传组件
|
||
|
||
/// COS 上传组件
|
||
/// 提供图片选择、预览和上传功能
|
||
public struct COSUploadView: View {
|
||
|
||
// MARK: - Properties
|
||
|
||
let store: StoreOf<COSFeature>
|
||
@State private var selectedImage: UIImage?
|
||
@State private var showingImagePicker = false
|
||
|
||
// MARK: - Initialization
|
||
|
||
public init(store: StoreOf<COSFeature>) {
|
||
self.store = store
|
||
}
|
||
|
||
// MARK: - Body
|
||
|
||
public var body: some View {
|
||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||
VStack(spacing: 20) {
|
||
// 图片选择区域
|
||
ImageSelectionArea(
|
||
selectedImage: $selectedImage,
|
||
showingImagePicker: $showingImagePicker
|
||
)
|
||
|
||
// 上传进度区域
|
||
if let uploadState = viewStore.uploadState,
|
||
uploadState.isUploading || uploadState.result != nil || uploadState.error != nil {
|
||
UploadProgressArea(uploadState: uploadState)
|
||
}
|
||
|
||
// 上传按钮
|
||
UploadButton(
|
||
selectedImage: selectedImage,
|
||
isUploading: viewStore.uploadState?.isUploading == true,
|
||
isServiceReady: isServiceReady(viewStore),
|
||
onUpload: {
|
||
uploadImage(viewStore)
|
||
}
|
||
)
|
||
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
.sheet(isPresented: $showingImagePicker) {
|
||
ImagePicker(selectedImage: $selectedImage)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 私有方法
|
||
|
||
/// 检查服务是否就绪
|
||
private func isServiceReady(_ viewStore: ViewStore<COSFeature.State, COSFeature.Action>) -> Bool {
|
||
let isInitialized = viewStore.configurationState?.serviceStatus.isInitialized == true
|
||
let hasValidToken = viewStore.tokenState?.currentToken?.isValid == true
|
||
return isInitialized && hasValidToken
|
||
}
|
||
|
||
/// 上传图片
|
||
private func uploadImage(_ viewStore: ViewStore<COSFeature.State, COSFeature.Action>) {
|
||
guard let image = selectedImage else { return }
|
||
|
||
let fileName = "image_\(Date().timeIntervalSince1970).jpg"
|
||
viewStore.send(.upload(.uploadUIImage(image, fileName)))
|
||
}
|
||
}
|
||
|
||
// MARK: - 图片选择区域
|
||
|
||
/// 图片选择区域组件
|
||
private struct ImageSelectionArea: View {
|
||
@Binding var selectedImage: UIImage?
|
||
@Binding var showingImagePicker: Bool
|
||
|
||
var body: some View {
|
||
VStack(spacing: 16) {
|
||
if let image = selectedImage {
|
||
// 显示选中的图片
|
||
VStack(spacing: 12) {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
.frame(maxHeight: 200)
|
||
.cornerRadius(8)
|
||
|
||
HStack(spacing: 12) {
|
||
Button("重新选择") {
|
||
showingImagePicker = true
|
||
}
|
||
.buttonStyle(.bordered)
|
||
|
||
Button("清除") {
|
||
selectedImage = nil
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.foregroundColor(.red)
|
||
}
|
||
}
|
||
} else {
|
||
// 显示选择按钮
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "photo.badge.plus")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.blue)
|
||
|
||
Text("选择图片")
|
||
.font(.headline)
|
||
|
||
Text("点击选择要上传的图片")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
|
||
Button("选择图片") {
|
||
showingImagePicker = true
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(40)
|
||
.background(Color(.systemGray6))
|
||
.cornerRadius(12)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 上传进度区域
|
||
|
||
/// 上传进度区域组件
|
||
private struct UploadProgressArea: View {
|
||
let uploadState: UploadState
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Image(systemName: progressIcon)
|
||
.foregroundColor(progressColor)
|
||
|
||
Text("上传进度")
|
||
.font(.headline)
|
||
|
||
Spacer()
|
||
|
||
if uploadState.isUploading {
|
||
Button("取消") {
|
||
// TODO: 实现取消上传
|
||
}
|
||
.font(.caption)
|
||
.foregroundColor(.red)
|
||
}
|
||
}
|
||
|
||
if let task = uploadState.currentTask {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("文件: \(task.fileName)")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
|
||
Text("大小: \(ByteCountFormatter.string(fromByteCount: Int64(task.imageData.count), countStyle: .file))")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
|
||
if uploadState.isUploading {
|
||
VStack(spacing: 8) {
|
||
ProgressView(value: uploadState.progress)
|
||
.progressViewStyle(LinearProgressViewStyle(tint: .blue))
|
||
|
||
HStack {
|
||
Text("\(Int(uploadState.progress * 100))%")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
|
||
Spacer()
|
||
|
||
Text(estimatedTimeRemaining)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let result = uploadState.result {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.foregroundColor(.green)
|
||
|
||
Text("上传成功")
|
||
.font(.headline)
|
||
.foregroundColor(.green)
|
||
}
|
||
|
||
Text("URL: \(result)")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(3)
|
||
|
||
Button("复制链接") {
|
||
UIPasteboard.general.string = result
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.font(.caption)
|
||
}
|
||
.padding()
|
||
.background(Color(.systemGreen).opacity(0.1))
|
||
.cornerRadius(8)
|
||
}
|
||
|
||
if let error = uploadState.error {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundColor(.red)
|
||
|
||
Text("上传失败")
|
||
.font(.headline)
|
||
.foregroundColor(.red)
|
||
}
|
||
|
||
Text(error)
|
||
.font(.caption)
|
||
.foregroundColor(.red)
|
||
}
|
||
.padding()
|
||
.background(Color(.systemRed).opacity(0.1))
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color(.systemGray6))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
private var progressIcon: String {
|
||
if uploadState.isUploading {
|
||
return "arrow.up.circle.fill"
|
||
} else if uploadState.result != nil {
|
||
return "checkmark.circle.fill"
|
||
} else if uploadState.error != nil {
|
||
return "xmark.circle.fill"
|
||
} else {
|
||
return "arrow.up.circle"
|
||
}
|
||
}
|
||
|
||
private var progressColor: Color {
|
||
if uploadState.isUploading {
|
||
return .blue
|
||
} else if uploadState.result != nil {
|
||
return .green
|
||
} else if uploadState.error != nil {
|
||
return .red
|
||
} else {
|
||
return .gray
|
||
}
|
||
}
|
||
|
||
private var estimatedTimeRemaining: String {
|
||
// 简单的剩余时间估算
|
||
let remainingProgress = 1.0 - uploadState.progress
|
||
if remainingProgress > 0 {
|
||
let estimatedSeconds = Int(remainingProgress * 30) // 假设总时间30秒
|
||
return "预计剩余 \(estimatedSeconds) 秒"
|
||
} else {
|
||
return "即将完成"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 上传按钮
|
||
|
||
/// 上传按钮组件
|
||
private struct UploadButton: View {
|
||
let selectedImage: UIImage?
|
||
let isUploading: Bool
|
||
let isServiceReady: Bool
|
||
let onUpload: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 12) {
|
||
if !isServiceReady {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle")
|
||
.foregroundColor(.orange)
|
||
|
||
Text("服务未就绪")
|
||
.font(.headline)
|
||
.foregroundColor(.orange)
|
||
|
||
Text("请确保 Token 有效且服务已初始化")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.padding()
|
||
.background(Color(.systemOrange).opacity(0.1))
|
||
.cornerRadius(8)
|
||
}
|
||
|
||
Button(action: onUpload) {
|
||
HStack {
|
||
if isUploading {
|
||
ProgressView()
|
||
.scaleEffect(0.8)
|
||
.tint(.white)
|
||
} else {
|
||
Image(systemName: "arrow.up.circle.fill")
|
||
}
|
||
|
||
Text(buttonTitle)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding()
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(selectedImage == nil || isUploading || !isServiceReady)
|
||
}
|
||
}
|
||
|
||
private var buttonTitle: String {
|
||
if isUploading {
|
||
return "上传中..."
|
||
} else if selectedImage == nil {
|
||
return "请先选择图片"
|
||
} else if !isServiceReady {
|
||
return "服务未就绪"
|
||
} else {
|
||
return "开始上传"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 图片选择器
|
||
|
||
/// 图片选择器组件
|
||
private struct ImagePicker: UIViewControllerRepresentable {
|
||
@Binding var selectedImage: UIImage?
|
||
@Environment(\.presentationMode) var presentationMode
|
||
|
||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||
var configuration = PHPickerConfiguration()
|
||
configuration.filter = .images
|
||
configuration.selectionLimit = 1
|
||
|
||
let picker = PHPickerViewController(configuration: configuration)
|
||
picker.delegate = context.coordinator
|
||
return picker
|
||
}
|
||
|
||
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(self)
|
||
}
|
||
|
||
class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
||
let parent: ImagePicker
|
||
|
||
init(_ parent: ImagePicker) {
|
||
self.parent = parent
|
||
}
|
||
|
||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||
parent.presentationMode.wrappedValue.dismiss()
|
||
|
||
guard let provider = results.first?.itemProvider else { return }
|
||
|
||
if provider.canLoadObject(ofClass: UIImage.self) {
|
||
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
|
||
// 在回调中立即进行类型转换,避免数据竞争
|
||
guard let uiImage = image as? UIImage else { return }
|
||
|
||
// 在主线程中安全地设置图片
|
||
DispatchQueue.main.async {
|
||
guard let self = self else { return }
|
||
self.parent.selectedImage = uiImage
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 扩展
|
||
|
||
extension COSServiceStatus {
|
||
var isInitialized: Bool {
|
||
switch self {
|
||
case .initialized:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 预览
|
||
|
||
#Preview {
|
||
COSUploadView(
|
||
store: Store(
|
||
initialState: COSFeature.State(),
|
||
reducer: { COSFeature() }
|
||
)
|
||
)
|
||
} |