diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index 89ccafb..21681c5 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -137,7 +137,8 @@ struct FleetView: View { .buttonStyle( AIButtonStyle( kind: .secondary, - size: .compact + size: .regular, + fixedWidth: 96 ) ) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift index d1e5534..f05a734 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift @@ -55,7 +55,11 @@ struct LogsView: View { .navigationTitle("日志中心") .toolbar { ToolbarItemGroup(placement: .primaryAction) { - AIToolbarButton("刷新", systemImage: "arrow.clockwise") { + AIToolbarButton( + "刷新", + systemImage: "arrow.clockwise", + kind: .primary + ) { Task { await model.refresh() } } .disabled(model.isLoading) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift index 3128ea0..312df0d 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift @@ -56,7 +56,7 @@ struct NotificationsView: View { Text( model.isRetryingAll ? "投递中" - : (model.overview?.queue.total == 0 ? "暂无待投递" : "立即投递") + : (model.overview?.queue.total == 0 ? "没有待投递" : "立即投递") ) } } @@ -198,7 +198,11 @@ struct NotificationsView: View { Label("重试", systemImage: "arrow.clockwise") } .buttonStyle( - AIButtonStyle(kind: .secondary, size: .compact, fillsWidth: true) + AIButtonStyle( + kind: .secondary, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) ) .disabled(model.busyQueueID != nil) @@ -207,7 +211,13 @@ struct NotificationsView: View { } label: { Label("删除", systemImage: "trash") } - .buttonStyle(AIButtonStyle(kind: .danger, size: .compact, fillsWidth: true)) + .buttonStyle( + AIButtonStyle( + kind: .danger, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + ) .disabled(model.busyQueueID != nil) } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift index 43273bb..6e4b920 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift @@ -1,103 +1,116 @@ import SwiftUI struct SettingsView: View { - @StateObject private var model: SettingsViewModel - @State private var showsClearDialog = false + @StateObject private var model: SettingsViewModel + @State private var showsClearDialog = false - init(model: SettingsViewModel? = nil) { - _model = StateObject(wrappedValue: model ?? DefaultContainer.makeSettingsModel()) + init(model: SettingsViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? DefaultContainer.makeSettingsModel()) + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + if let errorMessage = model.errorMessage { + AIStatusBanner( + errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") + } + if let notice = model.notice { + AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle") + } + + runtimeCard + storageCard + securityCard + dangerZoneCard + } + .padding(16) + .padding(.bottom, 8) } - - var body: some View { - ScrollView { - LazyVStack(spacing: 12) { - if let errorMessage = model.errorMessage { - AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") - } - if let notice = model.notice { - AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle") - } - - runtimeCard - storageCard - securityCard - } - .padding(16) - .padding(.bottom, 8) - } - .background(AITheme.canvas.ignoresSafeArea()) - .navigationTitle("设置") - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - AIToolbarButton("刷新", systemImage: "arrow.clockwise") { - Task { await model.refresh() } - } - .disabled(model.isLoading) - } - } - .refreshable { - await model.refresh() - } - .confirmationDialog( - "确认清除运行日志?此操作不可恢复。", - isPresented: $showsClearDialog, - titleVisibility: .visible + .background(AITheme.canvas.ignoresSafeArea()) + .navigationTitle("设置") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + AIToolbarButton( + "刷新", + systemImage: "arrow.clockwise", + kind: .primary ) { - Button("清除运行日志", role: .destructive) { - Task { await model.clearRuntimeLogs() } - } - Button("取消", role: .cancel) {} - } - .task { - await model.refresh() + Task { await model.refresh() } } + .disabled(model.isLoading) + } } - - private var runtimeCard: some View { - AICard { - VStack(alignment: .leading, spacing: 12) { - AISectionTitle("应用信息", systemImage: "info.circle") - if let runtime = model.runtime { - AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))") - AIKeyValueRow("设备", value: runtime.deviceModel) - } else { - Text("正在读取应用信息…") - .font(.subheadline) - .foregroundStyle(AITheme.muted) - } - } - } + .refreshable { + await model.refresh() } - - private var storageCard: some View { - AICard { - VStack(alignment: .leading, spacing: 12) { - AISectionTitle("本地数据", systemImage: "externaldrive") - AIKeyValueRow("存储位置", value: "仅保存在本机应用沙盒") - AIKeyValueRow("当前占用", value: model.storageLabel) - Button { - showsClearDialog = true - } label: { - Label("清除运行日志", systemImage: "trash") - } - .buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true)) - } - } + .confirmationDialog( + "确认清除运行日志?此操作不可恢复。", + isPresented: $showsClearDialog, + titleVisibility: .visible + ) { + Button("清除运行日志", role: .destructive) { + Task { await model.clearRuntimeLogs() } + } + Button("取消", role: .cancel) {} } - - private var securityCard: some View { - AICard { - VStack(alignment: .leading, spacing: 12) { - AISectionTitle("安全与登录", systemImage: "lock.shield") - AIKeyValueRow("实例密码", value: "保存在本机应用沙盒") - AIKeyValueRow("登录会话", value: "仅保存在内存") - AIStatusBanner( - "实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。", - tint: AITheme.primary, - systemImage: "shield.checkered" - ) - } - } + .task { + await model.refresh() } + } + private var runtimeCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + AISectionTitle("应用信息", systemImage: "info.circle") + if let runtime = model.runtime { + AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))") + AIKeyValueRow("设备", value: runtime.deviceModel) + } else { + Text("正在读取应用信息…") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } + } + } + } + + private var storageCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + AISectionTitle("本地数据", systemImage: "externaldrive") + AIKeyValueRow("存储位置", value: "仅保存在本机应用沙盒") + AIKeyValueRow("当前占用", value: model.storageLabel) + } + } + } + + private var securityCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + AISectionTitle("隐私与安全", systemImage: "lock.shield") + AIKeyValueRow("实例密码", value: "保存在本机应用沙盒") + AIKeyValueRow("登录会话", value: "仅保存在内存") + AIStatusBanner( + "实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。", + tint: AITheme.primary, + systemImage: "shield.checkered" + ) + } + } + } + + private var dangerZoneCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + AISectionTitle("数据清理", systemImage: "trash.square", detail: "不可恢复") + Button { + showsClearDialog = true + } label: { + Label("清除运行日志", systemImage: "trash") + } + .buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true)) + } + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift index f9a538b..ae2aac5 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift @@ -41,6 +41,7 @@ public struct AIButtonStyle: ButtonStyle { case regular case large case toolbar + case toolbarInline } private let kind: Kind @@ -66,13 +67,16 @@ public struct AIButtonStyle: ButtonStyle { switch size { case .compact: AITheme.compactButtonHeight case .large: AITheme.largeButtonHeight - case .regular: AITheme.buttonHeight - case .toolbar: AITheme.toolbarButtonHeight + case .regular: AITheme.buttonHeight + case .toolbar: AITheme.toolbarButtonHeight + case .toolbarInline: AITheme.toolbarButtonHeight } - configuration.label + configuration.label .labelStyle(.titleAndIcon) - .font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold)) + .font( + .system(size: size == .compact || size == .toolbarInline ? 15 : 16, weight: .semibold) + ) .lineLimit(1) .foregroundStyle(kind.foregroundColor(isEnabled: isEnabled)) .padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0) @@ -83,19 +87,38 @@ public struct AIButtonStyle: ButtonStyle { .frame(maxWidth: fillsWidth ? .infinity : nil) .frame(width: fixedWidth) .background( - RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous) - .fill(kind.backgroundColor(isEnabled: isEnabled)) - .shadow( - color: (kind == .secondary || !isEnabled) ? .clear : .black.opacity(0.10), - radius: 12, - x: 0, - y: 5 + kind == .primary && isEnabled && size != .toolbarInline + ? AnyShapeStyle( + LinearGradient( + colors: [AITheme.primaryTop, AITheme.primaryBottom], + startPoint: .top, + endPoint: .bottom + ) ) + : AnyShapeStyle(kind.backgroundColor(isEnabled: isEnabled)) + ) + .shadow( + color: (kind == .secondary || !isEnabled) ? .clear : .black.opacity(0.10), + radius: 12, + x: 0, + y: 5 + ) + .clipShape( + RoundedRectangle( + cornerRadius: size == .toolbarInline ? AITheme.toolbarButtonHeight / 2 : AITheme.buttonRadius, + style: .continuous + ) ) - .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous) - .strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear) + RoundedRectangle( + cornerRadius: size == .toolbarInline ? AITheme.toolbarButtonHeight / 2 : AITheme.buttonRadius, + style: .continuous + ) + .strokeBorder( + size == .toolbarInline && kind == .primary + ? AITheme.primary.opacity(0.28) + : (kind == .secondary || !isEnabled ? AITheme.line : .clear) + ) } .scaleEffect(configuration.isPressed ? Self.pressedScale : 1) .opacity(configuration.isPressed ? 0.82 : 1) @@ -106,7 +129,7 @@ public struct AIButtonStyle: ButtonStyle { case .compact: return AITheme.compactButtonMinWidth case .regular: return AITheme.regularButtonMinWidth case .large: return 92 - case .toolbar: return AITheme.toolbarButtonMinWidth + case .toolbar, .toolbarInline: return AITheme.toolbarButtonMinWidth } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift index c6488ad..507c10d 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift @@ -11,8 +11,8 @@ public struct AICard: View { public var body: some View { content - .padding(padding) - .background(AITheme.surface) + .padding(padding) + .background(AICard.backgroundGradient) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)) .shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8) .overlay { @@ -22,6 +22,16 @@ public struct AICard: View { } } +private extension AICard { + static var backgroundGradient: LinearGradient { + LinearGradient( + colors: [AITheme.surfaceRaised, AITheme.surface], + startPoint: .top, + endPoint: .bottom + ) + } +} + public struct AITag: View { private let text: String private let tint: Color @@ -33,7 +43,7 @@ public struct AITag: View { public var body: some View { Text(text) - .font(.system(size: 12, weight: .semibold)) + .font(.caption2.weight(.semibold)) .foregroundStyle(tint) .lineLimit(1) .truncationMode(.middle) @@ -100,6 +110,7 @@ public struct AISectionTitle: View { Image(systemName: systemImage) .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.primary) + .frame(width: 22, height: 22) .accessibilityHidden(true) Text(title) .font(.headline.weight(.semibold)) @@ -152,6 +163,8 @@ public struct AIStatCard: View { VStack(alignment: .leading, spacing: 5) { Text(title) .font(.caption.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.85) .foregroundStyle(AITheme.muted) Text(value) .font(.title3.weight(.bold)) @@ -194,9 +207,9 @@ public struct AIStatusBanner: View { } .padding(15) .background(tint.opacity(0.10)) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) + RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous) .strokeBorder(tint.opacity(0.22)) } .accessibilityElement(children: .combine) @@ -282,7 +295,7 @@ public struct AIListCard: View { Text(subtitle) .font(.caption) .foregroundStyle(AITheme.muted) - .lineLimit(1) + .lineLimit(2) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .leading) } @@ -318,12 +331,28 @@ public struct AILoadingCard: View { VStack(spacing: 10) { ProgressView() .controlSize(.regular) + .frame(width: 44, height: 44) + .background(AITheme.control) + .clipShape(Circle()) Text(message) .font(.subheadline) .foregroundStyle(AITheme.muted) } .frame(maxWidth: .infinity) - .padding(.vertical, 18) + .padding(16) + .background( + LinearGradient( + colors: [AITheme.surfaceRaised, AITheme.surface], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)) + .shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8) + .overlay { + RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous) + .strokeBorder(AITheme.line) + } .accessibilityElement(children: .combine) } } @@ -367,9 +396,10 @@ private struct AIEqualWidthLayout: Layout { } let equalWidth = max(0, (width - spacing * CGFloat(count - 1)) / CGFloat(count)) - let height = subviews.map { - $0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height - }.max() ?? 0 + let height = + subviews.map { + $0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height + }.max() ?? 0 return CGSize(width: width, height: height) } @@ -454,7 +484,7 @@ public struct AISegmentedControl: View { selection = option.value } label: { Text(option.title) - .font(.subheadline.weight(isSelected ? .semibold : .medium)) + .font(.body.weight(isSelected ? .semibold : .medium)) .lineLimit(1) .minimumScaleFactor(0.75) .foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted) @@ -462,11 +492,18 @@ public struct AISegmentedControl: View { .background { if isSelected { RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(AITheme.primary) + .fill( + LinearGradient( + colors: [AITheme.primaryTop, AITheme.primaryBottom], + startPoint: .top, + endPoint: .bottom + ) + ) } } } .buttonStyle(.plain) + .contentShape(Rectangle()) .accessibilityLabel(option.title) .accessibilityAddTraits(isSelected ? .isSelected : []) } @@ -516,6 +553,7 @@ public struct AISearchField: View { public var body: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .medium)) .foregroundStyle(AITheme.muted) .accessibilityHidden(true) TextField(placeholder, text: $text) @@ -627,7 +665,7 @@ public struct AIToolbarButton: View { .font(.system(size: 15, weight: .semibold)) } } - .buttonStyle(AIButtonStyle(kind: kind, size: .toolbar)) + .buttonStyle(AIButtonStyle(kind: kind, size: .toolbarInline)) .accessibilityLabel(title) } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift index 19c057f..51c9055 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift @@ -38,6 +38,8 @@ private enum AIPalette { static let line = color(light: rgba(227, 230, 234, 1), dark: rgba(255, 255, 255, 0.09)) static let brand = color(light: rgba(13, 15, 18, 1), dark: rgba(8, 9, 11, 1)) static let primary = color(light: rgba(17, 20, 25, 1), dark: rgba(220, 178, 111, 1)) + static let primaryTop = color(light: rgba(43, 49, 58, 1), dark: rgba(240, 209, 154, 1)) + static let primaryBottom = color(light: rgba(15, 18, 22, 1), dark: rgba(198, 154, 92, 1)) static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(12, 14, 17, 1)) static let danger = color(light: rgba(190, 65, 57, 1), dark: rgba(242, 124, 113, 1)) static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(22, 9, 8, 1)) @@ -56,6 +58,8 @@ private enum AIPalette { static let onPrimary = Color.white static let onDanger = Color.white static let primary = Color(red: 0.067, green: 0.078, blue: 0.098) + static let primaryTop = Color(red: 0.17, green: 0.19, blue: 0.23) + static let primaryBottom = Color(red: 0.06, green: 0.07, blue: 0.09) static let danger = Color(red: 0.745, green: 0.255, blue: 0.224) static let warning = Color(red: 0.667, green: 0.435, blue: 0.09) static let success = Color(red: 0.196, green: 0.588, blue: 0.396) @@ -76,6 +80,8 @@ private enum AIPalette { static let onPrimary = color(255, 255, 255) static let onDanger = color(255, 255, 255) static let primary = color(17, 20, 25) + static let primaryTop = color(43, 49, 58) + static let primaryBottom = color(15, 18, 22) static let danger = color(190, 65, 57) static let warning = color(170, 111, 23) static let success = color(50, 150, 101) @@ -93,6 +99,8 @@ public enum AITheme { public static let line = AIPalette.line public static let brand = AIPalette.brand public static let primary = AIPalette.primary + public static let primaryTop = AIPalette.primaryTop + public static let primaryBottom = AIPalette.primaryBottom public static let onPrimary = AIPalette.onPrimary public static let danger = AIPalette.danger public static let onDanger = AIPalette.onDanger