feat(ios): unify list cards and loading states

This commit is contained in:
chick
2026-09-10 01:35:12 +08:00
parent 995206ac23
commit c841748971
32 changed files with 1297 additions and 607 deletions
@@ -21,27 +21,29 @@ struct LogsView: View {
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
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)
}
}
stats
Picker("视图", selection: $tab) {
Text("运行日志").tag(Tab.runtime)
Text("连接日志").tag(Tab.connections)
Text("设备诊断").tag(Tab.diagnostics)
}
.pickerStyle(.segmented)
.pickerStyle(.segmented)
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
filterBar
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
}
}
}
.padding(16)
@@ -50,12 +52,9 @@ struct LogsView: View {
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
AIToolbarButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
} label: {
Label("刷新", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
.disabled(model.isLoading)
}
}
@@ -80,87 +79,88 @@ struct LogsView: View {
}
private var stats: some View {
AICard {
VStack(spacing: 12) {
filterBar
HStack(spacing: 8) {
metric("日志", "\(model.snapshot?.runtime.count ?? 0)", AITheme.primary)
metric("在线", "\(onlineCount)", AITheme.success)
metric("离线", "\(model.offlineCount)", AITheme.danger)
}
}
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 {
VStack(spacing: 10) {
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
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)
if tab == .runtime {
Picker("来源", selection: $model.selectedSource) {
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
Text(source.label).tag(Optional(source))
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.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))
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()
}
}
.pickerStyle(.menu)
}
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)
.transition(.opacity)
}
.animation(.easeInOut(duration: 0.18), value: tab)
}
}
private var runtimeSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
sectionTitle("统一时间线", detail: "\(model.filteredRuntime.count) 条")
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
if model.filteredRuntime.isEmpty {
empty("暂无运行日志。")
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会汇总实例日志。")
} else {
ForEach(model.filteredRuntime) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(entry.message)
.font(.subheadline.weight(.semibold))
Spacer()
AITag(entry.level.label, tint: levelTint(entry.level))
}
Text("\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())")
.font(.caption)
.foregroundStyle(AITheme.muted)
HStack(spacing: 6) {
AITag(entry.source.label, tint: AITheme.primary)
if let duration = entry.durationMs {
AITag("\(duration) ms", tint: AITheme.muted)
}
}
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
}()
)
}
.padding(.bottom, 6)
}
}
}
}
@@ -169,35 +169,31 @@ struct LogsView: View {
private var connectionSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条")
Button {
HStack(alignment: .center, spacing: 10) {
AISectionTitle(
"连接探测记录",
systemImage: "dot.radiowaves.left.and.right",
detail: "\(model.filteredConnections.count) 条"
)
compactActionButton("清理", systemImage: "trash") {
showsPruneDialog = true
} label: {
Label("清理", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
}
if model.filteredConnections.isEmpty {
empty("暂无连接日志。")
AIEmptyState(systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。")
} else {
ForEach(model.filteredConnections) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(entry.instanceName)
.font(.subheadline.weight(.semibold))
Spacer()
AITag(entry.outcome.label, tint: outcomeTint(entry.outcome))
}
Text("\(entry.observedAt.formatted()) · \(entry.state)")
.font(.caption)
.foregroundStyle(AITheme.muted)
Text("HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms · \(entry.errorCode ?? "--")")
.font(.caption)
.foregroundStyle(AITheme.muted)
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 ?? "无错误码"
]
)
}
.padding(.bottom, 6)
}
}
}
}
@@ -206,44 +202,37 @@ struct LogsView: View {
private var diagnosticSection: some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
sectionTitle("设备诊断", detail: "\(model.filteredDiagnostics.count) 台")
AISectionTitle("设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
if model.filteredDiagnostics.isEmpty {
empty("暂无可诊断实例。")
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
} else {
ForEach(model.filteredDiagnostics) { diagnostic in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(diagnostic.name)
.font(.subheadline.weight(.semibold))
Spacer()
AITag(
diagnostic.healthStatus ?? "未知",
tint: diagnostic.state == .online ? AITheme.success : AITheme.warning
)
}
Text(diagnostic.origin)
.font(.caption)
.foregroundStyle(AITheme.muted)
if let version = diagnostic.version {
Text("版本 \(version) · \(diagnostic.platform ?? "未知平台")")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
HStack(spacing: 6) {
AITag(
"能力 \(diagnostic.capabilities.supported)/\(diagnostic.capabilities.total)",
tint: AITheme.primary
)
if let connection = diagnostic.connection {
AITag(
"可用 \(connection.availabilityPercent)%",
tint: connection.failed == 0 ? AITheme.success : AITheme.warning
)
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
)
}
}
}
}
.padding(.bottom, 8)
}
}
}
}
@@ -253,35 +242,21 @@ struct LogsView: View {
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
}
private func metric(_ title: String, _ value: String, _ tint: Color) -> some View {
VStack(spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.headline.weight(.bold))
.foregroundStyle(tint)
private func compactActionButton(
_ title: String,
systemImage: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
}
.frame(maxWidth: .infinity)
}
private func sectionTitle(_ title: String, detail: String) -> some View {
HStack {
Text(title)
.font(.headline)
Spacer()
Text(detail)
.font(.caption)
.foregroundStyle(AITheme.muted)
}
}
private func empty(_ text: String) -> some View {
Text(text)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 18)
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
private func levelTint(_ level: RuntimeLogLevel) -> Color {
@@ -300,4 +275,12 @@ struct LogsView: View {
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
}
}
}