- 消息卡片删除按钮移出正文行,改用 overlay 悬在右侧,44pt 点击区不再把行撑高 - 批量标签胶囊由等宽栅格改为流式布局,新增 AIFlowLayout 并让节点页复用同一实现 - 设置页应用信息补「构建」一行,版本号全程不带括号 - 添加实例与发送消息两张表单表统一行内标题,去掉大标题留下的空白带 - 节点空态文案改为「节点未返回设备信息」,与量表条实际返回的数据不矛盾 - 图标石墨底、面板阴影与插槽统一压暖 3~4 级,全图 0 蓝色像素
209 lines
7.3 KiB
Swift
209 lines
7.3 KiB
Swift
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@StateObject private var model: SettingsViewModel
|
|
@ObservedObject private var themeStore = ThemeStore.shared
|
|
@State private var showsClearDialog = false
|
|
|
|
init(model: SettingsViewModel? = nil) {
|
|
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeSettingsModel())
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
if let errorMessage = model.errorMessage {
|
|
AIStatusBanner(
|
|
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
|
}
|
|
if let notice = model.notice {
|
|
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
|
|
}
|
|
|
|
themeCard
|
|
runtimeCard
|
|
storageCard
|
|
securityCard
|
|
dangerZoneCard
|
|
}
|
|
.padding(16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.background(AITheme.canvas.ignoresSafeArea())
|
|
.navigationTitle("设置")
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
AIIconButton("刷新", systemImage: "arrow.clockwise") {
|
|
Task { await model.refresh() }
|
|
}
|
|
.disabled(model.isLoading)
|
|
}
|
|
}
|
|
.refreshable {
|
|
await model.refresh()
|
|
}
|
|
.confirmationDialog(
|
|
"确认清除运行日志?此操作不可恢复。",
|
|
isPresented: $showsClearDialog,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("清除运行日志", role: .destructive) {
|
|
Task { await model.clearRuntimeLogs() }
|
|
}
|
|
Button("取消", role: .cancel) {}
|
|
}
|
|
.task {
|
|
await model.refresh()
|
|
}
|
|
}
|
|
|
|
private var runtimeCard: some View {
|
|
AICard {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
AISectionTitle("应用信息", systemImage: "info.circle")
|
|
if let runtime = model.runtime {
|
|
AIKeyValueRow("版本", value: runtime.marketingVersion)
|
|
// 构建号单独一行:同一营销版本会连续出多个包,只写版本号分不清装的是哪个。
|
|
AIKeyValueRow("构建", value: runtime.buildVersion)
|
|
AIKeyValueRow("系统", value: runtime.systemVersion)
|
|
} else {
|
|
Text("正在读取应用信息…")
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.muted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var themeCard: some View {
|
|
AICard {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// 三个选项块本身已经标出当前模式,标题右侧再重复一次是冗余信息。
|
|
AISectionTitle("主题外观", systemImage: "circle.lefthalf.filled")
|
|
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
|
GridRow {
|
|
ForEach(ThemeMode.allCases) { mode in
|
|
themeOption(mode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func themeOption(_ mode: ThemeMode) -> some View {
|
|
let isSelected = themeStore.mode == mode
|
|
|
|
return Button {
|
|
themeStore.set(mode)
|
|
} label: {
|
|
VStack(spacing: 7) {
|
|
Image(systemName: mode.systemImage)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
|
.accessibilityHidden(true)
|
|
Text(mode.label)
|
|
.font(.caption.weight(isSelected ? .semibold : .medium))
|
|
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 68)
|
|
// 选中格直接反色成实心块,浅底黑、深底白;一层 10% 的色雾在三块同色瓷砖里根本分不出来。
|
|
.background(isSelected ? AITheme.primary : AITheme.control)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(isSelected ? Color.clear : AITheme.line)
|
|
}
|
|
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("主题:\(mode.label)")
|
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
}
|
|
|
|
|
|
private var storageCard: some View {
|
|
AICard {
|
|
NavigationLink {
|
|
LogsView()
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 10) {
|
|
AISectionTitle("本地数据", systemImage: "externaldrive")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Image(systemName: "chevron.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(AITheme.muted)
|
|
.accessibilityHidden(true)
|
|
}
|
|
AIKeyValueRow("日志记录", value: model.storageLabel)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityHint("进入日志与诊断")
|
|
}
|
|
}
|
|
|
|
private var securityCard: some View {
|
|
AICard {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
AISectionTitle("隐私与数据", systemImage: "lock.shield")
|
|
AIKeyValueRow("节点与凭据", value: "仅写入本机存储")
|
|
AIKeyValueRow("网络请求", value: "只访问你添加的节点")
|
|
// 卡内再套一个描边框会变成"卡中卡",这里退成一段脚注。
|
|
HStack(alignment: .top, spacing: 7) {
|
|
Image(systemName: "checkmark.seal")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(AITheme.success)
|
|
.padding(.top, 2)
|
|
.accessibilityHidden(true)
|
|
Text("不经过中转服务器,也不上传遥测数据;节点地址、密码与日志只保存在这台 iPhone 上,删除应用即彻底清除。")
|
|
.font(.caption)
|
|
.foregroundStyle(AITheme.muted)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var dangerZoneCard: some View {
|
|
AICard {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
AISectionTitle("数据清理", systemImage: "trash.square", detail: "不可恢复")
|
|
// 整块实心红是这页最大的色块,会把"读设置"的视线抢走;退成一行危险色入口。
|
|
Button {
|
|
showsClearDialog = true
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "trash")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.accessibilityHidden(true)
|
|
Text("清除运行日志")
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer(minLength: 8)
|
|
Image(systemName: "chevron.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.opacity(0.7)
|
|
.accessibilityHidden(true)
|
|
}
|
|
.foregroundStyle(AITheme.danger)
|
|
.padding(.horizontal, 12)
|
|
.frame(minHeight: AITheme.compactControlHeight)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AITheme.danger.opacity(0.07))
|
|
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
|
|
.strokeBorder(AITheme.danger.opacity(0.20))
|
|
}
|
|
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|