feat(ios): unify action hierarchy and surfaces

Refine primary and toolbar actions, align row buttons, normalize status surfaces, and split storage from destructive cleanup. Verified with Swift tests and the simulator build.
This commit is contained in:
chick
2026-09-12 16:15:57 +08:00
parent 0f2866a853
commit 20632be86a
7 changed files with 220 additions and 123 deletions
@@ -137,7 +137,8 @@ struct FleetView: View {
.buttonStyle( .buttonStyle(
AIButtonStyle( AIButtonStyle(
kind: .secondary, kind: .secondary,
size: .compact size: .regular,
fixedWidth: 96
) )
) )
@@ -55,7 +55,11 @@ struct LogsView: View {
.navigationTitle("日志中心") .navigationTitle("日志中心")
.toolbar { .toolbar {
ToolbarItemGroup(placement: .primaryAction) { ToolbarItemGroup(placement: .primaryAction) {
AIToolbarButton("刷新", systemImage: "arrow.clockwise") { AIToolbarButton(
"刷新",
systemImage: "arrow.clockwise",
kind: .primary
) {
Task { await model.refresh() } Task { await model.refresh() }
} }
.disabled(model.isLoading) .disabled(model.isLoading)
@@ -56,7 +56,7 @@ struct NotificationsView: View {
Text( Text(
model.isRetryingAll model.isRetryingAll
? "投递中" ? "投递中"
: (model.overview?.queue.total == 0 ? "暂无待投递" : "立即投递") : (model.overview?.queue.total == 0 ? "没有待投递" : "立即投递")
) )
} }
} }
@@ -198,7 +198,11 @@ struct NotificationsView: View {
Label("重试", systemImage: "arrow.clockwise") Label("重试", systemImage: "arrow.clockwise")
} }
.buttonStyle( .buttonStyle(
AIButtonStyle(kind: .secondary, size: .compact, fillsWidth: true) AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
) )
.disabled(model.busyQueueID != nil) .disabled(model.busyQueueID != nil)
@@ -207,7 +211,13 @@ struct NotificationsView: View {
} label: { } label: {
Label("删除", systemImage: "trash") Label("删除", systemImage: "trash")
} }
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact, fillsWidth: true)) .buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
.disabled(model.busyQueueID != nil) .disabled(model.busyQueueID != nil)
} }
} }
@@ -1,103 +1,116 @@
import SwiftUI import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@StateObject private var model: SettingsViewModel @StateObject private var model: SettingsViewModel
@State private var showsClearDialog = false @State private var showsClearDialog = false
init(model: SettingsViewModel? = nil) { init(model: SettingsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeSettingsModel()) _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)
} }
.background(AITheme.canvas.ignoresSafeArea())
var body: some View { .navigationTitle("设置")
ScrollView { .toolbar {
LazyVStack(spacing: 12) { ToolbarItemGroup(placement: .primaryAction) {
if let errorMessage = model.errorMessage { AIToolbarButton(
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") "刷新",
} systemImage: "arrow.clockwise",
if let notice = model.notice { kind: .primary
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
) { ) {
Button("清除运行日志", role: .destructive) { Task { await model.refresh() }
Task { await model.clearRuntimeLogs() }
}
Button("取消", role: .cancel) {}
}
.task {
await model.refresh()
} }
.disabled(model.isLoading)
}
} }
.refreshable {
private var runtimeCard: some View { await model.refresh()
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)
}
}
}
} }
.confirmationDialog(
private var storageCard: some View { "确认清除运行日志?此操作不可恢复。",
AICard { isPresented: $showsClearDialog,
VStack(alignment: .leading, spacing: 12) { titleVisibility: .visible
AISectionTitle("本地数据", systemImage: "externaldrive") ) {
AIKeyValueRow("存储位置", value: "仅保存在本机应用沙盒") Button("清除运行日志", role: .destructive) {
AIKeyValueRow("当前占用", value: model.storageLabel) Task { await model.clearRuntimeLogs() }
Button { }
showsClearDialog = true Button("取消", role: .cancel) {}
} label: {
Label("清除运行日志", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
}
}
} }
.task {
private var securityCard: some View { await model.refresh()
AICard {
VStack(alignment: .leading, spacing: 12) {
AISectionTitle("安全与登录", systemImage: "lock.shield")
AIKeyValueRow("实例密码", value: "保存在本机应用沙盒")
AIKeyValueRow("登录会话", value: "仅保存在内存")
AIStatusBanner(
"实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。",
tint: AITheme.primary,
systemImage: "shield.checkered"
)
}
}
} }
}
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))
}
}
}
} }
@@ -41,6 +41,7 @@ public struct AIButtonStyle: ButtonStyle {
case regular case regular
case large case large
case toolbar case toolbar
case toolbarInline
} }
private let kind: Kind private let kind: Kind
@@ -66,13 +67,16 @@ public struct AIButtonStyle: ButtonStyle {
switch size { switch size {
case .compact: AITheme.compactButtonHeight case .compact: AITheme.compactButtonHeight
case .large: AITheme.largeButtonHeight case .large: AITheme.largeButtonHeight
case .regular: AITheme.buttonHeight case .regular: AITheme.buttonHeight
case .toolbar: AITheme.toolbarButtonHeight case .toolbar: AITheme.toolbarButtonHeight
case .toolbarInline: AITheme.toolbarButtonHeight
} }
configuration.label configuration.label
.labelStyle(.titleAndIcon) .labelStyle(.titleAndIcon)
.font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold)) .font(
.system(size: size == .compact || size == .toolbarInline ? 15 : 16, weight: .semibold)
)
.lineLimit(1) .lineLimit(1)
.foregroundStyle(kind.foregroundColor(isEnabled: isEnabled)) .foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
.padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0) .padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0)
@@ -83,19 +87,38 @@ public struct AIButtonStyle: ButtonStyle {
.frame(maxWidth: fillsWidth ? .infinity : nil) .frame(maxWidth: fillsWidth ? .infinity : nil)
.frame(width: fixedWidth) .frame(width: fixedWidth)
.background( .background(
RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous) kind == .primary && isEnabled && size != .toolbarInline
.fill(kind.backgroundColor(isEnabled: isEnabled)) ? AnyShapeStyle(
.shadow( LinearGradient(
color: (kind == .secondary || !isEnabled) ? .clear : .black.opacity(0.10), colors: [AITheme.primaryTop, AITheme.primaryBottom],
radius: 12, startPoint: .top,
x: 0, endPoint: .bottom
y: 5 )
) )
: 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 { .overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous) RoundedRectangle(
.strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear) 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) .scaleEffect(configuration.isPressed ? Self.pressedScale : 1)
.opacity(configuration.isPressed ? 0.82 : 1) .opacity(configuration.isPressed ? 0.82 : 1)
@@ -106,7 +129,7 @@ public struct AIButtonStyle: ButtonStyle {
case .compact: return AITheme.compactButtonMinWidth case .compact: return AITheme.compactButtonMinWidth
case .regular: return AITheme.regularButtonMinWidth case .regular: return AITheme.regularButtonMinWidth
case .large: return 92 case .large: return 92
case .toolbar: return AITheme.toolbarButtonMinWidth case .toolbar, .toolbarInline: return AITheme.toolbarButtonMinWidth
} }
} }
@@ -11,8 +11,8 @@ public struct AICard<Content: View>: View {
public var body: some View { public var body: some View {
content content
.padding(padding) .padding(padding)
.background(AITheme.surface) .background(AICard.backgroundGradient)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8) .shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
.overlay { .overlay {
@@ -22,6 +22,16 @@ public struct AICard<Content: View>: View {
} }
} }
private extension AICard {
static var backgroundGradient: LinearGradient {
LinearGradient(
colors: [AITheme.surfaceRaised, AITheme.surface],
startPoint: .top,
endPoint: .bottom
)
}
}
public struct AITag: View { public struct AITag: View {
private let text: String private let text: String
private let tint: Color private let tint: Color
@@ -33,7 +43,7 @@ public struct AITag: View {
public var body: some View { public var body: some View {
Text(text) Text(text)
.font(.system(size: 12, weight: .semibold)) .font(.caption2.weight(.semibold))
.foregroundStyle(tint) .foregroundStyle(tint)
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.middle)
@@ -100,6 +110,7 @@ public struct AISectionTitle: View {
Image(systemName: systemImage) Image(systemName: systemImage)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.primary) .foregroundStyle(AITheme.primary)
.frame(width: 22, height: 22)
.accessibilityHidden(true) .accessibilityHidden(true)
Text(title) Text(title)
.font(.headline.weight(.semibold)) .font(.headline.weight(.semibold))
@@ -152,6 +163,8 @@ public struct AIStatCard: View {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text(title) Text(title)
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.85)
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
Text(value) Text(value)
.font(.title3.weight(.bold)) .font(.title3.weight(.bold))
@@ -194,9 +207,9 @@ public struct AIStatusBanner: View {
} }
.padding(15) .padding(15)
.background(tint.opacity(0.10)) .background(tint.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous))
.overlay { .overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous) RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous)
.strokeBorder(tint.opacity(0.22)) .strokeBorder(tint.opacity(0.22))
} }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
@@ -282,7 +295,7 @@ public struct AIListCard<Footer: View>: View {
Text(subtitle) Text(subtitle)
.font(.caption) .font(.caption)
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
.lineLimit(1) .lineLimit(2)
.truncationMode(.middle) .truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -318,12 +331,28 @@ public struct AILoadingCard: View {
VStack(spacing: 10) { VStack(spacing: 10) {
ProgressView() ProgressView()
.controlSize(.regular) .controlSize(.regular)
.frame(width: 44, height: 44)
.background(AITheme.control)
.clipShape(Circle())
Text(message) Text(message)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
} }
.frame(maxWidth: .infinity) .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) .accessibilityElement(children: .combine)
} }
} }
@@ -367,9 +396,10 @@ private struct AIEqualWidthLayout: Layout {
} }
let equalWidth = max(0, (width - spacing * CGFloat(count - 1)) / CGFloat(count)) let equalWidth = max(0, (width - spacing * CGFloat(count - 1)) / CGFloat(count))
let height = subviews.map { let height =
$0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height subviews.map {
}.max() ?? 0 $0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height
}.max() ?? 0
return CGSize(width: width, height: height) return CGSize(width: width, height: height)
} }
@@ -454,7 +484,7 @@ public struct AISegmentedControl<Value: Hashable>: View {
selection = option.value selection = option.value
} label: { } label: {
Text(option.title) Text(option.title)
.font(.subheadline.weight(isSelected ? .semibold : .medium)) .font(.body.weight(isSelected ? .semibold : .medium))
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.75) .minimumScaleFactor(0.75)
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted) .foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
@@ -462,11 +492,18 @@ public struct AISegmentedControl<Value: Hashable>: View {
.background { .background {
if isSelected { if isSelected {
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(AITheme.primary) .fill(
LinearGradient(
colors: [AITheme.primaryTop, AITheme.primaryBottom],
startPoint: .top,
endPoint: .bottom
)
)
} }
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityLabel(option.title) .accessibilityLabel(option.title)
.accessibilityAddTraits(isSelected ? .isSelected : []) .accessibilityAddTraits(isSelected ? .isSelected : [])
} }
@@ -516,6 +553,7 @@ public struct AISearchField: View {
public var body: some View { public var body: some View {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
.accessibilityHidden(true) .accessibilityHidden(true)
TextField(placeholder, text: $text) TextField(placeholder, text: $text)
@@ -627,7 +665,7 @@ public struct AIToolbarButton: View {
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
} }
} }
.buttonStyle(AIButtonStyle(kind: kind, size: .toolbar)) .buttonStyle(AIButtonStyle(kind: kind, size: .toolbarInline))
.accessibilityLabel(title) .accessibilityLabel(title)
} }
} }
@@ -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 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 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 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 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 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)) 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 onPrimary = Color.white
static let onDanger = Color.white static let onDanger = Color.white
static let primary = Color(red: 0.067, green: 0.078, blue: 0.098) 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 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 warning = Color(red: 0.667, green: 0.435, blue: 0.09)
static let success = Color(red: 0.196, green: 0.588, blue: 0.396) 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 onPrimary = color(255, 255, 255)
static let onDanger = color(255, 255, 255) static let onDanger = color(255, 255, 255)
static let primary = color(17, 20, 25) 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 danger = color(190, 65, 57)
static let warning = color(170, 111, 23) static let warning = color(170, 111, 23)
static let success = color(50, 150, 101) static let success = color(50, 150, 101)
@@ -93,6 +99,8 @@ public enum AITheme {
public static let line = AIPalette.line public static let line = AIPalette.line
public static let brand = AIPalette.brand public static let brand = AIPalette.brand
public static let primary = AIPalette.primary 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 onPrimary = AIPalette.onPrimary
public static let danger = AIPalette.danger public static let danger = AIPalette.danger
public static let onDanger = AIPalette.onDanger public static let onDanger = AIPalette.onDanger