Sync migrated iOS modules

This commit is contained in:
2026-06-22 15:35:12 +08:00
parent ace9c94359
commit 484566d27e
48 changed files with 4455 additions and 15 deletions

View File

@ -0,0 +1,260 @@
//
// AccountSwitchView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
///
struct AccountSwitchView: View {
@Environment(AppSession.self) private var appSession
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(AppRouter.self) private var appRouter
@Environment(ProfileAPI.self) private var profileAPI
@Environment(AuthAPI.self) private var authAPI
@Environment(AccountContextAPI.self) private var accountContextAPI
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@State private var viewModel = AccountSwitchViewModel()
private var selectedAccount: AccountSwitchAccount? {
viewModel.selectedAccount
}
var body: some View {
VStack(spacing: 0) {
accountList
bottomBar
}
.background(Color(hex: 0xF5F7FB).ignoresSafeArea())
.navigationTitle("账号切换")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadAccounts()
}
}
@ViewBuilder
private var accountList: some View {
if viewModel.accounts.isEmpty && !viewModel.loading {
ContentUnavailableView(
"暂无可切换账号",
systemImage: "person.crop.circle.badge.exclamationmark",
description: Text("当前登录账号下没有其他可切换账号。")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.accounts) { account in
accountCard(account)
}
}
.padding(16)
.padding(.bottom, 92)
}
.refreshable {
await loadAccounts(force: true)
}
}
}
private var bottomBar: some View {
VStack(spacing: 0) {
Divider()
Button {
Task { await confirmSelection() }
} label: {
HStack(spacing: 8) {
if viewModel.switching {
ProgressView()
.tint(.white)
}
Text("确认切换")
.font(.system(size: 16, weight: .semibold))
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.buttonStyle(.plain)
.disabled(!canConfirm)
}
.background(.white)
}
private var canConfirm: Bool {
selectedAccount != nil && !viewModel.loading && !viewModel.switching
}
///
private func accountCard(_ account: AccountSwitchAccount) -> some View {
let selected = viewModel.selectedAccountId == account.id
return Button {
viewModel.select(account)
} label: {
HStack(spacing: 13) {
accountAvatar(account)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(nonEmpty(account.title) ?? account.accountTypeLabel)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
if account.isCurrent || isCurrentAccount(account) {
tag("当前", foreground: AppDesign.primary, background: AppDesign.primarySoft)
}
}
if !account.subtitle.isEmpty {
Text(account.subtitle)
.font(.system(size: 13))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
}
if !account.phone.isEmpty {
Text(account.phone)
.font(.system(size: 12))
.foregroundStyle(Color(hex: 0x9AA1AA))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .trailing, spacing: 8) {
tag(
account.isStoreUser ? "门店" : "景区",
foreground: account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED),
background: account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF)
)
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(selected ? AppDesign.primary : Color(hex: 0xB6BECA))
}
}
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(selected ? AppDesign.primary : Color(hex: 0xE6ECF4), lineWidth: selected ? 1.4 : 1)
}
.shadow(color: Color(hex: 0xC8D7EA, alpha: selected ? 0.24 : 0.12), radius: selected ? 10 : 6, x: 0, y: 5)
}
.buttonStyle(.plain)
}
@ViewBuilder
///
private func accountAvatar(_ account: AccountSwitchAccount) -> some View {
if let url = URL(string: account.avatar), !account.avatar.isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
avatarFallback(account)
}
}
.frame(width: 50, height: 50)
.clipShape(Circle())
} else {
avatarFallback(account)
.frame(width: 50, height: 50)
}
}
///
private func avatarFallback(_ account: AccountSwitchAccount) -> some View {
ZStack {
Circle()
.fill(account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF))
Text(account.isStoreUser ? "" : "")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED))
}
}
///
private func tag(_ text: String, foreground: Color, background: Color) -> some View {
Text(text)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(foreground)
.padding(.horizontal, 7)
.frame(height: 22)
.background(background, in: RoundedRectangle(cornerRadius: 4))
}
///
private func loadAccounts(force: Bool = false) async {
do {
try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId)
} catch is CancellationError {
return
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func confirmSelection() async {
guard let account = selectedAccount else { return }
if isCurrentAccount(account) {
dismiss()
return
}
do {
let response = try await viewModel.switchAccount(account, api: authAPI)
let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? ""
try await authSessionCoordinator.completeLogin(
with: response,
username: username,
privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI
)
appRouter.reset()
toastCenter.show("账号已切换")
dismiss()
} catch is CancellationError {
return
} catch {
toastCenter.show(error.localizedDescription)
}
}
private var currentAccountId: String? {
guard let current = accountContext.profile else { return nil }
if let store = accountContext.currentStore {
return "\(V9StoreUser.accountTypeValue)_\(store.id)"
}
if let scenic = accountContext.currentScenic {
return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)"
}
return current.userId.isEmpty ? nil : current.userId
}
///
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
account.isCurrent || viewModel.isCurrent(account, currentAccountId: currentAccountId)
}
///
private func nonEmpty(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
}

View File

@ -14,6 +14,7 @@ struct ProfileView: View {
@Environment(PermissionContext.self) private var permissionContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(AppRouter.self) private var appRouter
@Environment(RouterPath.self) private var router
@Environment(ProfileAPI.self) private var profileAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
@ -234,7 +235,7 @@ struct ProfileView: View {
divider
Button {
toastCenter.show("账号切换待接入")
router.navigate(to: .profile(.accountSwitch))
} label: {
infoRow(title: "当前账号") {
VStack(alignment: .trailing, spacing: 3) {
@ -269,7 +270,7 @@ struct ProfileView: View {
divider
Button {
toastCenter.show("实名认证页面待接入")
router.navigate(to: .profile(.realNameAuth))
} label: {
infoRow(title: "认证状态") {
realNameStatusView
@ -289,6 +290,17 @@ struct ProfileView: View {
infoRow(title: "当前景区") {
scenicStatusView
}
divider
Button {
router.navigate(to: .profile(.settings))
} label: {
infoRow(title: "系统设置") {
rowChevron
}
}
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
.padding(.vertical, 4)

View File

@ -0,0 +1,383 @@
//
// RealNameAuthView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
///
struct RealNameAuthView: View {
@Environment(ProfileAPI.self) private var profileAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = RealNameAuthViewModel()
var body: some View {
ScrollView {
VStack(spacing: 14) {
auditStatusPanel
identityCard
privilegeCard
tipsCard
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 34)
}
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("实名认证")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadInfo()
}
.overlay {
if viewModel.loading && viewModel.info == nil {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(0.35))
}
}
}
private var auditStatusPanel: some View {
VStack(spacing: 14) {
auditRow("审核状态", value: viewModel.info?.auditStatusText?.nonEmpty ?? viewModel.auditStatusText(viewModel.info?.auditStatus ?? 0))
auditRow("审核人:", value: viewModel.info?.auditor?.name.nonEmpty ?? "--")
auditRow("审核时间:", value: viewModel.info?.auditAt?.nonEmpty ?? "--")
auditRow("审核备注:", value: viewModel.info?.rejectReason?.nonEmpty ?? "--")
}
.padding(.horizontal, 20)
.padding(.vertical, 22)
.background(Color(hex: 0xEAF4FF), in: RoundedRectangle(cornerRadius: 14))
}
///
private func auditRow(_ title: String, value: String) -> some View {
HStack(alignment: .top) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color(hex: 0x1687D9))
Spacer(minLength: 20)
Text(value)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color(hex: 0x1687D9))
.multilineTextAlignment(.trailing)
}
}
private var identityCard: some View {
VStack(alignment: .leading, spacing: 14) {
identityImageSection(title: "身份证国徽面", url: viewModel.backUrl)
identityImageSection(title: "身份证人像面", url: viewModel.frontUrl)
formField("姓名", text: realNameBinding, placeholder: "请输入姓名")
formField("身份证号码", text: idCardNoBinding, placeholder: "请输入身份证号码")
validitySection
if shouldShowSmsSection {
smsSection
}
urlField("身份证人像面图片 URL", text: frontURLBinding)
urlField("身份证国徽面图片 URL", text: backURLBinding)
submitButton
statusBanner
}
.padding(.horizontal, 18)
.padding(.vertical, 20)
.background(.white, in: RoundedRectangle(cornerRadius: 14))
}
///
private func identityImageSection(title: String, url: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(hex: 0x222222))
imagePreview(url: url)
}
}
private var validitySection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Text("有效期")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(hex: 0x222222))
Spacer()
Button {
viewModel.isLongValid.toggle()
} label: {
HStack(spacing: 6) {
Image(systemName: viewModel.isLongValid ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18, weight: .semibold))
Text("长期有效")
.font(.system(size: 15))
}
.foregroundStyle(viewModel.isLongValid ? AppDesign.primary : Color(hex: 0x8EA0AF))
}
.buttonStyle(.plain)
}
HStack(spacing: 12) {
dateField(selection: startDateBinding)
dateField(selection: endDateBinding)
.disabled(viewModel.isLongValid)
.opacity(viewModel.isLongValid ? 0.45 : 1)
}
}
}
private var smsSection: some View {
HStack(spacing: 10) {
TextField("", text: smsCodeBinding, prompt: Text("请输入短信验证码").foregroundColor(AppDesign.placeholder))
.keyboardType(.numberPad)
.font(.system(size: 16))
.appInputFieldStyle(cornerRadius: 8, minHeight: 48)
Button {
Task { await sendCode() }
} label: {
Text(viewModel.sendingCode ? "发送中" : "获取验证码")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(viewModel.sendingCode ? Color(hex: 0x9CA3AF) : .white)
.frame(width: 104, height: 48)
.background(viewModel.sendingCode ? Color(hex: 0xEEF2F7) : AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(viewModel.sendingCode)
}
}
private var submitButton: some View {
Button {
Task { await submit() }
} label: {
Text(viewModel.submitting ? "提交中..." : "下一步")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.disabled(!canSubmit || viewModel.submitting)
}
@ViewBuilder
private var statusBanner: some View {
if let statusMessage = viewModel.statusMessage {
HStack(spacing: 6) {
Image(systemName: statusMessageIsSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 12))
Text(statusMessage)
.font(.system(size: 13))
}
.foregroundStyle(statusMessageIsSuccess ? AppDesign.success : AppDesign.warning)
.padding(.horizontal, 10)
.frame(minHeight: 34, alignment: .leading)
.background((statusMessageIsSuccess ? AppDesign.success : AppDesign.warning).opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
}
}
private var privilegeCard: some View {
VStack(alignment: .leading, spacing: 22) {
Text("实名认证特权")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(hex: 0x222222))
privilegeRow(icon: "checkmark.circle.fill", title: "开启接单权限", message: "完成实名认证后可立即接单,实名状态对游客可见")
privilegeRow(icon: "person.3.fill", title: "加入营销群", message: "专业摄影师交流群,获取更多流量与技术支持")
privilegeRow(icon: "map.fill", title: "景区选择权限", message: "可自主选择运营景区,获得独家运营资源")
}
.padding(.horizontal, 20)
.padding(.vertical, 22)
.background(.white, in: RoundedRectangle(cornerRadius: 14))
}
///
private func privilegeRow(icon: String, title: String, message: String) -> some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(AppDesign.primary, in: Circle())
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(hex: 0x333333))
Text(message)
.font(.system(size: 14))
.foregroundStyle(Color(hex: 0x777777))
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var tipsCard: some View {
VStack(alignment: .leading, spacing: 14) {
Text("温馨提示:")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(hex: 0x333333))
tipText("请确保填写的信息真实有效")
tipText("身份信息仅用于实名认证,我们将严格保护您的隐私")
tipText("如有疑问请联系客服")
}
.padding(.horizontal, 20)
.padding(.vertical, 22)
.background(.white, in: RoundedRectangle(cornerRadius: 14))
}
///
private func tipText(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("-")
Text(text)
.fixedSize(horizontal: false, vertical: true)
}
.font(.system(size: 15))
.foregroundStyle(Color(hex: 0x666666))
}
///
private func imagePreview(url: String) -> some View {
ZStack {
if let imageURL = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)), !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
case .empty:
ProgressView().tint(AppDesign.primary)
default:
placeholderImageContent
}
}
} else {
placeholderImageContent
}
}
.frame(maxWidth: .infinity)
.frame(height: 166)
.background(Color(hex: 0xFAFAFA))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(hex: 0xE0E0E0), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
)
}
private var placeholderImageContent: some View {
ZStack {
Color(hex: 0xF3F6FA)
Image(systemName: "photo")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(Color(hex: 0x8A94A6))
}
}
///
private func formField(_ title: String, text: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(hex: 0x222222))
TextField("", text: text, prompt: Text(placeholder).foregroundColor(AppDesign.placeholder))
.font(.system(size: 16))
.foregroundStyle(Color(hex: 0x333333))
.appInputFieldStyle(cornerRadius: 8, minHeight: 54)
}
}
/// URL
private func urlField(_ title: String, text: Binding<String>) -> some View {
formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL")
}
///
private func dateField(selection: Binding<Date>) -> some View {
DatePicker("", selection: selection, displayedComponents: .date)
.labelsHidden()
.datePickerStyle(.compact)
.font(.system(size: 16))
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(Color(hex: 0xF1F1F1), in: RoundedRectangle(cornerRadius: 6))
}
private var canSubmit: Bool {
viewModel.validationMessage == nil && !viewModel.submitting
}
private var shouldShowSmsSection: Bool {
!viewModel.smsCode.isEmpty || viewModel.info?.verified != true
}
private var statusMessageIsSuccess: Bool {
guard let statusMessage = viewModel.statusMessage else { return false }
return statusMessage.contains("成功") || statusMessage.contains("已发送") || statusMessage.contains("已提交")
}
private var realNameBinding: Binding<String> {
Binding(get: { viewModel.realName }, set: { viewModel.realName = $0 })
}
private var idCardNoBinding: Binding<String> {
Binding(get: { viewModel.idCardNo }, set: { viewModel.idCardNo = $0 })
}
private var smsCodeBinding: Binding<String> {
Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 })
}
private var frontURLBinding: Binding<String> {
Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 })
}
private var backURLBinding: Binding<String> {
Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $0 })
}
private var startDateBinding: Binding<Date> {
Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 })
}
private var endDateBinding: Binding<Date> {
Binding(get: { viewModel.endDate }, set: { viewModel.endDate = $0 })
}
///
private func loadInfo() async {
do {
try await viewModel.load(api: profileAPI)
} catch is CancellationError {
return
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func sendCode() async {
do {
try await viewModel.sendCode(api: profileAPI)
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func submit() async {
do {
try await viewModel.submit(api: profileAPI)
} catch {
viewModel.statusMessage = error.localizedDescription
toastCenter.show(error.localizedDescription)
}
}
}
private extension String {
///
var nonEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

View File

@ -0,0 +1,246 @@
//
// SettingsFlowViews.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
import UIKit
import WebKit
/// H5
enum AgreementPage: Hashable, Identifiable {
case about
case userAgreement
case privacyPolicy
case walletUserNotice
case walletPrivacy
var id: String { title }
///
var title: String {
switch self {
case .about: "关于我们"
case .userAgreement: "用户协议"
case .privacyPolicy: "隐私政策"
case .walletUserNotice: "钱包用户须知"
case .walletPrivacy: "钱包隐私政策"
}
}
/// H5
var url: URL {
let path = switch self {
case .about: "/h5/app/about-us"
case .userAgreement: "/h5/app/user-agreement"
case .privacyPolicy: "/h5/app/privacy-policy"
case .walletUserNotice: "/h5/app/wallet-user-notice"
case .walletPrivacy: "/h5/app/wallet-privacy"
}
return APIEnvironment.current.baseURL.appending(path: path)
}
}
///
enum SettingsDisplayPolicy {
///
nonisolated static func versionText(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String {
AppClientInfo.appVersion(infoDictionary: infoDictionary)
}
}
///
struct SettingsCenterView: View {
@Environment(RouterPath.self) private var router
@State private var copiedDownloadLink = false
private var versionText: String {
SettingsDisplayPolicy.versionText()
}
private var downloadLink: String {
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
settingsGroup {
settingsButton(title: "关于我们") {
router.navigate(to: .profile(.agreement(.about)))
}
settingsDivider
settingsRow(title: "系统版本", value: versionText)
settingsDivider
Button {
copyDownloadLink()
} label: {
settingsRow(title: "App下载", value: copiedDownloadLink ? "已复制" : "复制链接", valueColor: AppDesign.primary)
}
.buttonStyle(.plain)
settingsDivider
settingsButton(title: "用户协议") {
router.navigate(to: .profile(.agreement(.userAgreement)))
}
settingsDivider
settingsButton(title: "隐私政策") {
router.navigate(to: .profile(.agreement(.privacyPolicy)))
}
}
footer
.padding(.top, 40)
}
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 32)
}
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline)
}
/// App
private func copyDownloadLink() {
UIPasteboard.general.string = downloadLink
copiedDownloadLink = true
Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
copiedDownloadLink = false
}
}
///
private func settingsGroup<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(spacing: 0) {
content()
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
///
private func settingsButton(title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
settingsRow(title: title, showsChevron: true)
}
.buttonStyle(.plain)
}
///
private func settingsRow(
title: String,
value: String? = nil,
valueColor: Color = Color(hex: 0x333333),
showsChevron: Bool = false
) -> some View {
HStack(spacing: 12) {
Text(title)
.font(.system(size: 18))
.foregroundStyle(Color(hex: 0x4B5563))
Spacer()
if let value, !value.isEmpty {
Text(value)
.font(.system(size: 18))
.foregroundStyle(valueColor)
.lineLimit(1)
}
if showsChevron {
Image(systemName: "chevron.right")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(hex: 0x999999))
}
}
.frame(height: 58)
.contentShape(Rectangle())
}
/// 线
private var settingsDivider: some View {
Rectangle()
.fill(Color(hex: 0xEEEEEE))
.frame(height: 0.5)
}
///
private var footer: some View {
VStack(spacing: 12) {
Text("Copyright © 2025 All Rights Reserved")
Text("苏ICP备2025157647号")
}
.font(.system(size: 16))
.foregroundStyle(Color(hex: 0xB6BECA))
.multilineTextAlignment(.center)
}
}
/// H5 使 WKWebView 线
struct AgreementView: View {
let page: AgreementPage
@State private var isLoading = true
var body: some View {
ZStack {
AgreementWebView(url: page.url, isLoading: $isLoading)
if isLoading {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(0.4))
}
}
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle(page.title)
.navigationBarTitleDisplayMode(.inline)
}
}
/// WKWebView SwiftUI
private struct AgreementWebView: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
/// WebView
func makeCoordinator() -> Coordinator {
Coordinator(isLoading: $isLoading)
}
/// WKWebView
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
return webView
}
/// URL WebView
func updateUIView(_ webView: WKWebView, context: Context) {
guard webView.url != url else { return }
isLoading = true
webView.load(URLRequest(url: url))
}
/// WebView
final class Coordinator: NSObject, WKNavigationDelegate {
@Binding var isLoading: Bool
///
init(isLoading: Binding<Bool>) {
_isLoading = isLoading
}
/// loading
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
isLoading = false
}
/// loading
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
isLoading = false
}
}
}