feat(ios): refine adaptive theme and unified controls
This commit is contained in:
@@ -38,29 +38,21 @@ struct InstanceDetailView: View {
|
||||
private var header: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("设备详情", systemImage: "iphone.gen3")
|
||||
Button {
|
||||
AISectionTitle(
|
||||
"设备详情",
|
||||
systemImage: "iphone.gen3",
|
||||
actionTitle: revealsSensitive ? "隐藏敏感值" : "显示敏感值",
|
||||
actionSystemImage: revealsSensitive ? "eye.slash" : "eye"
|
||||
) {
|
||||
revealsSensitive.toggle()
|
||||
} label: {
|
||||
Label(
|
||||
revealsSensitive ? "隐藏敏感值" : "显示敏感值",
|
||||
systemImage: revealsSensitive ? "eye.slash" : "eye"
|
||||
)
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .secondary,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.footerSecondaryButtonWidth
|
||||
)
|
||||
)
|
||||
Text(model.instance.origin.absoluteString)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
if model.isLoading {
|
||||
Text("正在读取设备能力…")
|
||||
Text("正在读取设备详情…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
|
||||
@@ -14,14 +14,18 @@ struct FleetView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
searchField
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage,
|
||||
tint: AITheme.danger,
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
if model.selectionMode {
|
||||
selectionBar
|
||||
}
|
||||
if model.isLoading && model.rows.isEmpty {
|
||||
AILoadingCard("正在同步实例")
|
||||
AILoadingCard("正在同步实例数据")
|
||||
}
|
||||
if model.rows.isEmpty && !model.isLoading {
|
||||
AICard {
|
||||
@@ -58,6 +62,7 @@ struct FleetView: View {
|
||||
Label("批量选择", systemImage: "checklist")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Button {
|
||||
model.showingForm = true
|
||||
@@ -65,6 +70,7 @@ struct FleetView: View {
|
||||
Label("添加实例", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +169,9 @@ struct FleetView: View {
|
||||
Text(row.instance.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
AITag(row.status.label, tint: statusColor(row.status))
|
||||
}
|
||||
|
||||
@@ -39,17 +39,13 @@ struct InstanceFormView: View {
|
||||
.aiBottomActionBar {
|
||||
HStack(spacing: 10) {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .secondary,
|
||||
size: .regular,
|
||||
fixedWidth: AITheme.footerSecondaryButtonWidth
|
||||
)
|
||||
)
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Button("保存实例") { save() }
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!canSave)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,9 +58,7 @@ struct InstanceFormView: View {
|
||||
secure: Bool = false
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
AIFieldLabel(title)
|
||||
if secure {
|
||||
SecureField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
|
||||
@@ -1,286 +1,278 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogsView: View {
|
||||
private enum Tab: Hashable {
|
||||
case runtime
|
||||
case connections
|
||||
case diagnostics
|
||||
}
|
||||
private enum Tab: Hashable {
|
||||
case runtime
|
||||
case connections
|
||||
case diagnostics
|
||||
}
|
||||
|
||||
@StateObject private var model: LogsViewModel
|
||||
@State private var tab: Tab = .runtime
|
||||
@State private var showsPruneDialog = false
|
||||
@StateObject private var model: LogsViewModel
|
||||
@State private var tab: Tab = .runtime
|
||||
@State private var showsPruneDialog = false
|
||||
|
||||
init(model: LogsViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
|
||||
}
|
||||
init(model: LogsViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.snapshot == nil && model.isLoading {
|
||||
AILoadingCard("正在同步日志中心")
|
||||
} else {
|
||||
stats
|
||||
Picker("视图", selection: $tab) {
|
||||
Text("运行日志").tag(Tab.runtime)
|
||||
Text("连接日志").tag(Tab.connections)
|
||||
Text("设备诊断").tag(Tab.diagnostics)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
filterBar
|
||||
|
||||
switch tab {
|
||||
case .runtime:
|
||||
runtimeSection
|
||||
case .connections:
|
||||
connectionSection
|
||||
case .diagnostics:
|
||||
diagnosticSection
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("日志中心")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
AIToolbarButton("刷新", systemImage: "arrow.clockwise") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"清理 7 天前的连接日志?操作不可恢复。",
|
||||
isPresented: $showsPruneDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("清理连接日志", role: .destructive) {
|
||||
Task {
|
||||
_ = await model.pruneConnections(olderThanDays: 7)
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var stats: some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
|
||||
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
|
||||
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
|
||||
}
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
|
||||
Group {
|
||||
if tab == .runtime {
|
||||
HStack(spacing: 10) {
|
||||
Picker("来源", selection: $model.selectedSource) {
|
||||
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
|
||||
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
|
||||
Text(source.label).tag(Optional(source))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
|
||||
Picker("级别", selection: $model.selectedLevel) {
|
||||
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
|
||||
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
|
||||
Text(level.label).tag(Optional(level))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if tab == .connections {
|
||||
Picker("结果", selection: $model.selectedOutcome) {
|
||||
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
|
||||
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
|
||||
Text(outcome.label).tag(Optional(outcome))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: tab)
|
||||
}
|
||||
}
|
||||
|
||||
private var runtimeSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
|
||||
if model.filteredRuntime.isEmpty {
|
||||
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会汇总实例日志。")
|
||||
} else {
|
||||
ForEach(model.filteredRuntime) { entry in
|
||||
AIListCard(
|
||||
title: entry.message,
|
||||
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
|
||||
status: entry.level.label,
|
||||
statusTint: levelTint(entry.level),
|
||||
details: {
|
||||
var values = [entry.source.label]
|
||||
if let duration = entry.durationMs {
|
||||
values.append("耗时 \(duration) ms")
|
||||
}
|
||||
return values
|
||||
}()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
AISectionTitle(
|
||||
"连接探测记录",
|
||||
systemImage: "dot.radiowaves.left.and.right",
|
||||
detail: "\(model.filteredConnections.count) 条"
|
||||
)
|
||||
compactActionButton("清理", systemImage: "trash") {
|
||||
showsPruneDialog = true
|
||||
}
|
||||
}
|
||||
if model.filteredConnections.isEmpty {
|
||||
AIEmptyState(systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。")
|
||||
} else {
|
||||
ForEach(model.filteredConnections) { entry in
|
||||
AIListCard(
|
||||
title: entry.instanceName,
|
||||
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
|
||||
status: entry.outcome.label,
|
||||
statusTint: outcomeTint(entry.outcome),
|
||||
details: [
|
||||
"HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms",
|
||||
entry.errorCode ?? "无错误码"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
|
||||
if model.filteredDiagnostics.isEmpty {
|
||||
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
|
||||
} else {
|
||||
ForEach(model.filteredDiagnostics) { diagnostic in
|
||||
AIListCard(
|
||||
title: diagnostic.name,
|
||||
status: diagnostic.healthStatus ?? "未知",
|
||||
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
|
||||
details: [
|
||||
diagnostic.origin,
|
||||
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")"
|
||||
]
|
||||
) {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
|
||||
alignment: .leading,
|
||||
spacing: 6
|
||||
) {
|
||||
ForEach(diagnostic.featureStatus) { feature in
|
||||
AITag(feature.name, tint: featureTint(feature.state))
|
||||
}
|
||||
if let connection = diagnostic.connection, connection.total > 1 {
|
||||
AITag(
|
||||
"延迟 \(connection.averageDurationMs) ms",
|
||||
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var onlineCount: Int {
|
||||
(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
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.snapshot == nil && model.isLoading {
|
||||
AILoadingCard("正在同步日志数据")
|
||||
} else {
|
||||
stats
|
||||
AISegmentedControl(
|
||||
"日志视图",
|
||||
selection: $tab,
|
||||
options: [
|
||||
(value: Tab.runtime, title: "运行日志"),
|
||||
(value: Tab.connections, title: "连接日志"),
|
||||
(value: Tab.diagnostics, title: "设备诊断"),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func levelTint(_ level: RuntimeLogLevel) -> Color {
|
||||
switch level {
|
||||
case .info: return AITheme.success
|
||||
case .warning: return AITheme.warning
|
||||
case .error: return AITheme.danger
|
||||
}
|
||||
}
|
||||
filterBar
|
||||
|
||||
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
|
||||
switch outcome {
|
||||
case .success: return AITheme.success
|
||||
case .stale: return AITheme.warning
|
||||
case .failed: return AITheme.danger
|
||||
case .unsupported: return AITheme.muted
|
||||
switch tab {
|
||||
case .runtime:
|
||||
runtimeSection
|
||||
case .connections:
|
||||
connectionSection
|
||||
case .diagnostics:
|
||||
diagnosticSection
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("日志中心")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
AIToolbarButton("刷新", systemImage: "arrow.clockwise") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"清理 7 天前的连接日志?操作不可恢复。",
|
||||
isPresented: $showsPruneDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("清理连接日志", role: .destructive) {
|
||||
Task {
|
||||
_ = await model.pruneConnections(olderThanDays: 7)
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func featureTint(_ state: FeatureStatus.State) -> Color {
|
||||
switch state {
|
||||
case .ready: return AITheme.success
|
||||
case .requiresLogin: return AITheme.warning
|
||||
case .unavailable: return AITheme.muted
|
||||
}
|
||||
private var stats: some View {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
|
||||
) {
|
||||
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
|
||||
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
|
||||
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
|
||||
}
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
|
||||
Group {
|
||||
if tab == .runtime {
|
||||
HStack(spacing: 10) {
|
||||
Picker("来源", selection: $model.selectedSource) {
|
||||
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
|
||||
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
|
||||
Text(source.label).tag(Optional(source))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
|
||||
Picker("级别", selection: $model.selectedLevel) {
|
||||
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
|
||||
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
|
||||
Text(level.label).tag(Optional(level))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if tab == .connections {
|
||||
Picker("结果", selection: $model.selectedOutcome) {
|
||||
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
|
||||
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
|
||||
Text(outcome.label).tag(Optional(outcome))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: tab)
|
||||
}
|
||||
}
|
||||
|
||||
private var runtimeSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
|
||||
if model.filteredRuntime.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会同步实例运行记录。")
|
||||
}
|
||||
} else {
|
||||
ForEach(model.filteredRuntime) { entry in
|
||||
AIListCard(
|
||||
title: entry.message,
|
||||
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
|
||||
status: entry.level.label,
|
||||
statusTint: levelTint(entry.level),
|
||||
details: {
|
||||
var values = [entry.source.label]
|
||||
if let duration = entry.durationMs {
|
||||
values.append("耗时 \(duration) ms")
|
||||
}
|
||||
return values
|
||||
}()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle(
|
||||
"连接探测记录",
|
||||
systemImage: "dot.radiowaves.left.and.right",
|
||||
detail: "\(model.filteredConnections.count) 条",
|
||||
actionTitle: "清理",
|
||||
actionSystemImage: "trash",
|
||||
actionKind: .danger
|
||||
) {
|
||||
showsPruneDialog = true
|
||||
}
|
||||
if model.filteredConnections.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(model.filteredConnections) { entry in
|
||||
AIListCard(
|
||||
title: entry.instanceName,
|
||||
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
|
||||
status: entry.outcome.label,
|
||||
statusTint: outcomeTint(entry.outcome),
|
||||
details: [
|
||||
"HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms",
|
||||
entry.errorCode ?? "无错误码",
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle(
|
||||
"设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
|
||||
if model.filteredDiagnostics.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
|
||||
}
|
||||
} else {
|
||||
ForEach(model.filteredDiagnostics) { diagnostic in
|
||||
AIListCard(
|
||||
title: diagnostic.name,
|
||||
status: diagnostic.healthStatus ?? "未知",
|
||||
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
|
||||
details: [
|
||||
diagnostic.origin,
|
||||
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")",
|
||||
]
|
||||
) {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
|
||||
alignment: .leading,
|
||||
spacing: 6
|
||||
) {
|
||||
ForEach(diagnostic.featureStatus) { feature in
|
||||
AITag(feature.name, tint: featureTint(feature.state))
|
||||
}
|
||||
if let connection = diagnostic.connection, connection.total > 1 {
|
||||
AITag(
|
||||
"延迟 \(connection.averageDurationMs) ms",
|
||||
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var onlineCount: Int {
|
||||
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
|
||||
}
|
||||
|
||||
private func levelTint(_ level: RuntimeLogLevel) -> Color {
|
||||
switch level {
|
||||
case .info: return AITheme.success
|
||||
case .warning: return AITheme.warning
|
||||
case .error: return AITheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
|
||||
switch outcome {
|
||||
case .success: return AITheme.success
|
||||
case .stale: return AITheme.warning
|
||||
case .failed: return AITheme.danger
|
||||
case .unsupported: return AITheme.muted
|
||||
}
|
||||
}
|
||||
|
||||
private func featureTint(_ state: FeatureStatus.State) -> Color {
|
||||
switch state {
|
||||
case .ready: return AITheme.success
|
||||
case .requiresLogin: return AITheme.warning
|
||||
case .unavailable: return AITheme.muted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ struct MessagesView: View {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.isLoading && model.filteredRows.isEmpty {
|
||||
AILoadingCard("正在同步消息")
|
||||
AILoadingCard("正在同步消息数据")
|
||||
}
|
||||
if model.filteredRows.isEmpty && !model.isLoading {
|
||||
AICard {
|
||||
@@ -73,12 +73,13 @@ struct MessagesView: View {
|
||||
private var filterBar: some View {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
Picker("方向", selection: $model.direction) {
|
||||
ForEach(MessagesViewModel.DirectionFilter.allCases) { item in
|
||||
Text(item.rawValue).tag(item)
|
||||
AISegmentedControl(
|
||||
"消息方向",
|
||||
selection: $model.direction,
|
||||
options: MessagesViewModel.DirectionFilter.allCases.map {
|
||||
(value: $0, title: $0.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
)
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
@@ -100,6 +101,9 @@ struct MessagesView: View {
|
||||
HStack(spacing: 8) {
|
||||
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
AITag(message.direction.label, tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
|
||||
}
|
||||
@@ -107,16 +111,18 @@ struct MessagesView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text(message.instanceName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
Button("删除") {
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
pendingDeleteMessage = message
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
|
||||
@@ -28,9 +28,7 @@ struct SendMessageFormView: View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("实例")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
AIFieldLabel("实例")
|
||||
Picker("实例", selection: Binding(
|
||||
get: { model.selectedInstanceID ?? model.instances.first?.id },
|
||||
set: { model.selectedInstanceID = $0 }
|
||||
@@ -43,23 +41,21 @@ struct SendMessageFormView: View {
|
||||
.aiSelectSurface()
|
||||
}
|
||||
|
||||
TextField("收件号码", text: $phoneNumber)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
#if os(iOS)
|
||||
.keyboardType(.phonePad)
|
||||
#endif
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("收件号码")
|
||||
TextField("收件号码", text: $phoneNumber)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
#if os(iOS)
|
||||
.keyboardType(.phonePad)
|
||||
#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)
|
||||
}
|
||||
.lineLimit(4...8)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("消息内容")
|
||||
TextField("消息内容", text: $content, axis: .vertical)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.lineLimit(4...8)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let validationMessage {
|
||||
@@ -73,17 +69,13 @@ struct SendMessageFormView: View {
|
||||
.aiBottomActionBar {
|
||||
HStack(spacing: 10) {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .secondary,
|
||||
size: .regular,
|
||||
fixedWidth: AITheme.footerSecondaryButtonWidth
|
||||
)
|
||||
)
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Button("发送") { send() }
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!canSend)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,314 +1,327 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NotificationsView: View {
|
||||
@StateObject private var model: NotificationsViewModel
|
||||
@State private var pendingDeleteQueueID: String?
|
||||
private static let timestampFormatter = ISO8601DateFormatter()
|
||||
private static let fractionalTimestampFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
@StateObject private var model: NotificationsViewModel
|
||||
@State private var pendingDeleteQueueID: String?
|
||||
private static let timestampFormatter = ISO8601DateFormatter()
|
||||
private static let fractionalTimestampFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
init(model: NotificationsViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
|
||||
}
|
||||
init(model: NotificationsViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let actionMessage = model.actionMessage {
|
||||
AIStatusBanner(
|
||||
actionMessage,
|
||||
tint: model.actionSucceeded ? AITheme.success : AITheme.danger,
|
||||
systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
|
||||
if let overview = model.overview {
|
||||
stats(overview)
|
||||
queue(overview)
|
||||
channels(overview)
|
||||
logs(overview)
|
||||
devices(overview)
|
||||
} else if model.isLoading {
|
||||
AILoadingCard("正在加载通知中心")
|
||||
} else {
|
||||
empty
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("通知中心")
|
||||
.aiBottomActionBar {
|
||||
Button {
|
||||
Task { await model.retryAll() }
|
||||
} label: {
|
||||
Label("立即投递", systemImage: "paperplane")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"删除这条通知队列记录?操作不可恢复。",
|
||||
isPresented: Binding(
|
||||
get: { pendingDeleteQueueID != nil },
|
||||
set: { if !$0 { pendingDeleteQueueID = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("删除队列记录", role: .destructive) {
|
||||
guard let id = pendingDeleteQueueID else { return }
|
||||
Task {
|
||||
if let item = model.overview?.queue.items.first(where: { $0.id == id }) {
|
||||
await model.deleteQueue(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var empty: some View {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "bell",
|
||||
title: "暂无通知数据",
|
||||
message: "下拉刷新后从已配置实例同步。"
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let actionMessage = model.actionMessage {
|
||||
AIStatusBanner(
|
||||
actionMessage,
|
||||
tint: model.actionSucceeded ? AITheme.success : AITheme.danger,
|
||||
systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
|
||||
private func stats(_ overview: NotificationOverview) -> some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
AIStatCard(
|
||||
title: "通道",
|
||||
value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)",
|
||||
detail: "已启用 / 总数",
|
||||
tint: AITheme.primary
|
||||
)
|
||||
AIStatCard(
|
||||
title: "规则",
|
||||
value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)",
|
||||
detail: "已启用 / 总数",
|
||||
tint: AITheme.primary
|
||||
)
|
||||
AIStatCard(
|
||||
title: "队列",
|
||||
value: "\(overview.queue.pending + overview.queue.retrying)",
|
||||
detail: "等待与重试",
|
||||
tint: AITheme.warning
|
||||
)
|
||||
AIStatCard(
|
||||
title: "失败",
|
||||
value: "\(overview.queue.failed + overview.logs.failed)",
|
||||
detail: "队列与投递",
|
||||
tint: AITheme.danger
|
||||
)
|
||||
if let overview = model.overview {
|
||||
stats(overview)
|
||||
queue(overview)
|
||||
channels(overview)
|
||||
logs(overview)
|
||||
devices(overview)
|
||||
} else if model.isLoading {
|
||||
AILoadingCard("正在加载通知中心")
|
||||
} else {
|
||||
empty
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("通知中心")
|
||||
.aiBottomActionBar {
|
||||
Button {
|
||||
Task { await model.retryAll() }
|
||||
} label: {
|
||||
Label("立即投递", systemImage: "paperplane")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"删除这条通知队列记录?操作不可恢复。",
|
||||
isPresented: Binding(
|
||||
get: { pendingDeleteQueueID != nil },
|
||||
set: { if !$0 { pendingDeleteQueueID = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("删除队列记录", role: .destructive) {
|
||||
guard let id = pendingDeleteQueueID else { return }
|
||||
Task {
|
||||
if let item = model.overview?.queue.items.first(where: { $0.id == id }) {
|
||||
await model.deleteQueue(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func devices(_ overview: NotificationOverview) -> some View {
|
||||
private var empty: some View {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "bell",
|
||||
title: "暂无通知数据",
|
||||
message: "下拉刷新后从已配置实例同步。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func stats(_ overview: NotificationOverview) -> some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
AIStatCard(
|
||||
title: "通道",
|
||||
value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)",
|
||||
detail: "已启用 / 总数",
|
||||
tint: AITheme.primary
|
||||
)
|
||||
AIStatCard(
|
||||
title: "规则",
|
||||
value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)",
|
||||
detail: "已启用 / 总数",
|
||||
tint: AITheme.primary
|
||||
)
|
||||
AIStatCard(
|
||||
title: "队列",
|
||||
value: "\(overview.queue.pending + overview.queue.retrying)",
|
||||
detail: "等待与重试",
|
||||
tint: AITheme.warning
|
||||
)
|
||||
AIStatCard(
|
||||
title: "失败",
|
||||
value: "\(overview.queue.failed + overview.logs.failed)",
|
||||
detail: "队列与投递",
|
||||
tint: AITheme.danger
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func devices(_ overview: NotificationOverview) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个")
|
||||
if overview.devices.isEmpty {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个")
|
||||
if overview.devices.isEmpty {
|
||||
AIEmptyState(systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。")
|
||||
} else {
|
||||
ForEach(overview.devices) { device in
|
||||
AIListCard(
|
||||
title: device.name,
|
||||
status: deviceState(device.state),
|
||||
statusTint: deviceTint(device.state)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AIEmptyState(
|
||||
systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。")
|
||||
}
|
||||
} else {
|
||||
ForEach(overview.devices) { device in
|
||||
AIListCard(
|
||||
title: device.name,
|
||||
status: deviceState(device.state),
|
||||
statusTint: deviceTint(device.state)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func channels(_ overview: NotificationOverview) -> some View {
|
||||
private func channels(_ overview: NotificationOverview) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle(
|
||||
"通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类"
|
||||
)
|
||||
if overview.config.channelTypes.isEmpty {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类")
|
||||
if overview.config.channelTypes.isEmpty {
|
||||
AIEmptyState(systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。")
|
||||
} else {
|
||||
ForEach(overview.config.channelTypes) { channel in
|
||||
AIListCard(
|
||||
title: channelTypeLabel(channel.type),
|
||||
status: "\(channel.enabled)/\(channel.total)",
|
||||
statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AIEmptyState(
|
||||
systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。")
|
||||
}
|
||||
} else {
|
||||
ForEach(overview.config.channelTypes) { channel in
|
||||
AIListCard(
|
||||
title: channelTypeLabel(channel.type),
|
||||
status: "\(channel.enabled)/\(channel.total)",
|
||||
statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func queue(_ overview: NotificationOverview) -> some View {
|
||||
private func queue(_ overview: NotificationOverview) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项")
|
||||
if overview.queue.items.isEmpty {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项")
|
||||
if overview.queue.items.isEmpty {
|
||||
Text("队列为空。")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
} else {
|
||||
ForEach(overview.queue.items) { item in
|
||||
AIListCard(
|
||||
title: eventTypeLabel(item.eventType),
|
||||
subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))",
|
||||
status: statusLabel(item.status),
|
||||
statusTint: statusTint(item.status)
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await model.retryQueue(item) }
|
||||
} label: {
|
||||
Label("重试", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .secondary,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
)
|
||||
.disabled(model.busyQueueID == item.id)
|
||||
|
||||
Button {
|
||||
pendingDeleteQueueID = item.id
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .danger,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AIEmptyState(
|
||||
systemImage: "tray.full",
|
||||
title: "投递队列为空",
|
||||
message: "新通知触发后会在这里等待投递。"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(overview.queue.items) { item in
|
||||
AICard {
|
||||
AIListCard(
|
||||
title: eventTypeLabel(item.eventType),
|
||||
subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))",
|
||||
status: statusLabel(item.status),
|
||||
statusTint: statusTint(item.status)
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await model.retryQueue(item) }
|
||||
} label: {
|
||||
Label("重试", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .secondary,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
)
|
||||
.disabled(model.busyQueueID == item.id)
|
||||
|
||||
private func logs(_ overview: NotificationOverview) -> some View {
|
||||
Button {
|
||||
pendingDeleteQueueID = item.id
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .danger,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func logs(_ overview: NotificationOverview) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle(
|
||||
"投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条")
|
||||
if overview.logs.entries.isEmpty {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条")
|
||||
if overview.logs.entries.isEmpty {
|
||||
Text("暂无投递日志。")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
} else {
|
||||
ForEach(overview.logs.entries) { entry in
|
||||
AIListCard(
|
||||
title: eventTypeLabel(entry.eventType),
|
||||
subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))",
|
||||
status: statusLabel(entry.status),
|
||||
statusTint: statusTint(entry.status)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AIEmptyState(
|
||||
systemImage: "clock.arrow.circlepath",
|
||||
title: "暂无投递日志",
|
||||
message: "通知送达或失败后会在这里记录。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func timestampText(_ value: String) -> String {
|
||||
let date = Self.fractionalTimestampFormatter.date(from: value)
|
||||
?? Self.timestampFormatter.date(from: value)
|
||||
guard let date else { return value }
|
||||
return date.formatted(
|
||||
Date.FormatStyle(date: .numeric, time: .shortened)
|
||||
.locale(Locale(identifier: "zh_CN"))
|
||||
)
|
||||
}
|
||||
|
||||
private func deviceState(_ state: NotificationDeviceState) -> String {
|
||||
switch state {
|
||||
case .ready: return "在线"
|
||||
case .unauthorized: return "需登录"
|
||||
case .unavailable: return "离线"
|
||||
case .failed: return "异常"
|
||||
} else {
|
||||
ForEach(overview.logs.entries) { entry in
|
||||
AIListCard(
|
||||
title: eventTypeLabel(entry.eventType),
|
||||
subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))",
|
||||
status: statusLabel(entry.status),
|
||||
statusTint: statusTint(entry.status)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deviceTint(_ state: NotificationDeviceState) -> Color {
|
||||
switch state {
|
||||
case .ready: return AITheme.success
|
||||
case .unauthorized: return AITheme.warning
|
||||
case .unavailable: return AITheme.muted
|
||||
case .failed: return AITheme.danger
|
||||
}
|
||||
}
|
||||
private func timestampText(_ value: String) -> String {
|
||||
let date =
|
||||
Self.fractionalTimestampFormatter.date(from: value)
|
||||
?? Self.timestampFormatter.date(from: value)
|
||||
guard let date else { return value }
|
||||
return date.formatted(
|
||||
Date.FormatStyle(date: .numeric, time: .shortened)
|
||||
.locale(Locale(identifier: "zh_CN"))
|
||||
)
|
||||
}
|
||||
|
||||
private func channelTypeLabel(_ type: String) -> String {
|
||||
switch type {
|
||||
case "webhook": return "Webhook"
|
||||
case "bark": return "Bark"
|
||||
case "pushplus": return "PushPlus"
|
||||
case "wecom_app": return "企业微信应用"
|
||||
case "wecom_robot": return "企业微信机器人"
|
||||
case "dingtalk_robot": return "钉钉机器人"
|
||||
case "dingtalk_app": return "钉钉应用"
|
||||
case "feishu_robot": return "飞书机器人"
|
||||
case "telegram": return "Telegram"
|
||||
case "email": return "邮件"
|
||||
case "serverchan": return "Server 酱"
|
||||
default: return type
|
||||
}
|
||||
private func deviceState(_ state: NotificationDeviceState) -> String {
|
||||
switch state {
|
||||
case .ready: return "在线"
|
||||
case .unauthorized: return "需登录"
|
||||
case .unavailable: return "离线"
|
||||
case .failed: return "异常"
|
||||
}
|
||||
}
|
||||
|
||||
private func eventTypeLabel(_ value: String) -> String {
|
||||
switch value {
|
||||
case "sms": return "短信"
|
||||
case "ddns": return "DDNS"
|
||||
case "version": return "版本"
|
||||
case "system": return "系统"
|
||||
case "device": return "设备"
|
||||
case "automation": return "自动化"
|
||||
default: return value
|
||||
}
|
||||
private func deviceTint(_ state: NotificationDeviceState) -> Color {
|
||||
switch state {
|
||||
case .ready: return AITheme.success
|
||||
case .unauthorized: return AITheme.warning
|
||||
case .unavailable: return AITheme.muted
|
||||
case .failed: return AITheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
private func statusLabel(_ value: String) -> String {
|
||||
switch value {
|
||||
case "success", "succeeded": return "成功"
|
||||
case "failed": return "失败"
|
||||
case "pending": return "等待"
|
||||
case "sending": return "发送中"
|
||||
case "retrying": return "重试中"
|
||||
case "cancelled": return "已取消"
|
||||
case "unmatched": return "未匹配"
|
||||
case "no_available_channel": return "无可用通道"
|
||||
case "quiet_hours": return "免打扰"
|
||||
default: return value
|
||||
}
|
||||
private func channelTypeLabel(_ type: String) -> String {
|
||||
switch type {
|
||||
case "webhook": return "Webhook"
|
||||
case "bark": return "Bark"
|
||||
case "pushplus": return "PushPlus"
|
||||
case "wecom_app": return "企业微信应用"
|
||||
case "wecom_robot": return "企业微信机器人"
|
||||
case "dingtalk_robot": return "钉钉机器人"
|
||||
case "dingtalk_app": return "钉钉应用"
|
||||
case "feishu_robot": return "飞书机器人"
|
||||
case "telegram": return "Telegram"
|
||||
case "email": return "邮件"
|
||||
case "serverchan": return "Server 酱"
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
|
||||
private func statusTint(_ value: String) -> Color {
|
||||
switch value {
|
||||
case "success", "succeeded": return AITheme.success
|
||||
case "pending", "sending", "retrying": return AITheme.warning
|
||||
case "failed": return AITheme.danger
|
||||
default: return AITheme.muted
|
||||
}
|
||||
private func eventTypeLabel(_ value: String) -> String {
|
||||
switch value {
|
||||
case "sms": return "短信"
|
||||
case "ddns": return "DDNS"
|
||||
case "version": return "版本"
|
||||
case "system": return "系统"
|
||||
case "device": return "设备"
|
||||
case "automation": return "自动化"
|
||||
default: return value
|
||||
}
|
||||
}
|
||||
|
||||
private func statusLabel(_ value: String) -> String {
|
||||
switch value {
|
||||
case "success", "succeeded": return "成功"
|
||||
case "failed": return "失败"
|
||||
case "pending": return "等待"
|
||||
case "sending": return "发送中"
|
||||
case "retrying": return "重试中"
|
||||
case "cancelled": return "已取消"
|
||||
case "unmatched": return "未匹配"
|
||||
case "no_available_channel": return "无可用通道"
|
||||
case "quiet_hours": return "免打扰"
|
||||
default: return value
|
||||
}
|
||||
}
|
||||
|
||||
private func statusTint(_ value: String) -> Color {
|
||||
switch value {
|
||||
case "success", "succeeded": return AITheme.success
|
||||
case "pending", "sending", "retrying": return AITheme.warning
|
||||
case "failed": return AITheme.danger
|
||||
default: return AITheme.muted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,11 @@ struct SettingsView: View {
|
||||
AISectionTitle("安全与登录", systemImage: "lock.shield")
|
||||
AIKeyValueRow("实例密码", value: "保存在本机应用沙盒")
|
||||
AIKeyValueRow("登录会话", value: "仅保存在内存")
|
||||
AIStatusBanner(
|
||||
"实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。",
|
||||
tint: AITheme.primary,
|
||||
systemImage: "shield.checkered"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ struct MultiSimAdminApp: App {
|
||||
AITabBar(selection: $tab)
|
||||
}
|
||||
.tint(AITheme.primary)
|
||||
.preferredColorScheme(.light)
|
||||
#if os(iOS)
|
||||
.toolbarBackground(AITheme.canvas, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
|
||||
@@ -22,7 +22,7 @@ public struct AIButtonStyle: ButtonStyle {
|
||||
func backgroundColor(isEnabled: Bool) -> Color {
|
||||
switch self {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,445 +1,550 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct AICard<Content: View>: View {
|
||||
private let content: Content
|
||||
private let padding: CGFloat
|
||||
private let content: Content
|
||||
private let padding: CGFloat
|
||||
|
||||
public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(AITheme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
|
||||
.shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
}
|
||||
public var body: some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(AITheme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
|
||||
.shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AITag: View {
|
||||
private let text: String
|
||||
private let tint: Color
|
||||
private let text: String
|
||||
private let tint: Color
|
||||
|
||||
public init(_ text: String, tint: Color = AITheme.primary) {
|
||||
self.text = text
|
||||
self.tint = tint
|
||||
}
|
||||
public init(_ text: String, tint: Color = AITheme.primary) {
|
||||
self.text = text
|
||||
self.tint = tint
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.padding(.horizontal, 9)
|
||||
.frame(minHeight: 26)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.horizontal, 9)
|
||||
.frame(minHeight: 26)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
public struct AISectionTitle: View {
|
||||
private let title: String
|
||||
private let systemImage: String
|
||||
private let detail: String?
|
||||
private let title: String
|
||||
private let systemImage: String
|
||||
private let detail: String?
|
||||
private let footer: Footer
|
||||
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.systemImage = systemImage
|
||||
self.detail = detail
|
||||
self.footer = Footer(
|
||||
title: actionTitle,
|
||||
systemImage: actionSystemImage,
|
||||
kind: actionKind,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
|
||||
public init(_ title: String, systemImage: String, detail: String? = nil) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.detail = detail
|
||||
}
|
||||
private struct Footer: View {
|
||||
let title: String?
|
||||
let systemImage: String?
|
||||
let kind: AIButtonStyle.Kind
|
||||
let action: (() -> Void)?
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
Text(title)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
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 {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
Text(title)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.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 {
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let detail: String?
|
||||
private let tint: Color
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let detail: String?
|
||||
private let tint: Color
|
||||
|
||||
public init(title: String, value: String, detail: String? = nil, tint: Color) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.detail = detail
|
||||
self.tint = tint
|
||||
}
|
||||
public init(title: String, value: String, detail: String? = nil, tint: Color) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.detail = detail
|
||||
self.tint = tint
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
AICard(padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
public var body: some View {
|
||||
AICard(padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIStatusBanner: View {
|
||||
private let message: String
|
||||
private let tint: Color
|
||||
private let systemImage: String
|
||||
private let message: String
|
||||
private let tint: Color
|
||||
private let systemImage: String
|
||||
|
||||
public init(_ message: String, tint: Color, systemImage: String) {
|
||||
self.message = message
|
||||
self.tint = tint
|
||||
self.systemImage = systemImage
|
||||
}
|
||||
public init(_ message: String, tint: Color, systemImage: String) {
|
||||
self.message = message
|
||||
self.tint = tint
|
||||
self.systemImage = systemImage
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tint)
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(tint)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(14)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius)
|
||||
.strokeBorder(tint.opacity(0.22))
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
public var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tint)
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(tint)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(14)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius)
|
||||
.strokeBorder(tint.opacity(0.22))
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIEmptyState: View {
|
||||
private let systemImage: String
|
||||
private let title: String
|
||||
private let message: String
|
||||
private let systemImage: String
|
||||
private let title: String
|
||||
private let message: String
|
||||
|
||||
public init(systemImage: String, title: String, message: String) {
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.message = message
|
||||
}
|
||||
public init(systemImage: String, title: String, message: String) {
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(title)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.accessibilityElement(children: .combine)
|
||||
public var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.frame(width: 54, height: 54)
|
||||
.background(AITheme.primary.opacity(0.10))
|
||||
.clipShape(Circle())
|
||||
Text(title)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIListCard<Footer: View>: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let status: String?
|
||||
private let statusTint: Color?
|
||||
private let details: [String]
|
||||
private let footer: Footer
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let status: String?
|
||||
private let statusTint: Color?
|
||||
private let details: [String]
|
||||
private let footer: Footer
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
status: String? = nil,
|
||||
statusTint: Color? = nil,
|
||||
details: [String] = [],
|
||||
@ViewBuilder footer: () -> Footer = { EmptyView() }
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.status = status
|
||||
self.statusTint = statusTint
|
||||
self.details = details
|
||||
self.footer = footer()
|
||||
}
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
status: String? = nil,
|
||||
statusTint: Color? = nil,
|
||||
details: [String] = [],
|
||||
@ViewBuilder footer: () -> Footer = { EmptyView() }
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.status = status
|
||||
self.statusTint = statusTint
|
||||
self.details = details
|
||||
self.footer = footer()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if let status {
|
||||
AITag(status, tint: statusTint ?? AITheme.muted)
|
||||
}
|
||||
}
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if !details.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(details, id: \.self) { detail in
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Footer.self != EmptyView.self {
|
||||
footer
|
||||
}
|
||||
if let status {
|
||||
AITag(status, tint: statusTint ?? AITheme.muted)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if !details.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(details, id: \.self) { detail in
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Footer.self != EmptyView.self {
|
||||
footer
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AILoadingCard: View {
|
||||
private let message: String
|
||||
private let message: String
|
||||
|
||||
public init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
public init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
public var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIKeyValueRow: View {
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let valueTint: Color
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let valueTint: Color
|
||||
|
||||
public init(_ title: String, value: String, tint: Color = AITheme.ink) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.valueTint = tint
|
||||
public init(_ title: String, value: String, tint: Color = AITheme.ink) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.valueTint = tint
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(valueTint)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.minimumScaleFactor(0.85)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(valueTint)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
.textSelection(.enabled)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.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 init() {}
|
||||
public init() {}
|
||||
|
||||
public func _body(configuration: TextField<Self._Label>) -> some View {
|
||||
configuration
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: AITheme.buttonHeight)
|
||||
.background(AITheme.canvas)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
}
|
||||
public func _body(configuration: TextField<Self._Label>) -> some View {
|
||||
configuration
|
||||
.aiInputSurface()
|
||||
}
|
||||
}
|
||||
|
||||
public struct AISearchField: View {
|
||||
private let placeholder: String
|
||||
@Binding private var text: String
|
||||
private var onSubmit: (() -> Void)?
|
||||
private let placeholder: String
|
||||
@Binding private var text: String
|
||||
private var onSubmit: (() -> Void)?
|
||||
|
||||
public init(
|
||||
_ placeholder: String,
|
||||
text: Binding<String>,
|
||||
onSubmit: (() -> Void)? = nil
|
||||
) {
|
||||
self.placeholder = placeholder
|
||||
self._text = text
|
||||
self.onSubmit = onSubmit
|
||||
}
|
||||
public init(
|
||||
_ placeholder: String,
|
||||
text: Binding<String>,
|
||||
onSubmit: (() -> Void)? = nil
|
||||
) {
|
||||
self.placeholder = placeholder
|
||||
self._text = text
|
||||
self.onSubmit = onSubmit
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AITheme.muted)
|
||||
TextField(placeholder, text: $text)
|
||||
.textFieldStyle(.plain)
|
||||
.submitLabel(.search)
|
||||
.onSubmit {
|
||||
onSubmit?()
|
||||
}
|
||||
if !text.isEmpty {
|
||||
Button {
|
||||
text = ""
|
||||
onSubmit?()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
public var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AITheme.muted)
|
||||
TextField(placeholder, text: $text)
|
||||
.textFieldStyle(.plain)
|
||||
.submitLabel(.search)
|
||||
.onSubmit {
|
||||
onSubmit?()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: AITheme.buttonHeight)
|
||||
.background(AITheme.canvas)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
if !text.isEmpty {
|
||||
Button {
|
||||
text = ""
|
||||
onSubmit?()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
}
|
||||
.aiInputSurface(minHeight: AITheme.buttonHeight)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIBrandBar: View {
|
||||
public init() {}
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
Text(AppMetadata.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(red: 0.075, green: 0.118, blue: 0.188))
|
||||
public var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
Text(AppMetadata.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(AITheme.brand)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
safeAreaInset(edge: .bottom) {
|
||||
content()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.background(AITheme.canvas)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AITheme.line)
|
||||
.frame(height: 1)
|
||||
}
|
||||
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 {
|
||||
safeAreaInset(edge: .bottom) {
|
||||
content()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 8)
|
||||
.background(AITheme.canvas)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AITheme.line)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func aiSelectSurface() -> some View {
|
||||
self
|
||||
.tint(AITheme.ink)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.frame(maxWidth: .infinity, minHeight: AITheme.buttonHeight, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.background(AITheme.canvas)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
}
|
||||
func aiSelectSurface() -> some View {
|
||||
self
|
||||
.tint(AITheme.ink)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInputSurface()
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIToolbarButton: View {
|
||||
private let title: String
|
||||
private let systemImage: String?
|
||||
private let kind: AIButtonStyle.Kind
|
||||
private let action: () -> Void
|
||||
private let title: String
|
||||
private let systemImage: String?
|
||||
private let kind: AIButtonStyle.Kind
|
||||
private let action: () -> Void
|
||||
|
||||
public init(
|
||||
_ title: String,
|
||||
systemImage: String? = nil,
|
||||
kind: AIButtonStyle.Kind = .secondary,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.kind = kind
|
||||
self.action = action
|
||||
}
|
||||
public init(
|
||||
_ title: String,
|
||||
systemImage: String? = nil,
|
||||
kind: AIButtonStyle.Kind = .secondary,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.kind = kind
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
if let systemImage {
|
||||
Label(title, systemImage: systemImage)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
} else {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: kind, size: .toolbar))
|
||||
.accessibilityLabel(title)
|
||||
public var body: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
if let systemImage {
|
||||
Label(title, systemImage: systemImage)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
} else {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: kind, size: .toolbar))
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
public enum AITheme {
|
||||
public static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973)
|
||||
public static let surface = Color.white
|
||||
public static let ink = Color(red: 0.110, green: 0.153, blue: 0.220)
|
||||
public static let muted = Color(red: 0.435, green: 0.486, blue: 0.561)
|
||||
public static let line = Color(red: 0.878, green: 0.898, blue: 0.922)
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public static let primary = Color(red: 0.067, green: 0.467, blue: 0.431)
|
||||
public static let danger = Color(red: 0.714, green: 0.302, blue: 0.282)
|
||||
public static let warning = Color(red: 0.722, green: 0.471, blue: 0.067)
|
||||
public static let success = Color(red: 0.082, green: 0.584, blue: 0.439)
|
||||
private enum AIPalette {
|
||||
#if canImport(UIKit)
|
||||
private static func dynamicColor(light: UIColor, dark: UIColor) -> Color {
|
||||
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 compactButtonHeight: CGFloat = 40
|
||||
|
||||
Reference in New Issue
Block a user