feat(ios): refine adaptive theme and unified controls

This commit is contained in:
chick
2026-09-10 03:19:03 +08:00
parent c841748971
commit 31b2f33d15
12 changed files with 1161 additions and 984 deletions
@@ -38,29 +38,21 @@ struct InstanceDetailView: View {
private var header: some View { private var header: some View {
AICard { AICard {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
AISectionTitle("设备详情", systemImage: "iphone.gen3") AISectionTitle(
Button { "设备详情",
systemImage: "iphone.gen3",
actionTitle: revealsSensitive ? "隐藏敏感值" : "显示敏感值",
actionSystemImage: revealsSensitive ? "eye.slash" : "eye"
) {
revealsSensitive.toggle() revealsSensitive.toggle()
} label: {
Label(
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
systemImage: revealsSensitive ? "eye.slash" : "eye"
)
} }
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Text(model.instance.origin.absoluteString) Text(model.instance.origin.absoluteString)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
.lineLimit(2) .lineLimit(2)
.truncationMode(.middle) .truncationMode(.middle)
if model.isLoading { if model.isLoading {
Text("正在读取设备能力…") Text("正在读取设备详情…")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
} }
@@ -15,13 +15,17 @@ struct FleetView: View {
LazyVStack(spacing: 12) { LazyVStack(spacing: 12) {
searchField searchField
if let errorMessage = model.errorMessage { if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
} }
if model.selectionMode { if model.selectionMode {
selectionBar selectionBar
} }
if model.isLoading && model.rows.isEmpty { if model.isLoading && model.rows.isEmpty {
AILoadingCard("正在同步实例") AILoadingCard("正在同步实例数据")
} }
if model.rows.isEmpty && !model.isLoading { if model.rows.isEmpty && !model.isLoading {
AICard { AICard {
@@ -58,6 +62,7 @@ struct FleetView: View {
Label("批量选择", systemImage: "checklist") Label("批量选择", systemImage: "checklist")
} }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
Button { Button {
model.showingForm = true model.showingForm = true
@@ -65,6 +70,7 @@ struct FleetView: View {
Label("添加实例", systemImage: "plus") Label("添加实例", systemImage: "plus")
} }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
} }
} }
} }
@@ -163,6 +169,9 @@ struct FleetView: View {
Text(row.instance.name) Text(row.instance.name)
.font(.headline) .font(.headline)
.foregroundStyle(AITheme.ink) .foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer() Spacer()
AITag(row.status.label, tint: statusColor(row.status)) AITag(row.status.label, tint: statusColor(row.status))
} }
@@ -39,17 +39,13 @@ struct InstanceFormView: View {
.aiBottomActionBar { .aiBottomActionBar {
HStack(spacing: 10) { HStack(spacing: 10) {
Button("取消") { dismiss() } Button("取消") { dismiss() }
.buttonStyle( .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
AIButtonStyle( .frame(maxWidth: .infinity)
kind: .secondary,
size: .regular,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Button("保存实例") { save() } Button("保存实例") { save() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSave) .disabled(!canSave)
.frame(maxWidth: .infinity)
} }
} }
} }
@@ -62,9 +58,7 @@ struct InstanceFormView: View {
secure: Bool = false secure: Bool = false
) -> some View { ) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(title) AIFieldLabel(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
if secure { if secure {
SecureField(placeholder, text: text) SecureField(placeholder, text: text)
.textFieldStyle(AIFieldStyle()) .textFieldStyle(AIFieldStyle())
@@ -21,18 +21,22 @@ struct LogsView: View {
ScrollView { ScrollView {
LazyVStack(spacing: 12) { LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage { if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
} }
if model.snapshot == nil && model.isLoading { if model.snapshot == nil && model.isLoading {
AILoadingCard("正在同步日志中心") AILoadingCard("正在同步日志数据")
} else { } else {
stats stats
Picker("视图", selection: $tab) { AISegmentedControl(
Text("运行日志").tag(Tab.runtime) "日志视图",
Text("连接日志").tag(Tab.connections) selection: $tab,
Text("设备诊断").tag(Tab.diagnostics) options: [
} (value: Tab.runtime, title: "运行日志"),
.pickerStyle(.segmented) (value: Tab.connections, title: "连接日志"),
(value: Tab.diagnostics, title: "设备诊断"),
]
)
filterBar filterBar
@@ -79,7 +83,9 @@ struct LogsView: View {
} }
private var stats: some View { private var stats: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
) {
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary) AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success) AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger) AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
@@ -140,11 +146,12 @@ struct LogsView: View {
} }
private var runtimeSection: some View { private var runtimeSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条") AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty { if model.filteredRuntime.isEmpty {
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会汇总实例日志。") AICard {
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会同步实例运行记录。")
}
} else { } else {
ForEach(model.filteredRuntime) { entry in ForEach(model.filteredRuntime) { entry in
AIListCard( AIListCard(
@@ -164,23 +171,25 @@ struct LogsView: View {
} }
} }
} }
}
private var connectionSection: some View { private var connectionSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
AISectionTitle( AISectionTitle(
"连接探测记录", "连接探测记录",
systemImage: "dot.radiowaves.left.and.right", systemImage: "dot.radiowaves.left.and.right",
detail: "\(model.filteredConnections.count) 条" detail: "\(model.filteredConnections.count) 条",
) actionTitle: "清理",
compactActionButton("清理", systemImage: "trash") { actionSystemImage: "trash",
actionKind: .danger
) {
showsPruneDialog = true showsPruneDialog = true
} }
}
if model.filteredConnections.isEmpty { if model.filteredConnections.isEmpty {
AIEmptyState(systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。") AICard {
AIEmptyState(
systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。"
)
}
} else { } else {
ForEach(model.filteredConnections) { entry in ForEach(model.filteredConnections) { entry in
AIListCard( AIListCard(
@@ -190,21 +199,22 @@ struct LogsView: View {
statusTint: outcomeTint(entry.outcome), statusTint: outcomeTint(entry.outcome),
details: [ details: [
"HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms", "HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms",
entry.errorCode ?? "无错误码" entry.errorCode ?? "无错误码",
] ]
) )
} }
} }
} }
} }
}
private var diagnosticSection: some View { private var diagnosticSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
AISectionTitle("设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台") AISectionTitle(
"设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
if model.filteredDiagnostics.isEmpty { if model.filteredDiagnostics.isEmpty {
AICard {
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。") AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
}
} else { } else {
ForEach(model.filteredDiagnostics) { diagnostic in ForEach(model.filteredDiagnostics) { diagnostic in
AIListCard( AIListCard(
@@ -213,7 +223,7 @@ struct LogsView: View {
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning, statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
details: [ details: [
diagnostic.origin, diagnostic.origin,
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")" "版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")",
] ]
) { ) {
LazyVGrid( LazyVGrid(
@@ -236,29 +246,11 @@ struct LogsView: View {
} }
} }
} }
}
private var onlineCount: Int { private var onlineCount: Int {
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0 (model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
} }
private func compactActionButton(
_ title: String,
systemImage: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
private func levelTint(_ level: RuntimeLogLevel) -> Color { private func levelTint(_ level: RuntimeLogLevel) -> Color {
switch level { switch level {
case .info: return AITheme.success case .info: return AITheme.success
@@ -18,7 +18,7 @@ struct MessagesView: View {
AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle") AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
} }
if model.isLoading && model.filteredRows.isEmpty { if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息") AILoadingCard("正在同步消息数据")
} }
if model.filteredRows.isEmpty && !model.isLoading { if model.filteredRows.isEmpty && !model.isLoading {
AICard { AICard {
@@ -73,12 +73,13 @@ struct MessagesView: View {
private var filterBar: some View { private var filterBar: some View {
AICard { AICard {
VStack(spacing: 10) { VStack(spacing: 10) {
Picker("方向", selection: $model.direction) { AISegmentedControl(
ForEach(MessagesViewModel.DirectionFilter.allCases) { item in "消息方向",
Text(item.rawValue).tag(item) selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
} }
} )
.pickerStyle(.segmented)
Picker("实例", selection: $model.selectedInstanceID) { Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none) Text("全部实例").tag(Optional<UUID>.none)
@@ -100,6 +101,9 @@ struct MessagesView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber) Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.headline) .font(.headline)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer() Spacer()
AITag(message.direction.label, tint: message.direction == .outgoing ? AITheme.primary : AITheme.success) AITag(message.direction.label, tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
} }
@@ -107,16 +111,18 @@ struct MessagesView: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(AITheme.ink) .foregroundStyle(AITheme.ink)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
HStack { HStack(alignment: .center, spacing: 10) {
Text(message.instanceName) Text(message.instanceName)
.font(.caption) .font(.caption)
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.middle)
.layoutPriority(1) .layoutPriority(1)
Spacer() Spacer(minLength: 8)
Button("删除") { Button {
pendingDeleteMessage = message pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
} }
.buttonStyle( .buttonStyle(
AIButtonStyle( AIButtonStyle(
@@ -28,9 +28,7 @@ struct SendMessageFormView: View {
AICard { AICard {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("实例") AIFieldLabel("实例")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Picker("实例", selection: Binding( Picker("实例", selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id }, get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 } set: { model.selectedInstanceID = $0 }
@@ -43,25 +41,23 @@ struct SendMessageFormView: View {
.aiSelectSurface() .aiSelectSurface()
} }
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("收件号码")
TextField("收件号码", text: $phoneNumber) TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle()) .textFieldStyle(AIFieldStyle())
#if os(iOS) #if os(iOS)
.keyboardType(.phonePad) .keyboardType(.phonePad)
#endif #endif
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
} }
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("消息内容")
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(AIFieldStyle())
.lineLimit(4...8) .lineLimit(4...8)
} }
} }
}
if let validationMessage { if let validationMessage {
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
} }
@@ -73,17 +69,13 @@ struct SendMessageFormView: View {
.aiBottomActionBar { .aiBottomActionBar {
HStack(spacing: 10) { HStack(spacing: 10) {
Button("取消") { dismiss() } Button("取消") { dismiss() }
.buttonStyle( .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
AIButtonStyle( .frame(maxWidth: .infinity)
kind: .secondary,
size: .regular,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Button("发送") { send() } Button("发送") { send() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend) .disabled(!canSend)
.frame(maxWidth: .infinity)
} }
} }
} }
@@ -27,7 +27,8 @@ struct NotificationsView: View {
) )
} }
if let errorMessage = model.errorMessage { if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") AIStatusBanner(
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
} }
if let overview = model.overview { if let overview = model.overview {
@@ -121,11 +122,13 @@ struct NotificationsView: View {
} }
private func devices(_ overview: NotificationOverview) -> some View { private func devices(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个") AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个")
if overview.devices.isEmpty { if overview.devices.isEmpty {
AIEmptyState(systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。") AICard {
AIEmptyState(
systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。")
}
} else { } else {
ForEach(overview.devices) { device in ForEach(overview.devices) { device in
AIListCard( AIListCard(
@@ -137,14 +140,17 @@ struct NotificationsView: View {
} }
} }
} }
}
private func channels(_ overview: NotificationOverview) -> some View { private func channels(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
AISectionTitle("通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类") AISectionTitle(
"通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类"
)
if overview.config.channelTypes.isEmpty { if overview.config.channelTypes.isEmpty {
AIEmptyState(systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。") AICard {
AIEmptyState(
systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。")
}
} else { } else {
ForEach(overview.config.channelTypes) { channel in ForEach(overview.config.channelTypes) { channel in
AIListCard( AIListCard(
@@ -156,18 +162,21 @@ struct NotificationsView: View {
} }
} }
} }
}
private func queue(_ overview: NotificationOverview) -> some View { private func queue(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项") AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项")
if overview.queue.items.isEmpty { if overview.queue.items.isEmpty {
Text("队列为空。") AICard {
.font(.subheadline) AIEmptyState(
.foregroundStyle(AITheme.muted) systemImage: "tray.full",
title: "投递队列为空",
message: "新通知触发后会在这里等待投递。"
)
}
} else { } else {
ForEach(overview.queue.items) { item in ForEach(overview.queue.items) { item in
AICard {
AIListCard( AIListCard(
title: eventTypeLabel(item.eventType), title: eventTypeLabel(item.eventType),
subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))", subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))",
@@ -210,13 +219,17 @@ struct NotificationsView: View {
} }
private func logs(_ overview: NotificationOverview) -> some View { private func logs(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
AISectionTitle("投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条") AISectionTitle(
"投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条")
if overview.logs.entries.isEmpty { if overview.logs.entries.isEmpty {
Text("暂无投递日志。") AICard {
.font(.subheadline) AIEmptyState(
.foregroundStyle(AITheme.muted) systemImage: "clock.arrow.circlepath",
title: "暂无投递日志",
message: "通知送达或失败后会在这里记录。"
)
}
} else { } else {
ForEach(overview.logs.entries) { entry in ForEach(overview.logs.entries) { entry in
AIListCard( AIListCard(
@@ -229,10 +242,10 @@ struct NotificationsView: View {
} }
} }
} }
}
private func timestampText(_ value: String) -> String { private func timestampText(_ value: String) -> String {
let date = Self.fractionalTimestampFormatter.date(from: value) let date =
Self.fractionalTimestampFormatter.date(from: value)
?? Self.timestampFormatter.date(from: value) ?? Self.timestampFormatter.date(from: value)
guard let date else { return value } guard let date else { return value }
return date.formatted( return date.formatted(
@@ -92,6 +92,11 @@ struct SettingsView: View {
AISectionTitle("安全与登录", systemImage: "lock.shield") AISectionTitle("安全与登录", systemImage: "lock.shield")
AIKeyValueRow("实例密码", value: "保存在本机应用沙盒") AIKeyValueRow("实例密码", value: "保存在本机应用沙盒")
AIKeyValueRow("登录会话", value: "仅保存在内存") AIKeyValueRow("登录会话", value: "仅保存在内存")
AIStatusBanner(
"实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。",
tint: AITheme.primary,
systemImage: "shield.checkered"
)
} }
} }
} }
@@ -37,7 +37,6 @@ struct MultiSimAdminApp: App {
AITabBar(selection: $tab) AITabBar(selection: $tab)
} }
.tint(AITheme.primary) .tint(AITheme.primary)
.preferredColorScheme(.light)
#if os(iOS) #if os(iOS)
.toolbarBackground(AITheme.canvas, for: .navigationBar) .toolbarBackground(AITheme.canvas, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
@@ -22,7 +22,7 @@ public struct AIButtonStyle: ButtonStyle {
func backgroundColor(isEnabled: Bool) -> Color { func backgroundColor(isEnabled: Bool) -> Color {
switch self { switch self {
case .primary: return isEnabled ? AITheme.primary : AITheme.primary.opacity(0.35) case .primary: return isEnabled ? AITheme.primary : AITheme.primary.opacity(0.35)
case .secondary: return isEnabled ? AITheme.surface : AITheme.canvas case .secondary: return isEnabled ? AITheme.control : AITheme.canvas
case .danger: return isEnabled ? AITheme.danger : AITheme.danger.opacity(0.30) case .danger: return isEnabled ? AITheme.danger : AITheme.danger.opacity(0.30)
} }
} }
+140 -35
View File
@@ -36,7 +36,7 @@ public struct AITag: View {
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(tint) .foregroundStyle(tint)
.lineLimit(1) .lineLimit(1)
.fixedSize(horizontal: true, vertical: false) .truncationMode(.middle)
.padding(.horizontal, 9) .padding(.horizontal, 9)
.frame(minHeight: 26) .frame(minHeight: 26)
.background(tint.opacity(0.10)) .background(tint.opacity(0.10))
@@ -48,11 +48,47 @@ public struct AISectionTitle: View {
private let title: String private let title: String
private let systemImage: String private let systemImage: String
private let detail: String? private let detail: String?
private let footer: Footer
public init(_ title: String, systemImage: String, detail: String? = nil) { public init(
_ title: String,
systemImage: String,
detail: String? = nil,
actionTitle: String? = nil,
actionSystemImage: String? = nil,
actionKind: AIButtonStyle.Kind = .secondary,
action: (() -> Void)? = nil
) {
self.title = title self.title = title
self.systemImage = systemImage self.systemImage = systemImage
self.detail = detail self.detail = detail
self.footer = Footer(
title: actionTitle,
systemImage: actionSystemImage,
kind: actionKind,
action: action
)
}
private struct Footer: View {
let title: String?
let systemImage: String?
let kind: AIButtonStyle.Kind
let action: (() -> Void)?
var body: some View {
if let title, let action {
Button {
action()
} label: {
if let systemImage {
Label(title, systemImage: systemImage)
} else {
Text(title)
}
}
.buttonStyle(AIButtonStyle(kind: kind, size: .compact))
}
}
} }
public var body: some View { public var body: some View {
@@ -64,16 +100,33 @@ public struct AISectionTitle: View {
.font(.headline.weight(.bold)) .font(.headline.weight(.bold))
.foregroundStyle(AITheme.ink) .foregroundStyle(AITheme.ink)
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 8) .frame(maxWidth: .infinity, alignment: .leading)
if let detail { if let detail {
Text(detail) Text(detail)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
.lineLimit(1) .lineLimit(1)
} }
if Footer.self != EmptyView.self {
footer
} }
} }
} }
}
public struct AIFieldLabel: View {
private let title: String
public init(_ title: String) {
self.title = title
}
public var body: some View {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
}
}
public struct AIStatCard: View { public struct AIStatCard: View {
private let title: String private let title: String
@@ -157,8 +210,11 @@ public struct AIEmptyState: View {
public var body: some View { public var body: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: systemImage) Image(systemName: systemImage)
.font(.system(size: 28)) .font(.system(size: 26))
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.primary)
.frame(width: 54, height: 54)
.background(AITheme.primary.opacity(0.10))
.clipShape(Circle())
Text(title) Text(title)
.font(.headline.weight(.bold)) .font(.headline.weight(.bold))
.foregroundStyle(AITheme.ink) .foregroundStyle(AITheme.ink)
@@ -250,7 +306,6 @@ public struct AILoadingCard: View {
} }
public var body: some View { public var body: some View {
AICard {
VStack(spacing: 10) { VStack(spacing: 10) {
ProgressView() ProgressView()
.controlSize(.regular) .controlSize(.regular)
@@ -263,7 +318,6 @@ public struct AILoadingCard: View {
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
} }
}
public struct AIKeyValueRow: View { public struct AIKeyValueRow: View {
private let title: String private let title: String
@@ -288,6 +342,7 @@ public struct AIKeyValueRow: View {
.foregroundStyle(valueTint) .foregroundStyle(valueTint)
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
.lineLimit(2) .lineLimit(2)
.truncationMode(.middle)
.minimumScaleFactor(0.85) .minimumScaleFactor(0.85)
.textSelection(.enabled) .textSelection(.enabled)
} }
@@ -295,19 +350,66 @@ public struct AIKeyValueRow: View {
} }
} }
public struct AISegmentedControl<Value: Hashable>: View {
private let title: String
private let options: [(value: Value, title: String)]
@Binding private var selection: Value
public init(
_ title: String,
selection: Binding<Value>,
options: [(value: Value, title: String)]
) {
self.title = title
self._selection = selection
self.options = options
}
public var body: some View {
HStack(spacing: 4) {
ForEach(options, id: \.value) { option in
let isSelected = selection == option.value
Button {
selection = option.value
} label: {
Text(option.title)
.font(.subheadline.weight(isSelected ? .semibold : .medium))
.lineLimit(1)
.minimumScaleFactor(0.75)
.foregroundStyle(isSelected ? .white : AITheme.muted)
.frame(maxWidth: .infinity, minHeight: 34)
.background {
if isSelected {
RoundedRectangle(cornerRadius: 9)
.fill(AITheme.primary)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(option.title)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
.padding(4)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
.animation(.easeOut(duration: 0.16), value: selection)
.accessibilityElement(children: .contain)
.accessibilityLabel(title)
}
}
public struct AIFieldStyle: TextFieldStyle { public struct AIFieldStyle: TextFieldStyle {
public init() {} public init() {}
public func _body(configuration: TextField<Self._Label>) -> some View { public func _body(configuration: TextField<Self._Label>) -> some View {
configuration configuration
.padding(.horizontal, 12) .aiInputSurface()
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
} }
} }
@@ -327,7 +429,7 @@ public struct AISearchField: View {
} }
public var body: some View { public var body: some View {
HStack(spacing: 8) { HStack(spacing: 6) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
TextField(placeholder, text: $text) TextField(placeholder, text: $text)
@@ -343,19 +445,15 @@ public struct AISearchField: View {
} label: { } label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
.font(.system(size: 16, weight: .medium))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
.accessibilityLabel("清除搜索") .accessibilityLabel("清除搜索")
} }
} }
.padding(.horizontal, 12) .aiInputSurface(minHeight: AITheme.buttonHeight)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
} }
} }
@@ -372,17 +470,29 @@ public struct AIBrandBar: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(Color(red: 0.075, green: 0.118, blue: 0.188)) .background(AITheme.brand)
} }
} }
extension View { extension View {
func aiInputSurface(minHeight: CGFloat = AITheme.buttonHeight) -> some View {
self
.padding(.horizontal, 12)
.frame(minHeight: minHeight, alignment: .leading)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
}
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View { func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
safeAreaInset(edge: .bottom) { safeAreaInset(edge: .bottom) {
content() content()
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .padding(.bottom, 8)
.background(AITheme.canvas) .background(AITheme.canvas)
.overlay(alignment: .top) { .overlay(alignment: .top) {
Rectangle() Rectangle()
@@ -397,14 +507,9 @@ extension View {
.tint(AITheme.ink) .tint(AITheme.ink)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink) .foregroundStyle(AITheme.ink)
.frame(maxWidth: .infinity, minHeight: AITheme.buttonHeight, alignment: .leading) .labelsHidden()
.padding(.horizontal, 12) .frame(maxWidth: .infinity, alignment: .leading)
.background(AITheme.canvas) .aiInputSurface()
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
} }
} }
@@ -1,16 +1,86 @@
import SwiftUI import SwiftUI
public enum AITheme { #if canImport(UIKit)
public static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973) import UIKit
public static let surface = Color.white #elseif canImport(AppKit)
public static let ink = Color(red: 0.110, green: 0.153, blue: 0.220) import AppKit
public static let muted = Color(red: 0.435, green: 0.486, blue: 0.561) #endif
public static let line = Color(red: 0.878, green: 0.898, blue: 0.922)
public static let primary = Color(red: 0.067, green: 0.467, blue: 0.431) private enum AIPalette {
public static let danger = Color(red: 0.714, green: 0.302, blue: 0.282) #if canImport(UIKit)
public static let warning = Color(red: 0.722, green: 0.471, blue: 0.067) private static func dynamicColor(light: UIColor, dark: UIColor) -> Color {
public static let success = Color(red: 0.082, green: 0.584, blue: 0.439) Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
private static func rgba(
_ red: CGFloat,
_ green: CGFloat,
_ blue: CGFloat,
_ alpha: CGFloat
) -> UIColor {
UIColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha)
}
private static func color(light: UIColor, dark: UIColor) -> Color {
dynamicColor(light: light, dark: dark)
}
static let canvas = color(light: rgba(243, 245, 248, 1), dark: rgba(19, 22, 28, 1))
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(27, 31, 39, 1))
static let control = color(light: rgba(247, 249, 251, 1), dark: rgba(37, 44, 56, 1))
static let ink = color(light: rgba(28, 39, 56, 1), dark: rgba(235, 242, 247, 1))
static let muted = color(light: rgba(111, 124, 143, 1), dark: rgba(173, 183, 199, 1))
static let line = color(light: rgba(224, 229, 235, 1), dark: rgba(56, 63, 74, 1))
static let brand = color(light: rgba(19, 30, 48, 1), dark: rgba(14, 22, 35, 1))
static let primary = color(light: rgba(17, 119, 110, 1), dark: rgba(20, 142, 126, 1))
static let danger = color(light: rgba(182, 77, 72, 1), dark: rgba(193, 79, 71, 1))
static let warning = color(light: rgba(184, 120, 17, 1), dark: rgba(209, 141, 39, 1))
static let success = color(light: rgba(21, 149, 112, 1), dark: rgba(32, 161, 119, 1))
#elseif canImport(AppKit)
static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973)
static let surface = Color.white
static let control = Color(red: 0.969, green: 0.976, blue: 0.984)
static let ink = Color(red: 0.110, green: 0.153, blue: 0.220)
static let muted = Color(red: 0.435, green: 0.486, blue: 0.561)
static let line = Color(red: 0.878, green: 0.898, blue: 0.922)
static let brand = Color(red: 0.075, green: 0.118, blue: 0.188)
static let primary = Color(red: 0.067, green: 0.467, blue: 0.431)
static let danger = Color(red: 0.714, green: 0.302, blue: 0.282)
static let warning = Color(red: 0.722, green: 0.471, blue: 0.067)
static let success = Color(red: 0.082, green: 0.584, blue: 0.439)
#else
private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color {
Color(red: red / 255, green: green / 255, blue: blue / 255)
}
static let canvas = color(243, 245, 248)
static let surface = color(255, 255, 255)
static let control = color(247, 249, 251)
static let ink = color(28, 39, 56)
static let muted = color(111, 124, 143)
static let line = color(224, 229, 235)
static let brand = color(19, 30, 48)
static let primary = color(17, 119, 110)
static let danger = color(182, 77, 72)
static let warning = color(184, 120, 17)
static let success = color(21, 149, 112)
#endif
}
public enum AITheme {
public static let canvas = AIPalette.canvas
public static let surface = AIPalette.surface
public static let control = AIPalette.control
public static let ink = AIPalette.ink
public static let muted = AIPalette.muted
public static let line = AIPalette.line
public static let brand = AIPalette.brand
public static let primary = AIPalette.primary
public static let danger = AIPalette.danger
public static let warning = AIPalette.warning
public static let success = AIPalette.success
public static let buttonHeight: CGFloat = 44 public static let buttonHeight: CGFloat = 44
public static let compactButtonHeight: CGFloat = 40 public static let compactButtonHeight: CGFloat = 40