feat: 新增设置页面及相关功能实现
- 创建SettingPage视图,包含用户信息管理、头像设置、昵称编辑等功能。 - 实现SettingViewModel,处理设置页面的业务逻辑,包括头像上传、昵称更新等。 - 添加相机和相册选择功能,支持头像更换。 - 更新MainPage和MainViewModel,添加导航逻辑以支持设置页面的访问。 - 完善本地化支持,确保多语言兼容性。 - 新增相关测试建议,确保功能完整性和用户体验。
This commit is contained in:
179
issues/SettingPage实现.md
Normal file
179
issues/SettingPage实现.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# SettingPage 实现文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
成功创建了 MVVM 版本的 SettingPage,参照 AppSettingView 的 UI 设计,实现了完整的设置页面功能。
|
||||||
|
|
||||||
|
## 实现文件
|
||||||
|
|
||||||
|
### 1. SettingViewModel.swift
|
||||||
|
- **位置**: `yana/MVVM/ViewModel/SettingViewModel.swift`
|
||||||
|
- **功能**: 设置页面的业务逻辑处理
|
||||||
|
- **主要特性**:
|
||||||
|
- 用户信息管理(头像、昵称)
|
||||||
|
- 图片选择和处理(相机、相册)
|
||||||
|
- 头像上传到腾讯云 COS
|
||||||
|
- 昵称编辑和更新
|
||||||
|
- 各种设置操作(清除缓存、检查更新等)
|
||||||
|
- 退出登录功能
|
||||||
|
- WebView 导航状态管理
|
||||||
|
|
||||||
|
### 2. SettingPage.swift
|
||||||
|
- **位置**: `yana/MVVM/View/SettingPage.swift`
|
||||||
|
- **功能**: 设置页面的 UI 界面
|
||||||
|
- **主要特性**:
|
||||||
|
- 参照 AppSettingView 的 UI 布局
|
||||||
|
- 头像设置区域(支持点击更换)
|
||||||
|
- 个人信息设置区域(昵称编辑)
|
||||||
|
- 其他设置区域(各种设置选项)
|
||||||
|
- 退出登录区域
|
||||||
|
- 各种弹窗和确认对话框
|
||||||
|
- WebView 集成(用户协议、隐私政策等)
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
### 头像管理
|
||||||
|
- 支持从相机拍照
|
||||||
|
- 支持从相册选择
|
||||||
|
- 自动上传到腾讯云 COS
|
||||||
|
- 实时显示上传状态
|
||||||
|
|
||||||
|
### 昵称编辑
|
||||||
|
- 弹窗式编辑界面
|
||||||
|
- 字符长度限制(15字符)
|
||||||
|
- 实时验证和更新
|
||||||
|
|
||||||
|
### 设置选项
|
||||||
|
- 个人信息与权限
|
||||||
|
- 帮助
|
||||||
|
- 清除缓存
|
||||||
|
- 检查更新
|
||||||
|
- 注销账号
|
||||||
|
- 关于我们
|
||||||
|
|
||||||
|
### 退出登录
|
||||||
|
- 确认对话框
|
||||||
|
- 清除所有认证信息
|
||||||
|
- 回调到主页面
|
||||||
|
|
||||||
|
## 导航集成
|
||||||
|
|
||||||
|
### MainPage 修改
|
||||||
|
- 添加了 `showSettingPage` 状态
|
||||||
|
- 在 "Me" 标签页的右上角设置按钮点击时导航到 SettingPage
|
||||||
|
- 使用 `navigationDestination` 进行导航
|
||||||
|
|
||||||
|
### MainViewModel 修改
|
||||||
|
- 添加了 `showSettingPage` 发布属性
|
||||||
|
- 修改了 `onTopRightButtonTapped` 方法,在 "Me" 标签页时显示设置页面
|
||||||
|
|
||||||
|
## 技术特点
|
||||||
|
|
||||||
|
### MVVM 架构
|
||||||
|
- 清晰的视图和视图模型分离
|
||||||
|
- 使用 `@Published` 属性进行状态管理
|
||||||
|
- 异步操作使用 `Task` 和 `@MainActor`
|
||||||
|
|
||||||
|
### 图片处理
|
||||||
|
- 使用 `PhotosUI` 进行图片选择
|
||||||
|
- 自定义 `CameraPicker` 进行拍照
|
||||||
|
- 集成腾讯云 COS 进行图片上传
|
||||||
|
|
||||||
|
### 本地化支持
|
||||||
|
- 使用 `LocalizedString` 进行多语言支持
|
||||||
|
- 添加了缺失的本地化字符串
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
- 完善的错误状态管理
|
||||||
|
- 用户友好的错误提示
|
||||||
|
- 网络请求失败处理
|
||||||
|
|
||||||
|
## 依赖关系
|
||||||
|
|
||||||
|
### 内部依赖
|
||||||
|
- `UserInfoManager`: 用户信息管理
|
||||||
|
- `COSManagerAdapter`: 图片上传服务
|
||||||
|
- `APIService`: 网络请求服务
|
||||||
|
- `LogManager`: 日志管理
|
||||||
|
|
||||||
|
### 外部依赖
|
||||||
|
- `SwiftUI`: UI 框架
|
||||||
|
- `PhotosUI`: 图片选择
|
||||||
|
- `UIKit`: 相机功能
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
1. **基本功能测试**
|
||||||
|
- 页面加载和显示
|
||||||
|
- 导航和返回
|
||||||
|
- 用户信息显示
|
||||||
|
|
||||||
|
2. **头像功能测试**
|
||||||
|
- 相机拍照
|
||||||
|
- 相册选择
|
||||||
|
- 图片上传
|
||||||
|
- 上传状态显示
|
||||||
|
|
||||||
|
3. **昵称编辑测试**
|
||||||
|
- 弹窗显示
|
||||||
|
- 字符输入和限制
|
||||||
|
- 保存和更新
|
||||||
|
|
||||||
|
4. **设置选项测试**
|
||||||
|
- 各种设置项点击
|
||||||
|
- WebView 页面显示
|
||||||
|
- 退出登录流程
|
||||||
|
|
||||||
|
5. **错误处理测试**
|
||||||
|
- 网络异常情况
|
||||||
|
- 图片上传失败
|
||||||
|
- 用户信息获取失败
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **权限要求**
|
||||||
|
- 相机权限(用于拍照)
|
||||||
|
- 相册权限(用于选择图片)
|
||||||
|
|
||||||
|
2. **网络依赖**
|
||||||
|
- 图片上传需要网络连接
|
||||||
|
- 用户信息更新需要网络连接
|
||||||
|
|
||||||
|
3. **存储依赖**
|
||||||
|
- 用户信息存储在 Keychain
|
||||||
|
- 图片缓存管理
|
||||||
|
|
||||||
|
## 后续优化
|
||||||
|
|
||||||
|
1. **性能优化**
|
||||||
|
- 图片压缩优化
|
||||||
|
- 缓存策略优化
|
||||||
|
|
||||||
|
2. **用户体验**
|
||||||
|
- 添加加载动画
|
||||||
|
- 优化错误提示
|
||||||
|
|
||||||
|
3. **功能扩展**
|
||||||
|
- 添加更多设置选项
|
||||||
|
- 支持更多个人信息字段
|
||||||
|
|
||||||
|
## 文件修改记录
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
- `yana/MVVM/ViewModel/SettingViewModel.swift`
|
||||||
|
- `yana/MVVM/View/SettingPage.swift`
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
- `yana/MVVM/MainPage.swift`: 添加导航逻辑
|
||||||
|
- `yana/MVVM/ViewModel/MainViewModel.swift`: 添加设置页面状态
|
||||||
|
- `yana/MVVM/CommonComponents.swift`: 添加 AppImageSource 枚举
|
||||||
|
- `yana/Resources/zh-Hans.lproj/Localizable.strings`: 添加缺失的本地化字符串
|
||||||
|
- `yana/Resources/en.lproj/Localizable.strings`: 添加缺失的本地化字符串
|
||||||
|
|
||||||
|
### 重构文件
|
||||||
|
- `yana/MVVM/ViewModel/SettingViewModel.swift`: 移除重复的 AppImageSource 定义
|
||||||
|
- `yana/Features/AppSettingFeature.swift`: 移除重复的 AppImageSource 定义
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
成功实现了完整的 MVVM 版本 SettingPage,功能完整,代码结构清晰,符合项目的架构规范。所有功能都经过了仔细的设计和实现,确保了良好的用户体验和代码质量。
|
21
yana/Assets.xcassets/Home/setting icon.imageset/Contents.json
vendored
Normal file
21
yana/Assets.xcassets/Home/setting icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "切图 12@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Assets.xcassets/Home/setting icon.imageset/切图 12@3x.png
vendored
Normal file
BIN
yana/Assets.xcassets/Home/setting icon.imageset/切图 12@3x.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
@@ -3,12 +3,6 @@ import ComposableArchitecture
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
|
||||||
// 图片源选择枚举
|
|
||||||
enum AppImageSource: Equatable {
|
|
||||||
case camera
|
|
||||||
case photoLibrary
|
|
||||||
}
|
|
||||||
|
|
||||||
@Reducer
|
@Reducer
|
||||||
struct AppSettingFeature {
|
struct AppSettingFeature {
|
||||||
@ObservableState
|
@ObservableState
|
||||||
|
@@ -1,5 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - App Image Source Enum
|
||||||
|
enum AppImageSource: Equatable {
|
||||||
|
case camera
|
||||||
|
case photoLibrary
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 背景视图组件
|
// MARK: - 背景视图组件
|
||||||
struct LoginBackgroundView: View {
|
struct LoginBackgroundView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -180,6 +186,87 @@ struct LoginButtonView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 设置行组件
|
||||||
|
struct SettingRow: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let action: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
action?()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !subtitle.isEmpty {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if action != nil {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.disabled(action == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Camera Picker
|
||||||
|
struct CameraPicker: UIViewControllerRepresentable {
|
||||||
|
let onImagePicked: (UIImage?) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = .camera
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(onImagePicked: onImagePicked)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
let onImagePicked: (UIImage?) -> Void
|
||||||
|
|
||||||
|
init(onImagePicked: @escaping (UIImage?) -> Void) {
|
||||||
|
self.onImagePicked = onImagePicked
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||||
|
if let image = info[.originalImage] as? UIImage {
|
||||||
|
onImagePicked(image)
|
||||||
|
} else {
|
||||||
|
onImagePicked(nil)
|
||||||
|
}
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
onImagePicked(nil)
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
LoginBackgroundView()
|
LoginBackgroundView()
|
||||||
|
@@ -1,122 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
|
||||||
|
|
||||||
// MARK: - IDLogin ViewModel
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class IDLoginViewModel: ObservableObject {
|
|
||||||
// MARK: - Published Properties
|
|
||||||
@Published var userID: String = ""
|
|
||||||
@Published var password: String = ""
|
|
||||||
@Published var isPasswordVisible: Bool = false
|
|
||||||
@Published var isLoading: Bool = false
|
|
||||||
@Published var errorMessage: String?
|
|
||||||
@Published var showRecoverPassword: Bool = false
|
|
||||||
@Published var loginStep: LoginStep = .input
|
|
||||||
|
|
||||||
// MARK: - Callbacks
|
|
||||||
var onBack: (() -> Void)?
|
|
||||||
var onLoginSuccess: (() -> Void)?
|
|
||||||
|
|
||||||
// MARK: - Private Properties
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
// MARK: - Enums
|
|
||||||
enum LoginStep: Equatable {
|
|
||||||
case input
|
|
||||||
case completed
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
|
||||||
var isLoginButtonEnabled: Bool {
|
|
||||||
return !isLoading && !userID.isEmpty && !password.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
func onBackTapped() {
|
|
||||||
onBack?()
|
|
||||||
}
|
|
||||||
|
|
||||||
func onLoginTapped() {
|
|
||||||
guard isLoginButtonEnabled else { return }
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
errorMessage = nil
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let result = try await performLogin()
|
|
||||||
await MainActor.run {
|
|
||||||
self.handleLoginResult(result)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
self.handleLoginError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func onRecoverPasswordTapped() {
|
|
||||||
showRecoverPassword = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func onRecoverPasswordBack() {
|
|
||||||
showRecoverPassword = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
private func performLogin() async throws -> Bool {
|
|
||||||
// 使用LoginHelper创建登录请求(包含DES加密)
|
|
||||||
guard let loginRequest = await LoginHelper.createIDLoginRequest(
|
|
||||||
userID: userID,
|
|
||||||
password: password
|
|
||||||
) else {
|
|
||||||
throw APIError.custom("DES加密失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
let apiService = LiveAPIService()
|
|
||||||
let response: IDLoginResponse = try await apiService.request(loginRequest)
|
|
||||||
|
|
||||||
if response.code == 200, let data = response.data {
|
|
||||||
// 保存用户信息(如果API返回了用户信息)
|
|
||||||
if let userInfo = data.userInfo {
|
|
||||||
await UserInfoManager.saveUserInfo(userInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建并保存账户模型
|
|
||||||
guard let accountModel = AccountModel.from(loginData: data) else {
|
|
||||||
throw APIError.custom("账户信息无效")
|
|
||||||
}
|
|
||||||
await UserInfoManager.saveAccountModel(accountModel)
|
|
||||||
|
|
||||||
// 获取用户详细信息(如果API没有返回用户信息)
|
|
||||||
if data.userInfo == nil, let userInfo = await UserInfoManager.fetchUserInfoFromServer(
|
|
||||||
uid: String(data.uid ?? 0),
|
|
||||||
apiService: apiService
|
|
||||||
) {
|
|
||||||
await UserInfoManager.saveUserInfo(userInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
throw APIError.custom(response.message ?? "Login failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleLoginResult(_ success: Bool) {
|
|
||||||
isLoading = false
|
|
||||||
if success {
|
|
||||||
loginStep = .completed
|
|
||||||
onLoginSuccess?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleLoginError(_ error: Error) {
|
|
||||||
isLoading = false
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - IDLogin View
|
// MARK: - IDLogin View
|
||||||
|
|
||||||
@@ -184,7 +66,7 @@ struct IDLoginPage: View {
|
|||||||
|
|
||||||
// 登录按钮
|
// 登录按钮
|
||||||
LoginButtonView(
|
LoginButtonView(
|
||||||
isLoading: viewModel.isLoading,
|
isLoading: viewModel.isLoading || viewModel.isTicketLoading,
|
||||||
isEnabled: viewModel.isLoginButtonEnabled,
|
isEnabled: viewModel.isLoginButtonEnabled,
|
||||||
onTap: {
|
onTap: {
|
||||||
viewModel.onLoginTapped()
|
viewModel.onLoginTapped()
|
||||||
@@ -192,6 +74,23 @@ struct IDLoginPage: View {
|
|||||||
)
|
)
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
|
|
||||||
|
// Ticket加载状态提示
|
||||||
|
if viewModel.isTicketLoading {
|
||||||
|
Text("正在获取会话票据...")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误信息显示
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,10 +107,6 @@ struct IDLoginPage: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.onBack = onBack
|
viewModel.onBack = onBack
|
||||||
viewModel.onLoginSuccess = onLoginSuccess
|
viewModel.onLoginSuccess = onLoginSuccess
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据")
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.loginStep) { _, newStep in
|
.onChange(of: viewModel.loginStep) { _, newStep in
|
||||||
debugInfoSync("🔄 IDLoginView: loginStep 变化为 \(newStep)")
|
debugInfoSync("🔄 IDLoginView: loginStep 变化为 \(newStep)")
|
||||||
@@ -222,9 +117,9 @@ struct IDLoginPage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
//#Preview {
|
||||||
IDLoginPage(
|
// IDLoginPage(
|
||||||
onBack: {},
|
// onBack: {},
|
||||||
onLoginSuccess: {}
|
// onLoginSuccess: {}
|
||||||
)
|
// )
|
||||||
}
|
//}
|
||||||
|
@@ -1,57 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Main ViewModel
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class MainViewModel: ObservableObject {
|
|
||||||
// MARK: - Published Properties
|
|
||||||
@Published var selectedTab: Tab = .feed
|
|
||||||
@Published var isLoggedOut: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Callbacks
|
|
||||||
var onLogout: (() -> Void)?
|
|
||||||
|
|
||||||
// MARK: - Enums
|
|
||||||
enum Tab: String, CaseIterable {
|
|
||||||
case feed = "feed"
|
|
||||||
case me = "me"
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .feed:
|
|
||||||
return "Feed"
|
|
||||||
case .me:
|
|
||||||
return "Me"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconName: String {
|
|
||||||
switch self {
|
|
||||||
case .feed:
|
|
||||||
return "list.bullet"
|
|
||||||
case .me:
|
|
||||||
return "person.circle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
func onAppear() {
|
|
||||||
debugInfoSync("🚀 MainView onAppear")
|
|
||||||
debugInfoSync(" 当前selectedTab: \(selectedTab)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func onTabChanged(_ newTab: Tab) {
|
|
||||||
selectedTab = newTab
|
|
||||||
debugInfoSync("🔄 MainView selectedTab changed: \(newTab)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func onLogoutTapped() {
|
|
||||||
isLoggedOut = true
|
|
||||||
onLogout?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Main View
|
// MARK: - Main View
|
||||||
|
|
||||||
struct MainPage: View {
|
struct MainPage: View {
|
||||||
@@ -59,27 +7,53 @@ struct MainPage: View {
|
|||||||
let onLogout: () -> Void
|
let onLogout: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack(path: $viewModel.navigationPath) {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
// 背景图片
|
// 背景图片
|
||||||
LoginBackgroundView()
|
LoginBackgroundView()
|
||||||
|
|
||||||
// 主内容
|
// 主内容
|
||||||
mainContentView(geometry: geometry)
|
mainContentView(geometry: geometry)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding(.bottom, 80) // 为底部导航栏留出空间
|
|
||||||
|
|
||||||
// 底部导航栏 - 固定在底部
|
|
||||||
VStack {
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
// 右上角按钮
|
||||||
|
topRightButton
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
// 底部导航栏
|
||||||
bottomTabView
|
bottomTabView
|
||||||
|
.frame(height: 80)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationDestination(for: String.self) { destination in
|
||||||
|
switch destination {
|
||||||
|
case "setting":
|
||||||
|
SettingPage(
|
||||||
|
onBack: {
|
||||||
|
viewModel.navigationPath.removeLast()
|
||||||
|
},
|
||||||
|
onLogout: {
|
||||||
|
viewModel.onLogoutTapped()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.onLogout = onLogout
|
viewModel.onLogout = onLogout
|
||||||
|
viewModel.onAddButtonTapped = {
|
||||||
|
// TODO: 处理添加按钮点击事件
|
||||||
|
debugInfoSync("➕ 添加按钮被点击")
|
||||||
|
}
|
||||||
viewModel.onAppear()
|
viewModel.onAppear()
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.isLoggedOut) { _, isLoggedOut in
|
.onChange(of: viewModel.isLoggedOut) { _, isLoggedOut in
|
||||||
@@ -95,7 +69,7 @@ struct MainPage: View {
|
|||||||
Group {
|
Group {
|
||||||
switch viewModel.selectedTab {
|
switch viewModel.selectedTab {
|
||||||
case .feed:
|
case .feed:
|
||||||
TempFeedListPage()
|
MomentListHomePage()
|
||||||
case .me:
|
case .me:
|
||||||
TempMePage()
|
TempMePage()
|
||||||
}
|
}
|
||||||
@@ -119,7 +93,7 @@ struct MainPage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
@@ -127,24 +101,39 @@ struct MainPage: View {
|
|||||||
.fill(Color.black.opacity(0.3))
|
.fill(Color.black.opacity(0.3))
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
)
|
)
|
||||||
}
|
.safeAreaInset(edge: .bottom) {
|
||||||
}
|
Color.clear.frame(height: 0)
|
||||||
|
|
||||||
// MARK: - FeedListView (简化版本)
|
|
||||||
|
|
||||||
struct TempFeedListPage: View {
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
Text("Feed List")
|
|
||||||
.font(.title)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Text("This is a simplified FeedListView")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 右上角按钮
|
||||||
|
private var topRightButton: some View {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.onTopRightButtonTapped()
|
||||||
|
}) {
|
||||||
|
Group {
|
||||||
|
switch viewModel.selectedTab {
|
||||||
|
case .feed:
|
||||||
|
Image("add icon")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
case .me:
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
.font(.system(size: 24, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - MeView (简化版本)
|
// MARK: - MeView (简化版本)
|
||||||
|
|
||||||
@@ -162,6 +151,6 @@ struct TempMePage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
//#Preview {
|
||||||
MainPage(onLogout: {})
|
// MainPage(onLogout: {})
|
||||||
}
|
//}
|
||||||
|
74
yana/MVVM/View/MomentListHomePage.swift
Normal file
74
yana/MVVM/View/MomentListHomePage.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - BackgroundView
|
||||||
|
struct MomentListBackgroundView: View {
|
||||||
|
var body: some View {
|
||||||
|
Image("bg")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.clipped()
|
||||||
|
.ignoresSafeArea(.all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MomentListHomePage
|
||||||
|
struct MomentListHomePage: View {
|
||||||
|
@StateObject private var viewModel = MomentListHomeViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
// 背景
|
||||||
|
MomentListBackgroundView()
|
||||||
|
|
||||||
|
VStack(alignment: .center, spacing: 0) {
|
||||||
|
// 标题
|
||||||
|
Text(LocalizedString("feedList.title", comment: "Enjoy your Life Time"))
|
||||||
|
.font(.system(size: 22, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.top, 60)
|
||||||
|
|
||||||
|
// Volume 图标
|
||||||
|
Image("Volume")
|
||||||
|
.frame(width: 56, height: 41)
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
// 标语
|
||||||
|
Text(LocalizedString("feedList.slogan", comment: "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable."))
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
.padding(.horizontal, 30)
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
|
// 动态列表内容
|
||||||
|
if !viewModel.moments.isEmpty {
|
||||||
|
// 显示第一个数据来测试效果
|
||||||
|
MomentListItem(moment: viewModel.moments[0])
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
} else if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else if let error = viewModel.error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onAppear {
|
||||||
|
viewModel.onAppear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
247
yana/MVVM/View/MomentListItem.swift
Normal file
247
yana/MVVM/View/MomentListItem.swift
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - MomentListItem
|
||||||
|
struct MomentListItem: View {
|
||||||
|
let moment: MomentsInfo
|
||||||
|
|
||||||
|
init(moment: MomentsInfo) {
|
||||||
|
self.moment = moment
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// 背景层
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.clear)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
|
||||||
|
|
||||||
|
// 内容层
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
// 用户信息
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
// 头像
|
||||||
|
CachedAsyncImage(url: moment.avatar) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.overlay(
|
||||||
|
Text(String(moment.nick.prefix(1)))
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(moment.nick)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
UserIDDisplay(uid: moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
// 时间
|
||||||
|
Text(formatDisplayTime(moment.publishTime))
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.white.opacity(0.15))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态内容
|
||||||
|
if !moment.content.isEmpty {
|
||||||
|
Text(moment.content)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(.leading, 40 + 8) // 与用户名左边对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片网格
|
||||||
|
if let images = moment.dynamicResList, !images.isEmpty {
|
||||||
|
MomentImageGrid(images: images)
|
||||||
|
.padding(.leading, 40 + 8)
|
||||||
|
.padding(.bottom, images.count == 2 ? 30 : 0) // 两张图片时增加底部间距
|
||||||
|
}
|
||||||
|
|
||||||
|
// 互动按钮
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Like 按钮与用户名左侧对齐
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
Text("\(moment.likeCount)")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||||
|
.padding(.leading, 40 + 8) // 与用户名左侧对齐(头像宽度 + 间距)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 时间显示逻辑
|
||||||
|
private func formatDisplayTime(_ timestamp: Int) -> String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
let now = Date()
|
||||||
|
let interval = now.timeIntervalSince(date)
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
if interval < 60 {
|
||||||
|
return "刚刚"
|
||||||
|
} else if interval < 3600 {
|
||||||
|
return "\(Int(interval / 60))分钟前"
|
||||||
|
} else {
|
||||||
|
return "\(Int(interval / 3600))小时前"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formatter.dateFormat = "MM/dd"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 图片网格组件
|
||||||
|
struct MomentImageGrid: View {
|
||||||
|
let images: [MomentsPicture]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let availableWidth = max(geometry.size.width, 1)
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
if availableWidth < 10 {
|
||||||
|
Color.clear.frame(height: 1)
|
||||||
|
} else {
|
||||||
|
switch images.count {
|
||||||
|
case 1:
|
||||||
|
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
MomentSquareImageView(image: images[0], size: imageSize)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
let imageSize: CGFloat = (availableWidth - spacing) / 2
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
MomentSquareImageView(image: images[0], size: imageSize)
|
||||||
|
MomentSquareImageView(image: images[1], size: imageSize)
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
ForEach(Array(images.prefix(3).enumerated()), id: \.element.id) { _, image in
|
||||||
|
MomentSquareImageView(image: image, size: imageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||||
|
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
||||||
|
LazyVGrid(columns: columns, spacing: spacing) {
|
||||||
|
ForEach(Array(images.prefix(9).enumerated()), id: \.element.id) { _, image in
|
||||||
|
MomentSquareImageView(image: image, size: imageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: calculateGridHeight())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateGridHeight() -> CGFloat {
|
||||||
|
switch images.count {
|
||||||
|
case 1:
|
||||||
|
return 200
|
||||||
|
case 2:
|
||||||
|
return 120
|
||||||
|
case 3:
|
||||||
|
return 100
|
||||||
|
case 4...6:
|
||||||
|
return 216
|
||||||
|
default:
|
||||||
|
return 340
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 正方形图片视图组件
|
||||||
|
struct MomentSquareImageView: View {
|
||||||
|
let image: MomentsPicture
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let safeSize = size.isFinite && size > 0 ? size : 100
|
||||||
|
CachedAsyncImage(url: image.resUrl ?? "") { imageView in
|
||||||
|
imageView
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.overlay(
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: safeSize, height: safeSize)
|
||||||
|
.clipped()
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
// 创建测试数据
|
||||||
|
let testMoment = MomentsInfo(
|
||||||
|
dynamicId: 1,
|
||||||
|
uid: 123456,
|
||||||
|
nick: "测试用户",
|
||||||
|
avatar: "",
|
||||||
|
type: 0,
|
||||||
|
content: "这是一条测试动态内容,用来测试 MomentListItem 的显示效果。",
|
||||||
|
likeCount: 42,
|
||||||
|
isLike: false,
|
||||||
|
commentCount: 5,
|
||||||
|
publishTime: Int(Date().timeIntervalSince1970 * 1000),
|
||||||
|
worldId: 1,
|
||||||
|
status: 1,
|
||||||
|
playCount: nil,
|
||||||
|
dynamicResList: [
|
||||||
|
MomentsPicture(id: 1, resUrl: "https://picsum.photos/300/300", format: "jpg", width: 300, height: 300, resDuration: nil),
|
||||||
|
MomentsPicture(id: 2, resUrl: "https://picsum.photos/301/301", format: "jpg", width: 301, height: 301, resDuration: nil)
|
||||||
|
],
|
||||||
|
gender: nil,
|
||||||
|
squareTop: nil,
|
||||||
|
topicTop: nil,
|
||||||
|
newUser: nil,
|
||||||
|
defUser: nil,
|
||||||
|
scene: nil,
|
||||||
|
userVipInfoVO: nil,
|
||||||
|
headwearPic: nil,
|
||||||
|
headwearEffect: nil,
|
||||||
|
headwearType: nil,
|
||||||
|
headwearName: nil,
|
||||||
|
headwearId: nil,
|
||||||
|
experLevelPic: nil,
|
||||||
|
charmLevelPic: nil,
|
||||||
|
isCustomWord: nil,
|
||||||
|
labelList: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
MomentListItem(moment: testMoment)
|
||||||
|
.padding()
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
358
yana/MVVM/View/SettingPage.swift
Normal file
358
yana/MVVM/View/SettingPage.swift
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// MARK: - Setting Page
|
||||||
|
|
||||||
|
struct SettingPage: View {
|
||||||
|
@StateObject private var viewModel = SettingViewModel()
|
||||||
|
let onBack: () -> Void
|
||||||
|
let onLogout: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
// 背景颜色
|
||||||
|
Color(hex: 0x0C0527)
|
||||||
|
.ignoresSafeArea(.all)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 顶部导航栏
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.onBackTapped()
|
||||||
|
}) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 24, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(LocalizedString("appSetting.title", comment: "编辑"))
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 占位,保持标题居中
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
// 主要内容区域
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 头像设置区域
|
||||||
|
avatarSection()
|
||||||
|
.padding(.top, 20)
|
||||||
|
|
||||||
|
// 个人信息设置区域
|
||||||
|
personalInfoSection()
|
||||||
|
.padding(.top, 30)
|
||||||
|
|
||||||
|
// 其他设置区域
|
||||||
|
otherSettingsSection()
|
||||||
|
.padding(.top, 20)
|
||||||
|
|
||||||
|
Spacer(minLength: 40)
|
||||||
|
|
||||||
|
// 退出登录按钮
|
||||||
|
logoutSection()
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.onAppear {
|
||||||
|
viewModel.onBack = onBack
|
||||||
|
viewModel.onLogout = onLogout
|
||||||
|
viewModel.onAppear()
|
||||||
|
}
|
||||||
|
// 图片源选择 ActionSheet
|
||||||
|
.confirmationDialog(
|
||||||
|
"请选择图片来源",
|
||||||
|
isPresented: $viewModel.showImageSourceActionSheet,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(LocalizedString("app_settings.take_photo", comment: "拍照")) {
|
||||||
|
viewModel.selectImageSource(.camera)
|
||||||
|
}
|
||||||
|
Button(LocalizedString("app_settings.select_from_album", comment: "从相册选择")) {
|
||||||
|
viewModel.selectImageSource(.photoLibrary)
|
||||||
|
}
|
||||||
|
Button(LocalizedString("common.cancel", comment: "取消"), role: .cancel) { }
|
||||||
|
}
|
||||||
|
// 相机选择器
|
||||||
|
.sheet(isPresented: $viewModel.showCamera) {
|
||||||
|
CameraPicker { image in
|
||||||
|
guard let image = image else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModel.onCameraImagePicked(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 相册选择器
|
||||||
|
.photosPicker(
|
||||||
|
isPresented: $viewModel.showPhotoPicker,
|
||||||
|
selection: $viewModel.selectedPhotoItems,
|
||||||
|
maxSelectionCount: 1,
|
||||||
|
matching: .images
|
||||||
|
)
|
||||||
|
// 昵称编辑弹窗
|
||||||
|
.alert(LocalizedString("appSetting.nickname", comment: "编辑昵称"), isPresented: $viewModel.isEditingNickname) {
|
||||||
|
TextField(LocalizedString("appSetting.nickname", comment: "请输入昵称"), text: $viewModel.nicknameInput)
|
||||||
|
.onChange(of: viewModel.nicknameInput) { _, newValue in
|
||||||
|
viewModel.onNicknameInputChanged(newValue)
|
||||||
|
}
|
||||||
|
Button(LocalizedString("common.cancel", comment: "取消")) {
|
||||||
|
viewModel.isEditingNickname = false
|
||||||
|
}
|
||||||
|
Button(LocalizedString("common.confirm", comment: "确认")) {
|
||||||
|
viewModel.onNicknameEditConfirmed()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(LocalizedString("appSetting.nickname", comment: "请输入新的昵称"))
|
||||||
|
}
|
||||||
|
// 登出确认弹窗
|
||||||
|
.alert(LocalizedString("appSetting.logoutConfirmation.title", comment: "确认退出"), isPresented: $viewModel.showLogoutConfirmation) {
|
||||||
|
Button(LocalizedString("common.cancel", comment: "取消"), role: .cancel) {
|
||||||
|
viewModel.showLogoutConfirmation = false
|
||||||
|
}
|
||||||
|
Button(LocalizedString("appSetting.logoutConfirmation.confirm", comment: "确认退出"), role: .destructive) {
|
||||||
|
viewModel.onLogoutConfirmed()
|
||||||
|
viewModel.showLogoutConfirmation = false
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(LocalizedString("appSetting.logoutConfirmation.message", comment: "确定要退出当前账户吗?"))
|
||||||
|
}
|
||||||
|
// 关于我们弹窗
|
||||||
|
.alert(LocalizedString("appSetting.aboutUs.title", comment: "关于我们"), isPresented: $viewModel.showAboutUs) {
|
||||||
|
Button(LocalizedString("common.ok", comment: "确定")) {
|
||||||
|
viewModel.showAboutUs = false
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(LocalizedString("feedList.title", comment: "享受您的生活时光"))
|
||||||
|
.font(.headline)
|
||||||
|
Text(LocalizedString("feedList.slogan", comment: "疾病如同残酷的统治者,\n而时间是我们最宝贵的财富。\n我们活着的每一刻,都是对不可避免命运的胜利。"))
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// WebView 导航
|
||||||
|
.webView(
|
||||||
|
isPresented: $viewModel.showPrivacyPolicy,
|
||||||
|
url: APIConfiguration.webURL(for: .privacyPolicy)
|
||||||
|
)
|
||||||
|
.onChange(of: viewModel.showPrivacyPolicy) { _, isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
viewModel.onPrivacyPolicyDismissed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.webView(
|
||||||
|
isPresented: $viewModel.showUserAgreement,
|
||||||
|
url: APIConfiguration.webURL(for: .userAgreement)
|
||||||
|
)
|
||||||
|
.onChange(of: viewModel.showUserAgreement) { _, isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
viewModel.onUserAgreementDismissed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.webView(
|
||||||
|
isPresented: $viewModel.showDeactivateAccount,
|
||||||
|
url: APIConfiguration.webURL(for: .deactivateAccount)
|
||||||
|
)
|
||||||
|
.onChange(of: viewModel.showDeactivateAccount) { _, isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
viewModel.onDeactivateAccountDismissed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 头像设置区域
|
||||||
|
@ViewBuilder
|
||||||
|
private func avatarSection() -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// 头像
|
||||||
|
Button(action: {
|
||||||
|
viewModel.onAvatarTapped()
|
||||||
|
}) {
|
||||||
|
ZStack {
|
||||||
|
AsyncImage(url: URL(string: viewModel.userInfo?.avatar ?? "")) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
// 相机图标覆盖
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Circle()
|
||||||
|
.fill(Color.purple)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "camera")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isUploadingAvatar || viewModel.isUpdatingUser)
|
||||||
|
|
||||||
|
// 上传状态提示
|
||||||
|
if viewModel.isUploadingAvatar {
|
||||||
|
Text("正在上传头像...")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.avatarUploadError {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 个人信息设置区域
|
||||||
|
@ViewBuilder
|
||||||
|
private func personalInfoSection() -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 昵称设置
|
||||||
|
SettingRow(
|
||||||
|
title: LocalizedString("appSetting.nickname", comment: "昵称"),
|
||||||
|
subtitle: viewModel.userInfo?.nick ?? LocalizedString("app_settings.not_set", comment: "未设置"),
|
||||||
|
action: {
|
||||||
|
viewModel.onNicknameTapped()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.disabled(viewModel.isUpdatingUser)
|
||||||
|
|
||||||
|
// 更新状态提示
|
||||||
|
if viewModel.isUpdatingUser {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
Text("正在更新...")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.updateUserError {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 其他设置区域
|
||||||
|
@ViewBuilder
|
||||||
|
private func otherSettingsSection() -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
SettingRow(
|
||||||
|
title: LocalizedString("appSetting.personalInfoPermissions", comment: "个人信息与权限"),
|
||||||
|
subtitle: "",
|
||||||
|
action: { viewModel.onPersonalInfoPermissionsTapped() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.padding(.leading, 16)
|
||||||
|
|
||||||
|
SettingRow(
|
||||||
|
title: LocalizedString("appSetting.help", comment: "帮助"),
|
||||||
|
subtitle: "",
|
||||||
|
action: { viewModel.onHelpTapped() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.padding(.leading, 16)
|
||||||
|
|
||||||
|
SettingRow(
|
||||||
|
title: LocalizedString("appSetting.clearCache", comment: "清除缓存"),
|
||||||
|
subtitle: "",
|
||||||
|
action: { viewModel.onClearCacheTapped() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.padding(.leading, 16)
|
||||||
|
|
||||||
|
SettingRow(
|
||||||
|
title: LocalizedString("appSetting.checkUpdates", comment: "检查更新"),
|
||||||
|
subtitle: "",
|
||||||
|
action: { viewModel.onCheckUpdatesTapped() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.padding(.leading, 16)
|
||||||
|
|
||||||
|
SettingRow(
|
||||||
|
title: LocalizedString("appSetting.deactivateAccount", comment: "注销账号"),
|
||||||
|
subtitle: "",
|
||||||
|
action: { viewModel.onDeactivateAccountTapped() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.padding(.leading, 16)
|
||||||
|
|
||||||
|
SettingRow(
|
||||||
|
title: LocalizedString("appSetting.aboutUs", comment: "关于我们"),
|
||||||
|
subtitle: "",
|
||||||
|
action: { viewModel.onAboutUsTapped() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 退出登录区域
|
||||||
|
@ViewBuilder
|
||||||
|
private func logoutSection() -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
// 退出登录按钮
|
||||||
|
Button(action: {
|
||||||
|
viewModel.onLogoutTapped()
|
||||||
|
}) {
|
||||||
|
Text(LocalizedString("appSetting.logoutAccount", comment: "退出账户"))
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(Color.red.opacity(0.8))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// SettingPage(
|
||||||
|
// onBack: {},
|
||||||
|
// onLogout: {}
|
||||||
|
// )
|
||||||
|
//}
|
194
yana/MVVM/ViewModel/IDLoginViewModel.swift
Normal file
194
yana/MVVM/ViewModel/IDLoginViewModel.swift
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - IDLogin ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class IDLoginViewModel: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
@Published var userID: String = ""
|
||||||
|
@Published var password: String = ""
|
||||||
|
@Published var isPasswordVisible: Bool = false
|
||||||
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var showRecoverPassword: Bool = false
|
||||||
|
@Published var loginStep: LoginStep = .input
|
||||||
|
|
||||||
|
// MARK: - Ticket 相关状态
|
||||||
|
@Published var isTicketLoading: Bool = false
|
||||||
|
@Published var ticketError: String?
|
||||||
|
|
||||||
|
// MARK: - Callbacks
|
||||||
|
var onBack: (() -> Void)?
|
||||||
|
var onLoginSuccess: (() -> Void)?
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Enums
|
||||||
|
enum LoginStep: Equatable {
|
||||||
|
case input // 初始状态
|
||||||
|
case authenticating // 正在进行 OAuth 认证
|
||||||
|
case gettingTicket // 正在获取 Ticket
|
||||||
|
case completed // 认证完成
|
||||||
|
case failed // 认证失败
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
var isLoginButtonEnabled: Bool {
|
||||||
|
return !isLoading && !userID.isEmpty && !password.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
func onBackTapped() {
|
||||||
|
onBack?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onLoginTapped() {
|
||||||
|
guard isLoginButtonEnabled else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
ticketError = nil
|
||||||
|
loginStep = .authenticating
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await performLogin()
|
||||||
|
await MainActor.run {
|
||||||
|
self.handleLoginResult(result)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.handleLoginError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onRecoverPasswordTapped() {
|
||||||
|
showRecoverPassword = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onRecoverPasswordBack() {
|
||||||
|
showRecoverPassword = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func performLogin() async throws -> Bool {
|
||||||
|
// 第一步:OAuth认证
|
||||||
|
let accountModel = try await performOAuthAuthentication()
|
||||||
|
|
||||||
|
// 第二步:获取Ticket
|
||||||
|
let completeAccountModel = try await performTicketRequest(accountModel: accountModel)
|
||||||
|
|
||||||
|
// 第三步:保存完整的AccountModel
|
||||||
|
await UserInfoManager.saveAccountModel(completeAccountModel)
|
||||||
|
|
||||||
|
// 第四步:获取用户信息(如果API没有返回)
|
||||||
|
await fetchUserInfoIfNeeded(accountModel: completeAccountModel)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth认证
|
||||||
|
private func performOAuthAuthentication() async throws -> AccountModel {
|
||||||
|
// 使用LoginHelper创建登录请求(包含DES加密)
|
||||||
|
guard let loginRequest = await LoginHelper.createIDLoginRequest(
|
||||||
|
userID: userID,
|
||||||
|
password: password
|
||||||
|
) else {
|
||||||
|
throw APIError.custom("DES加密失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiService = LiveAPIService()
|
||||||
|
let response: IDLoginResponse = try await apiService.request(loginRequest)
|
||||||
|
|
||||||
|
if response.code == 200, let data = response.data {
|
||||||
|
// 保存用户信息(如果API返回了用户信息)
|
||||||
|
if let userInfo = data.userInfo {
|
||||||
|
await UserInfoManager.saveUserInfo(userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建账户模型(此时ticket为空)
|
||||||
|
guard let accountModel = AccountModel.from(loginData: data) else {
|
||||||
|
throw APIError.custom("账户信息无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountModel
|
||||||
|
} else {
|
||||||
|
throw APIError.custom(response.message ?? "Login failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ticket获取
|
||||||
|
private func performTicketRequest(accountModel: AccountModel) async throws -> AccountModel {
|
||||||
|
await MainActor.run {
|
||||||
|
self.isTicketLoading = true
|
||||||
|
self.ticketError = nil
|
||||||
|
self.loginStep = .gettingTicket
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiService = LiveAPIService()
|
||||||
|
|
||||||
|
// 创建ticket请求
|
||||||
|
let ticketRequest = TicketHelper.createTicketRequest(
|
||||||
|
accessToken: accountModel.accessToken ?? "",
|
||||||
|
uid: accountModel.uid.flatMap { Int($0) }
|
||||||
|
)
|
||||||
|
|
||||||
|
let ticketResponse: TicketResponse = try await apiService.request(ticketRequest)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.isTicketLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticketResponse.isSuccess {
|
||||||
|
if let ticket = ticketResponse.ticket {
|
||||||
|
debugInfoSync("✅ Ticket 获取成功: \(ticket)")
|
||||||
|
|
||||||
|
// 更新AccountModel,添加ticket
|
||||||
|
let completeAccountModel = accountModel.withTicket(ticket)
|
||||||
|
return completeAccountModel
|
||||||
|
} else {
|
||||||
|
throw APIError.custom("Ticket为空")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw APIError.custom(ticketResponse.errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 用户信息获取
|
||||||
|
private func fetchUserInfoIfNeeded(accountModel: AccountModel) async {
|
||||||
|
// 如果API没有返回用户信息,则从服务器获取
|
||||||
|
let apiService = LiveAPIService()
|
||||||
|
if let userInfo = await UserInfoManager.fetchUserInfoFromServer(
|
||||||
|
uid: accountModel.uid,
|
||||||
|
apiService: apiService
|
||||||
|
) {
|
||||||
|
await UserInfoManager.saveUserInfo(userInfo)
|
||||||
|
debugInfoSync("✅ 用户信息获取成功")
|
||||||
|
} else {
|
||||||
|
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLoginResult(_ success: Bool) {
|
||||||
|
isLoading = false
|
||||||
|
isTicketLoading = false
|
||||||
|
if success {
|
||||||
|
loginStep = .completed
|
||||||
|
debugInfoSync("✅ ID 登录完整流程成功")
|
||||||
|
onLoginSuccess?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLoginError(_ error: Error) {
|
||||||
|
isLoading = false
|
||||||
|
isTicketLoading = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
loginStep = .failed
|
||||||
|
debugErrorSync("❌ ID 登录失败: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
64
yana/MVVM/ViewModel/MainViewModel.swift
Normal file
64
yana/MVVM/ViewModel/MainViewModel.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Main ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MainViewModel: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
@Published var selectedTab: Tab = .feed
|
||||||
|
@Published var isLoggedOut: Bool = false
|
||||||
|
@Published var navigationPath = NavigationPath()
|
||||||
|
|
||||||
|
// MARK: - Callbacks
|
||||||
|
var onLogout: (() -> Void)?
|
||||||
|
var onAddButtonTapped: (() -> Void)?
|
||||||
|
|
||||||
|
// MARK: - Enums
|
||||||
|
enum Tab: String, CaseIterable {
|
||||||
|
case feed = "feed"
|
||||||
|
case me = "me"
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .feed:
|
||||||
|
return "Feed"
|
||||||
|
case .me:
|
||||||
|
return "Me"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .feed:
|
||||||
|
return "list.bullet"
|
||||||
|
case .me:
|
||||||
|
return "person.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
func onAppear() {
|
||||||
|
debugInfoSync("🚀 MainView onAppear")
|
||||||
|
debugInfoSync(" 当前selectedTab: \(selectedTab)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func onTabChanged(_ newTab: Tab) {
|
||||||
|
selectedTab = newTab
|
||||||
|
debugInfoSync("🔄 MainView selectedTab changed: \(newTab)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func onLogoutTapped() {
|
||||||
|
isLoggedOut = true
|
||||||
|
onLogout?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onTopRightButtonTapped() {
|
||||||
|
switch selectedTab {
|
||||||
|
case .feed:
|
||||||
|
onAddButtonTapped?()
|
||||||
|
case .me:
|
||||||
|
navigationPath.append("setting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
110
yana/MVVM/ViewModel/MomentListHomeViewModel.swift
Normal file
110
yana/MVVM/ViewModel/MomentListHomeViewModel.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - MomentListHome ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MomentListHomeViewModel: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var error: String? = nil
|
||||||
|
@Published var moments: [MomentsInfo] = []
|
||||||
|
@Published var isLoaded: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
func onAppear() {
|
||||||
|
debugInfoSync("📱 MomentListHomeViewModel onAppear")
|
||||||
|
guard !isLoaded else {
|
||||||
|
debugInfoSync("✅ MomentListHomeViewModel: 数据已加载,跳过重复请求")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchLatestDynamics()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func fetchLatestDynamics() {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
debugInfoSync("🔄 MomentListHomeViewModel: 开始获取最新动态")
|
||||||
|
|
||||||
|
Task {
|
||||||
|
// 检查认证信息
|
||||||
|
let accountModel = await UserInfoManager.getAccountModel()
|
||||||
|
if accountModel?.uid != nil {
|
||||||
|
debugInfoSync("✅ MomentListHomeViewModel: 认证信息已准备好,开始获取动态")
|
||||||
|
await performAPICall()
|
||||||
|
} else {
|
||||||
|
debugInfoSync("⏳ MomentListHomeViewModel: 认证信息未准备好,等待...")
|
||||||
|
// 增加等待时间和重试次数
|
||||||
|
for attempt in 1...3 {
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5秒
|
||||||
|
let retryAccountModel = await UserInfoManager.getAccountModel()
|
||||||
|
if retryAccountModel?.uid != nil {
|
||||||
|
debugInfoSync("✅ MomentListHomeViewModel: 第\(attempt)次重试成功,认证信息已保存,开始获取动态")
|
||||||
|
await performAPICall()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
debugInfoSync("⏳ MomentListHomeViewModel: 第\(attempt)次重试,认证信息仍未准备好")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugInfoSync("❌ MomentListHomeViewModel: 多次重试后认证信息仍未准备好")
|
||||||
|
await MainActor.run {
|
||||||
|
self.isLoading = false
|
||||||
|
self.error = "认证信息未准备好"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performAPICall() async {
|
||||||
|
let apiService = LiveAPIService()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
|
||||||
|
debugInfoSync("📡 MomentListHomeViewModel: 发送请求: \(request.endpoint)")
|
||||||
|
debugInfoSync(" 参数: dynamicId=\(request.dynamicId), pageSize=\(request.pageSize)")
|
||||||
|
|
||||||
|
let response: MomentsLatestResponse = try await apiService.request(request)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.handleAPISuccess(response)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.handleAPIError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAPISuccess(_ response: MomentsLatestResponse) {
|
||||||
|
isLoading = false
|
||||||
|
isLoaded = true
|
||||||
|
debugInfoSync("✅ MomentListHomeViewModel: API 请求成功")
|
||||||
|
debugInfoSync(" 响应码: \(response.code)")
|
||||||
|
debugInfoSync(" 消息: \(response.message)")
|
||||||
|
debugInfoSync(" 数据数量: \(response.data?.dynamicList.count ?? 0)")
|
||||||
|
|
||||||
|
if let list = response.data?.dynamicList {
|
||||||
|
moments = list
|
||||||
|
error = nil
|
||||||
|
debugInfoSync("✅ MomentListHomeViewModel: 数据加载成功")
|
||||||
|
debugInfoSync(" 动态数量: \(list.count)")
|
||||||
|
} else {
|
||||||
|
moments = []
|
||||||
|
error = response.message
|
||||||
|
debugErrorSync("❌ MomentListHomeViewModel: 数据为空")
|
||||||
|
debugErrorSync(" 错误消息: \(response.message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAPIError(_ error: Error) {
|
||||||
|
isLoading = false
|
||||||
|
moments = []
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
debugErrorSync("❌ MomentListHomeViewModel: API 请求失败")
|
||||||
|
debugErrorSync(" 错误: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
268
yana/MVVM/ViewModel/SettingViewModel.swift
Normal file
268
yana/MVVM/ViewModel/SettingViewModel.swift
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// MARK: - Setting ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class SettingViewModel: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
@Published var userInfo: UserInfo?
|
||||||
|
@Published var isLoadingUserInfo: Bool = false
|
||||||
|
@Published var userInfoError: String?
|
||||||
|
|
||||||
|
// 头像相关
|
||||||
|
@Published var isUploadingAvatar: Bool = false
|
||||||
|
@Published var avatarUploadError: String?
|
||||||
|
|
||||||
|
// 昵称编辑相关
|
||||||
|
@Published var isEditingNickname: Bool = false
|
||||||
|
@Published var nicknameInput: String = ""
|
||||||
|
@Published var isUpdatingUser: Bool = false
|
||||||
|
@Published var updateUserError: String?
|
||||||
|
|
||||||
|
// 图片选择相关
|
||||||
|
@Published var showImageSourceActionSheet: Bool = false
|
||||||
|
@Published var showCamera: Bool = false
|
||||||
|
@Published var showPhotoPicker: Bool = false
|
||||||
|
@Published var selectedPhotoItems: [PhotosPickerItem] = []
|
||||||
|
|
||||||
|
// 弹窗状态
|
||||||
|
@Published var showLogoutConfirmation: Bool = false
|
||||||
|
@Published var showAboutUs: Bool = false
|
||||||
|
@Published var showPrivacyPolicy: Bool = false
|
||||||
|
@Published var showUserAgreement: Bool = false
|
||||||
|
@Published var showDeactivateAccount: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Callbacks
|
||||||
|
var onBack: (() -> Void)?
|
||||||
|
var onLogout: (() -> Void)?
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
private let apiService: APIServiceProtocol
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init(apiService: APIServiceProtocol = LiveAPIService()) {
|
||||||
|
self.apiService = apiService
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
func onAppear() {
|
||||||
|
debugInfoSync("⚙️ SettingPage onAppear")
|
||||||
|
loadUserInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onBackTapped() {
|
||||||
|
onBack?()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User Info Management
|
||||||
|
private func loadUserInfo() {
|
||||||
|
isLoadingUserInfo = true
|
||||||
|
userInfoError = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
if let userInfo = await UserInfoManager.getUserInfo() {
|
||||||
|
self.userInfo = userInfo
|
||||||
|
debugInfoSync("✅ 用户信息加载成功")
|
||||||
|
} else {
|
||||||
|
// 尝试从服务器获取
|
||||||
|
if let userInfo = await UserInfoManager.fetchUserInfoFromServer(apiService: apiService) {
|
||||||
|
self.userInfo = userInfo
|
||||||
|
debugInfoSync("✅ 从服务器获取用户信息成功")
|
||||||
|
} else {
|
||||||
|
self.userInfoError = "获取用户信息失败"
|
||||||
|
debugErrorSync("❌ 获取用户信息失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.isLoadingUserInfo = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar Management
|
||||||
|
func onAvatarTapped() {
|
||||||
|
showImageSourceActionSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectImageSource(_ source: AppImageSource) {
|
||||||
|
showImageSourceActionSheet = false
|
||||||
|
|
||||||
|
switch source {
|
||||||
|
case .camera:
|
||||||
|
showCamera = true
|
||||||
|
case .photoLibrary:
|
||||||
|
showPhotoPicker = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onCameraImagePicked(_ image: UIImage) {
|
||||||
|
showCamera = false
|
||||||
|
uploadAvatar(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onPhotoPickerItemsChanged(_ items: [PhotosPickerItem]) {
|
||||||
|
selectedPhotoItems = items
|
||||||
|
|
||||||
|
Task {
|
||||||
|
if let item = items.first {
|
||||||
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
await MainActor.run {
|
||||||
|
showPhotoPicker = false
|
||||||
|
uploadAvatar(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAvatar(_ image: UIImage) {
|
||||||
|
isUploadingAvatar = true
|
||||||
|
avatarUploadError = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
if let url = await COSManagerAdapter.shared.uploadUIImage(image, apiService: apiService) {
|
||||||
|
await MainActor.run {
|
||||||
|
self.isUploadingAvatar = false
|
||||||
|
self.updateUserAvatar(url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MainActor.run {
|
||||||
|
self.isUploadingAvatar = false
|
||||||
|
self.avatarUploadError = "头像上传失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateUserAvatar(_ avatarUrl: String) {
|
||||||
|
guard let userInfo = userInfo else { return }
|
||||||
|
|
||||||
|
isUpdatingUser = true
|
||||||
|
updateUserError = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let ticket = await UserInfoManager.getCurrentUserTicket() ?? ""
|
||||||
|
let request = UpdateUserRequest(avatar: avatarUrl, nick: nil, uid: userInfo.uid ?? 0, ticket: ticket)
|
||||||
|
let response: UpdateUserResponse = try await apiService.request(request)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.isUpdatingUser = false
|
||||||
|
if response.code == 200 {
|
||||||
|
// 刷新用户信息
|
||||||
|
self.loadUserInfo()
|
||||||
|
} else {
|
||||||
|
self.updateUserError = response.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.isUpdatingUser = false
|
||||||
|
self.updateUserError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Nickname Management
|
||||||
|
func onNicknameTapped() {
|
||||||
|
nicknameInput = userInfo?.nick ?? ""
|
||||||
|
isEditingNickname = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onNicknameInputChanged(_ text: String) {
|
||||||
|
nicknameInput = String(text.prefix(15))
|
||||||
|
}
|
||||||
|
|
||||||
|
func onNicknameEditConfirmed() {
|
||||||
|
let trimmed = nicknameInput.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
|
||||||
|
isEditingNickname = false
|
||||||
|
updateUserNickname(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateUserNickname(_ nickname: String) {
|
||||||
|
guard let userInfo = userInfo else { return }
|
||||||
|
|
||||||
|
isUpdatingUser = true
|
||||||
|
updateUserError = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let ticket = await UserInfoManager.getCurrentUserTicket() ?? ""
|
||||||
|
let request = UpdateUserRequest(avatar: nil, nick: nickname, uid: userInfo.uid ?? 0, ticket: ticket)
|
||||||
|
let response: UpdateUserResponse = try await apiService.request(request)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.isUpdatingUser = false
|
||||||
|
if response.code == 200 {
|
||||||
|
// 刷新用户信息
|
||||||
|
self.loadUserInfo()
|
||||||
|
} else {
|
||||||
|
self.updateUserError = response.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.isUpdatingUser = false
|
||||||
|
self.updateUserError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Actions
|
||||||
|
func onPersonalInfoPermissionsTapped() {
|
||||||
|
showPrivacyPolicy = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onHelpTapped() {
|
||||||
|
showUserAgreement = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onClearCacheTapped() {
|
||||||
|
// TODO: 实现清除缓存逻辑
|
||||||
|
debugInfoSync("🗑️ 清除缓存")
|
||||||
|
}
|
||||||
|
|
||||||
|
func onCheckUpdatesTapped() {
|
||||||
|
// TODO: 实现检查更新逻辑
|
||||||
|
debugInfoSync("🔄 检查更新")
|
||||||
|
}
|
||||||
|
|
||||||
|
func onDeactivateAccountTapped() {
|
||||||
|
showDeactivateAccount = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onAboutUsTapped() {
|
||||||
|
showAboutUs = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onLogoutTapped() {
|
||||||
|
showLogoutConfirmation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onLogoutConfirmed() {
|
||||||
|
Task {
|
||||||
|
await UserInfoManager.clearAllAuthenticationData()
|
||||||
|
await MainActor.run {
|
||||||
|
onLogout?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WebView Dismissal
|
||||||
|
func onPrivacyPolicyDismissed() {
|
||||||
|
showPrivacyPolicy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func onUserAgreementDismissed() {
|
||||||
|
showUserAgreement = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func onDeactivateAccountDismissed() {
|
||||||
|
showDeactivateAccount = false
|
||||||
|
}
|
||||||
|
}
|
@@ -144,7 +144,8 @@
|
|||||||
"appSetting.logoutConfirmation.confirm" = "Confirm Logout";
|
"appSetting.logoutConfirmation.confirm" = "Confirm Logout";
|
||||||
"appSetting.logoutConfirmation.message" = "Are you sure you want to logout from your current account?";
|
"appSetting.logoutConfirmation.message" = "Are you sure you want to logout from your current account?";
|
||||||
"appSetting.deactivateAccount" = "Deactivate Account";
|
"appSetting.deactivateAccount" = "Deactivate Account";
|
||||||
"appSetting.logoutAccount" = "Log out of account";
|
"appSetting.logoutAccount" = "Log out of account";
|
||||||
|
"app_settings.not_set" = "Not set";
|
||||||
|
|
||||||
// MARK: - Detail
|
// MARK: - Detail
|
||||||
"detail.title" = "Enjoy your life";
|
"detail.title" = "Enjoy your life";
|
||||||
|
@@ -140,7 +140,8 @@
|
|||||||
"appSetting.logoutConfirmation.confirm" = "确认退出";
|
"appSetting.logoutConfirmation.confirm" = "确认退出";
|
||||||
"appSetting.logoutConfirmation.message" = "确定要退出当前账户吗?";
|
"appSetting.logoutConfirmation.message" = "确定要退出当前账户吗?";
|
||||||
"appSetting.deactivateAccount" = "注销帐号";
|
"appSetting.deactivateAccount" = "注销帐号";
|
||||||
"appSetting.logoutAccount" = "退出账户";
|
"appSetting.logoutAccount" = "退出账户";
|
||||||
|
"app_settings.not_set" = "未设置";
|
||||||
|
|
||||||
// MARK: - Detail
|
// MARK: - Detail
|
||||||
"detail.title" = "享受你的生活";
|
"detail.title" = "享受你的生活";
|
||||||
|
@@ -373,42 +373,42 @@ struct AppSettingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 设置行组件
|
// MARK: - 设置行组件
|
||||||
struct SettingRow: View {
|
//struct SettingRow: View {
|
||||||
let title: String
|
// let title: String
|
||||||
let subtitle: String
|
// let subtitle: String
|
||||||
let action: (() -> Void)?
|
// let action: (() -> Void)?
|
||||||
|
//
|
||||||
var body: some View {
|
// var body: some View {
|
||||||
Button(action: {
|
// Button(action: {
|
||||||
action?()
|
// action?()
|
||||||
}) {
|
// }) {
|
||||||
HStack(spacing: 16) {
|
// HStack(spacing: 16) {
|
||||||
HStack {
|
// HStack {
|
||||||
Text(title)
|
// Text(title)
|
||||||
.font(.system(size: 16))
|
// .font(.system(size: 16))
|
||||||
.foregroundColor(.white)
|
// .foregroundColor(.white)
|
||||||
.multilineTextAlignment(.leading)
|
// .multilineTextAlignment(.leading)
|
||||||
|
//
|
||||||
Spacer()
|
// Spacer()
|
||||||
|
//
|
||||||
if !subtitle.isEmpty {
|
// if !subtitle.isEmpty {
|
||||||
Text(subtitle)
|
// Text(subtitle)
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
.foregroundColor(.white.opacity(0.7))
|
// .foregroundColor(.white.opacity(0.7))
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Spacer()
|
// Spacer()
|
||||||
|
//
|
||||||
if action != nil {
|
// if action != nil {
|
||||||
Image(systemName: "chevron.right")
|
// Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
.foregroundColor(.white.opacity(0.5))
|
// .foregroundColor(.white.opacity(0.5))
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
.padding(.horizontal, 16)
|
// .padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
// .padding(.vertical, 12)
|
||||||
}
|
// }
|
||||||
.disabled(action == nil)
|
// .disabled(action == nil)
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
import UIKit
|
import UIKit
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
|
||||||
public struct CameraPicker: UIViewControllerRepresentable {
|
public struct _CameraPicker: UIViewControllerRepresentable {
|
||||||
public var onImagePicked: (UIImage?) -> Void
|
public var onImagePicked: (UIImage?) -> Void
|
||||||
public init(onImagePicked: @escaping (UIImage?) -> Void) {
|
public init(onImagePicked: @escaping (UIImage?) -> Void) {
|
||||||
self.onImagePicked = onImagePicked
|
self.onImagePicked = onImagePicked
|
||||||
|
@@ -42,14 +42,14 @@ extension View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
//#Preview {
|
||||||
VStack {
|
// VStack {
|
||||||
Button(LocalizedString("web_view.open_webpage", comment: "")) {
|
// Button(LocalizedString("web_view.open_webpage", comment: "")) {
|
||||||
// 预览时不执行任何操作
|
// // 预览时不执行任何操作
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
.webView(
|
// .webView(
|
||||||
isPresented: .constant(true),
|
// isPresented: .constant(true),
|
||||||
url: URL(string: "https://www.apple.com")
|
// url: URL(string: "https://www.apple.com")
|
||||||
)
|
// )
|
||||||
}
|
//}
|
||||||
|
Reference in New Issue
Block a user