feat(ios): refine native interface and app icon

Unify bottom actions, selection controls, navigation structure, spacing, and theme colors across iOS pages. Replace the app icon with a higher-contrast native-style asset and include its source SVG.
This commit is contained in:
chick
2026-09-12 14:09:02 +08:00
parent 31b2f33d15
commit 0f2866a853
16 changed files with 1293 additions and 1035 deletions
+73
View File
@@ -0,0 +1,73 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="canvas" x1="0.04" y1="0" x2="0.96" y2="1">
<stop offset="0" stop-color="#22272F"/>
<stop offset="0.45" stop-color="#0A0C10"/>
<stop offset="1" stop-color="#040507"/>
</linearGradient>
<radialGradient id="glow" cx="0.31" cy="0.16" r="0.78">
<stop offset="0" stop-color="#424A57" stop-opacity="0.70"/>
<stop offset="0.62" stop-color="#161A20" stop-opacity="0.10"/>
<stop offset="1" stop-color="#060709" stop-opacity="0"/>
</radialGradient>
<linearGradient id="gold" x1="0.08" y1="0.02" x2="0.90" y2="1">
<stop offset="0" stop-color="#FBE1AC"/>
<stop offset="0.32" stop-color="#E4B875"/>
<stop offset="0.72" stop-color="#C08E52"/>
<stop offset="1" stop-color="#9F7040"/>
</linearGradient>
<linearGradient id="goldEdge" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFF2D1" stop-opacity="0.96"/>
<stop offset="0.52" stop-color="#D3A362" stop-opacity="0.30"/>
<stop offset="1" stop-color="#6E4922" stop-opacity="0.82"/>
</linearGradient>
<linearGradient id="chip" x1="0.08" y1="0" x2="0.92" y2="1">
<stop offset="0" stop-color="#262B33"/>
<stop offset="1" stop-color="#0A0D11"/>
</linearGradient>
<linearGradient id="sheen" x1="0.16" y1="0" x2="0.84" y2="0.70">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.30"/>
<stop offset="0.42" stop-color="#FFFFFF" stop-opacity="0.05"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#F3CE91"/>
<stop offset="1" stop-color="#A87C42"/>
</linearGradient>
<filter id="cardShadow" x="-35%" y="-35%" width="170%" height="170%">
<feDropShadow dx="0" dy="28" stdDeviation="36" flood-color="#000000" flood-opacity="0.55"/>
</filter>
<filter id="plateShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="18" stdDeviation="30" flood-color="#000000" flood-opacity="0.38"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="url(#canvas)"/>
<rect width="1024" height="1024" fill="url(#glow)"/>
<g>
<rect x="247" y="284" width="470" height="560" rx="98" fill="#12161A" fill-opacity="0.94" filter="url(#plateShadow)"/>
<rect x="247" y="284" width="470" height="560" rx="98" fill="none" stroke="#DCB26F" stroke-opacity="0.13" stroke-width="3"/>
</g>
<g opacity="0.86">
<path d="M743 304 A94 94 0 0 1 831 392" fill="none" stroke="url(#signal)" stroke-opacity="0.36" stroke-width="23" stroke-linecap="round"/>
<path d="M739 239 A181 181 0 0 1 887 387" fill="none" stroke="url(#signal)" stroke-opacity="0.20" stroke-width="23" stroke-linecap="round"/>
<path d="M736 174 A268 268 0 0 1 944 382" fill="none" stroke="url(#signal)" stroke-opacity="0.10" stroke-width="23" stroke-linecap="round"/>
</g>
<g filter="url(#cardShadow)">
<rect x="311" y="208" width="430" height="584" rx="108" fill="url(#gold)"/>
<rect x="311" y="208" width="430" height="584" rx="108" fill="url(#sheen)"/>
<rect x="313.5" y="210.5" width="425" height="579" rx="105.5" fill="none" stroke="url(#goldEdge)" stroke-width="5"/>
</g>
<g>
<rect x="429" y="421" width="194" height="180" rx="48" fill="url(#chip)"/>
<rect x="431" y="423" width="190" height="176" rx="46" fill="none" stroke="#F0C37E" stroke-opacity="0.24" stroke-width="3"/>
<path d="M458 470h136M458 505h136M458 540h136" stroke="#DCB26F" stroke-opacity="0.72" stroke-width="15" stroke-linecap="round"/>
<circle cx="526" cy="668" r="9" fill="#FFF6E3" fill-opacity="0.58"/>
</g>
<rect x="311" y="208" width="430" height="584" rx="108" fill="none" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -9,24 +9,26 @@ struct InstanceDetailView: View {
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
header
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
sectionCard(section)
}
ScrollView {
LazyVStack(spacing: 12) {
header
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
if let snapshot = model.snapshot {
ForEach(snapshot.sections) { section in
sectionCard(section)
}
}
.padding(16)
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle(model.instance.name)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.refreshable {
await model.refresh()
}
@@ -1,212 +1,217 @@
import SwiftUI
struct FleetView: View {
@StateObject private var model: FleetViewModel
@State private var showsDeleteDialog = false
@StateObject private var model: FleetViewModel
@State private var showsDeleteDialog = false
init(model: FleetViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
}
init(model: FleetViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
if model.selectionMode {
selectionBar
}
if model.isLoading && model.rows.isEmpty {
AILoadingCard("正在同步实例数据")
}
if model.rows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "server.rack",
title: "暂无实例",
message: "添加 SimAdmin 实例后即可开始管理。"
)
}
}
ForEach(model.rows) { row in
if model.selectionMode {
instanceCard(row)
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
searchField
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage,
tint: AITheme.danger,
systemImage: "exclamationmark.triangle"
)
}
if model.selectionMode {
selectionBar
}
if model.isLoading && model.rows.isEmpty {
AILoadingCard("正在同步实例数据")
}
if model.rows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "server.rack",
title: "暂无实例",
message: "添加 SimAdmin 实例后即可开始管理。"
)
}
}
.navigationTitle("节点总览")
.aiBottomActionBar {
if !model.selectionMode {
HStack(spacing: 10) {
Button {
model.selectionMode = true
} label: {
Label("批量选择", systemImage: "checklist")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
}
}
ForEach(model.rows) { row in
if model.selectionMode {
instanceCard(row)
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
.buttonStyle(.plain)
}
}
}
.confirmationDialog(
"删除已选实例?登录信息也会一并清除。",
isPresented: $showsDeleteDialog,
titleVisibility: .visible
) {
Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) {
Task { await model.deleteSelected() }
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
if !model.selectionMode {
AIActionRow {
Button {
model.selectionMode = true
} label: {
Label("批量选择", systemImage: "checklist")
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(model: model)
}
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
private var searchField: some View {
AISearchField("搜索实例或地址", text: $model.searchText) {
Task { await model.refresh() }
}
}
private var selectionBar: some View {
AICard {
HStack(spacing: 10) {
Button {
if model.selectedIDs.count == model.rows.count {
model.selectedIDs.removeAll()
} else {
model.selectedIDs = Set(model.rows.map(\.id))
}
} label: {
Label(
model.selectedIDs.count == model.rows.count ? "取消全选" : "全选",
systemImage: "checkmark.circle"
)
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
Spacer()
Text("已选 \(model.selectedIDs.count)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.muted)
Button {
showsDeleteDialog = true
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.footerSecondaryButtonWidth
)
)
.disabled(model.selectedIDs.isEmpty)
Button {
model.showingForm = true
} label: {
Label("添加实例", systemImage: "plus")
}
}
}
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
AICard {
HStack(alignment: .top, spacing: 12) {
if model.selectionMode {
Button {
toggle(row.id)
} label: {
Image(systemName: model.selectedIDs.contains(row.id)
? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
}
.buttonStyle(.plain)
.frame(width: 32, height: 32)
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(row.status.label, tint: statusColor(row.status))
}
Text(row.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
if !row.instance.tags.isEmpty {
HStack(spacing: 6) {
ForEach(row.instance.tags, id: \.self) { tag in
AITag(tag)
}
}
}
}
}
}
}
private func statusColor(_ status: ConnectionStatus) -> Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired: return AITheme.warning
case .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
private func toggle(_ id: UUID) {
if model.selectedIDs.contains(id) {
model.selectedIDs.remove(id)
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
}
} else {
model.selectedIDs.insert(id)
AIActionRow {
Button {
model.selectionMode = false
model.selectedIDs.removeAll()
} label: {
Label("取消选择", systemImage: "xmark.circle")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
showsDeleteDialog = true
} label: {
Label(
model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count)",
systemImage: "trash"
)
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
.disabled(model.selectedIDs.isEmpty)
}
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("节点总览")
.confirmationDialog(
"删除已选实例?登录信息也会一并清除。",
isPresented: $showsDeleteDialog,
titleVisibility: .visible
) {
Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) {
Task { await model.deleteSelected() }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingForm) {
InstanceFormView(model: model)
}
}
private var searchField: some View {
AISearchField("搜索实例或地址", text: $model.searchText) {
Task { await model.refresh() }
}
}
private var selectionBar: some View {
AICard {
HStack(spacing: 10) {
Button {
if model.selectedIDs.count == model.rows.count {
model.selectedIDs.removeAll()
} else {
model.selectedIDs = Set(model.rows.map(\.id))
}
} label: {
Label(
model.selectedIDs.count == model.rows.count ? "取消全选" : "全选",
systemImage: "checkmark.circle"
)
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact
)
)
Spacer()
Text("已选 \(model.selectedIDs.count)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.muted)
}
}
}
private func instanceCard(_ row: FleetViewModel.Row) -> some View {
AICard {
HStack(alignment: .top, spacing: 12) {
if model.selectionMode {
Button {
toggle(row.id)
} label: {
Image(
systemName: model.selectedIDs.contains(row.id)
? "checkmark.circle.fill" : "circle"
)
.font(.system(size: 20))
.foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted)
}
.buttonStyle(.plain)
.frame(width: 44, height: 44)
.accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择")
.accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : [])
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(row.instance.name)
.font(.headline)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(row.status.label, tint: statusColor(row.status))
}
Text(row.instance.origin.absoluteString)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
if !row.instance.tags.isEmpty {
HStack(spacing: 6) {
ForEach(row.instance.tags, id: \.self) { tag in
AITag(tag)
}
}
}
}
}
}
}
private func statusColor(_ status: ConnectionStatus) -> Color {
switch status {
case .online: return AITheme.success
case .offline: return AITheme.danger
case .authRequired: return AITheme.warning
case .degraded: return AITheme.warning
case .unknown: return AITheme.muted
}
}
private func toggle(_ id: UUID) {
if model.selectedIDs.contains(id) {
model.selectedIDs.remove(id)
} else {
model.selectedIDs.insert(id)
}
}
}
@@ -1,106 +1,125 @@
import SwiftUI
struct InstanceFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var name = ""
@State private var originText = ""
@State private var tagsText = ""
@State private var password = ""
@State private var validationMessage: String?
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: FleetViewModel
@State private var name = ""
@State private var originText = ""
@State private var tagsText = ""
@State private var password = ""
@State private var validationMessage: String?
@State private var isSaving = false
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
}
}
if let validationMessage {
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary")
field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true)
}
.navigationTitle("添加实例")
.aiBottomActionBar {
HStack(spacing: 10) {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
}
if let validationMessage {
AIStatusBanner(
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
AIActionRow {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button("保存实例") { save() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSave)
.frame(maxWidth: .infinity)
}
Button {
save()
} label: {
HStack(spacing: 8) {
if isSaving {
ProgressView()
.controlSize(.small)
}
Text(isSaving ? "保存中" : "保存实例")
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSave || isSaving)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("添加实例")
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
private func field(
_ title: String,
text: Binding<String>,
placeholder: String,
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel(title)
if secure {
SecureField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.textContentType(.password)
} else {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
#endif
.autocorrectionDisabled(title == "地址" || title == "标签")
}
}
private func field(
_ title: String,
text: Binding<String>,
placeholder: String,
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel(title)
if secure {
SecureField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.textContentType(.password)
.accessibilityLabel(title)
} else {
TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel(title)
.submitLabel(title == "地址" ? .next : .done)
#if os(iOS)
.keyboardType(title == "地址" ? .URL : .default)
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
#endif
.autocorrectionDisabled(title == "地址" || title == "标签")
}
}
}
private func save() {
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
origin.scheme == "http" || origin.scheme == "https",
origin.host != nil
else {
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
return
}
let tags = tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: normalizedName,
origin: origin,
tags: tags,
password: password.isEmpty ? nil : password
)
Task {
do {
try await model.add(input)
dismiss()
} catch {
validationMessage = "保存失败,请检查地址是否重复。"
}
}
private func save() {
guard !isSaving else { return }
isSaving = true
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
origin.scheme == "http" || origin.scheme == "https",
origin.host != nil
else {
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
isSaving = false
return
}
let tags =
tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let input = InstanceInput(
name: normalizedName,
origin: origin,
tags: tags,
password: password.isEmpty ? nil : password
)
Task {
do {
try await model.add(input)
} catch {
validationMessage = "保存失败,请检查地址是否重复。"
}
isSaving = false
}
}
}
@@ -16,43 +16,42 @@ struct LogsView: View {
}
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: "设备诊断"),
]
)
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: "设备诊断"),
]
)
filterBar
filterBar
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
}
switch tab {
case .runtime:
runtimeSection
case .connections:
connectionSection
case .diagnostics:
diagnosticSection
}
}
.padding(16)
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("日志中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
@@ -1,138 +1,140 @@
import SwiftUI
struct MessagesView: View {
@StateObject private var model: MessagesViewModel
@State private var pendingDeleteMessage: SMSMessage?
@StateObject private var model: MessagesViewModel
@State private var pendingDeleteMessage: SMSMessage?
init(model: MessagesViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
init(model: MessagesViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
}
if model.filteredRows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "刷新后从实例同步短信。"
)
}
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
.padding(16)
.padding(.bottom, 8)
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
}
if model.filteredRows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "刷新后从实例同步短信。"
)
}
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
.padding(16)
}
}
.navigationTitle("消息中心")
.aiBottomActionBar {
Button {
model.showingSendForm = true
} label: {
Label("发送消息", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteMessage != nil },
set: { if !$0 { pendingDeleteMessage = nil } }
),
titleVisibility: .visible
) {
Button("删除消息", role: .destructive) {
guard let message = pendingDeleteMessage else { return }
Task { await model.delete(message) }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingSendForm) {
SendMessageFormView(model: model)
.aiBottomActionBar {
Button {
model.showingSendForm = true
} label: {
Label("发送消息", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
AISearchField("搜索号码或内容", text: $model.searchText)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("消息中心")
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteMessage != nil },
set: { if !$0 { pendingDeleteMessage = nil } }
),
titleVisibility: .visible
) {
Button("删除消息", role: .destructive) {
guard let message = pendingDeleteMessage else { return }
Task { await model.delete(message) }
}
Button("取消", role: .cancel) {}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
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)
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.fixedSize(horizontal: false, vertical: true)
HStack(alignment: .center, spacing: 10) {
Text(message.instanceName)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Button {
pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingSendForm) {
SendMessageFormView(model: model)
}
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
AISearchField("搜索号码或内容", text: $model.searchText)
}
}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
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)
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.fixedSize(horizontal: false, vertical: true)
HStack(alignment: .center, spacing: 10) {
Text(message.instanceName)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Button {
pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
}
}
@@ -1,97 +1,124 @@
import SwiftUI
struct SendMessageFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: MessagesViewModel
@State private var phoneNumber = ""
@State private var content = ""
@State private var validationMessage: String?
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: MessagesViewModel
@State private var phoneNumber = ""
@State private var content = ""
@State private var validationMessage: String?
@State private var isSending = false
private var trimmedPhoneNumber: String {
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedPhoneNumber: String {
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedContent: String {
content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedContent: String {
content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSend: Bool {
!trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
}
private var canSend: Bool {
!model.instances.isEmpty && !trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
}
var body: some View {
NavigationStack {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("实例")
Picker("实例", selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)) {
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("收件号码")
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.keyboardType(.phonePad)
#endif
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("消息内容")
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(AIFieldStyle())
.lineLimit(4...8)
}
}
}
if let validationMessage {
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("实例")
Picker(
"实例",
selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)
) {
if model.instances.isEmpty {
Text("暂无实例").tag(Optional<UUID>.none)
}
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
}
.navigationTitle("发送消息")
.aiBottomActionBar {
HStack(spacing: 10) {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
.pickerStyle(.menu)
.aiSelectSurface()
}
Button("发送") { send() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend)
.frame(maxWidth: .infinity)
}
}
}
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("收件号码")
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("收件号码")
.submitLabel(.next)
#if os(iOS)
.keyboardType(.phonePad)
#endif
}
private func send() {
Task {
do {
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
phoneNumber = ""
content = ""
validationMessage = nil
dismiss()
} catch {
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("消息内容")
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("消息内容")
.submitLabel(.send)
.onSubmit {
if canSend { send() }
}
.lineLimit(4...8)
}
}
}
if let validationMessage {
AIStatusBanner(
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
AIActionRow {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
send()
} label: {
HStack(spacing: 8) {
if isSending {
ProgressView()
.controlSize(.small)
}
Text(isSending ? "发送中" : "发送")
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend || isSending)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("发送消息")
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
private func send() {
guard !isSending else { return }
isSending = true
Task {
do {
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
phoneNumber = ""
content = ""
validationMessage = nil
dismiss()
} catch {
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
}
isSending = false
}
}
}
@@ -15,9 +15,7 @@ struct NotificationsView: View {
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
ScrollView {
LazyVStack(spacing: 12) {
if let actionMessage = model.actionMessage {
AIStatusBanner(
@@ -44,18 +42,29 @@ struct NotificationsView: View {
}
}
.padding(16)
}
.padding(.bottom, 8)
}
.navigationTitle("通知中心")
.aiBottomActionBar {
Button {
Task { await model.retryAll() }
} label: {
Label("立即投递", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading)
Button {
Task { await model.retryAll() }
} label: {
HStack(spacing: 8) {
if model.isRetryingAll {
ProgressView()
.controlSize(.small)
}
Text(
model.isRetryingAll
? "投递中"
: (model.overview?.queue.total == 0 ? "暂无待投递" : "立即投递")
)
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(model.isLoading || model.isRetryingAll || model.overview?.queue.total == 0)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("通知中心")
.confirmationDialog(
"删除这条通知队列记录?操作不可恢复。",
isPresented: Binding(
@@ -176,13 +185,12 @@ struct NotificationsView: View {
}
} 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)
) {
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) }
@@ -190,27 +198,17 @@ struct NotificationsView: View {
Label("重试", systemImage: "arrow.clockwise")
}
.buttonStyle(
AIButtonStyle(
kind: .secondary,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
AIButtonStyle(kind: .secondary, size: .compact, fillsWidth: true)
)
.disabled(model.busyQueueID == item.id)
.disabled(model.busyQueueID != nil)
Button {
pendingDeleteQueueID = item.id
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact, fillsWidth: true))
.disabled(model.busyQueueID != nil)
}
}
}
@@ -2,79 +2,83 @@ import Foundation
@MainActor
final class NotificationsViewModel: ObservableObject {
@Published private(set) var overview: NotificationOverview?
@Published private(set) var instances: [Instance] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
@Published private(set) var actionMessage: String?
@Published private(set) var actionSucceeded = true
@Published private(set) var busyQueueID: String?
@Published private(set) var overview: NotificationOverview?
@Published private(set) var instances: [Instance] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
@Published private(set) var actionMessage: String?
@Published private(set) var actionSucceeded = true
@Published private(set) var busyQueueID: String?
@Published private(set) var isRetryingAll = false
private let repository: any InstanceRepository
private let service: NotificationService
private let repository: any InstanceRepository
private let service: NotificationService
init(repository: any InstanceRepository, service: NotificationService) {
self.repository = repository
self.service = service
init(repository: any InstanceRepository, service: NotificationService) {
self.repository = repository
self.service = service
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
overview = try await service.overview(loaded)
} catch {
errorMessage = "无法加载通知中心。"
}
isLoading = false
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
overview = try await service.overview(loaded)
} catch {
errorMessage = "无法加载通知中心。"
}
isLoading = false
func retryAll() async {
guard !isRetryingAll && !isLoading else { return }
isRetryingAll = true
let result = await service.retryAll(instances)
actionSucceeded = result.succeeded > 0
if result.succeeded > 0 {
actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。"
} else if result.skipped > 0 {
actionMessage = "没有可重试的队列接口。"
} else {
actionMessage = "队列重试失败。"
}
await refresh()
isRetryingAll = false
}
func retryAll() async {
let result = await service.retryAll(instances)
actionSucceeded = result.succeeded > 0
if result.succeeded > 0 {
actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。"
} else if result.skipped > 0 {
actionMessage = "没有可重试的队列接口。"
} else {
actionMessage = "队列重试失败。"
}
await refresh()
func retryQueue(_ item: NotificationQueueEntry) async {
await queueAction(item) { instance, id in
try await self.service.retryQueue(instance, id: id)
}
}
func retryQueue(_ item: NotificationQueueEntry) async {
await queueAction(item) { instance, id in
try await self.service.retryQueue(instance, id: id)
}
func deleteQueue(_ item: NotificationQueueEntry) async {
await queueAction(item) { instance, id in
try await self.service.deleteQueueItem(instance, id: id)
}
await refresh()
}
func deleteQueue(_ item: NotificationQueueEntry) async {
await queueAction(item) { instance, id in
try await self.service.deleteQueueItem(instance, id: id)
}
await refresh()
private func queueAction(
_ item: NotificationQueueEntry,
action: (Instance, String) async throws -> Void
) async {
guard let instance = instances.first(where: { $0.id == item.instanceID }) else {
errorMessage = "找不到队列所属实例。"
return
}
private func queueAction(
_ item: NotificationQueueEntry,
action: (Instance, String) async throws -> Void
) async {
guard let instance = instances.first(where: { $0.id == item.instanceID }) else {
errorMessage = "找不到队列所属实例。"
return
}
busyQueueID = item.id
do {
try await action(instance, item.id)
actionMessage = "队列操作已完成。"
actionSucceeded = true
errorMessage = nil
} catch {
errorMessage = "队列操作失败。"
actionSucceeded = false
}
busyQueueID = nil
busyQueueID = item.id
do {
try await action(instance, item.id)
actionMessage = "队列操作已完成。"
actionSucceeded = true
errorMessage = nil
} catch {
errorMessage = "队列操作失败。"
actionSucceeded = false
}
busyQueueID = nil
}
}
@@ -9,24 +9,23 @@ struct SettingsView: View {
}
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 let notice = model.notice {
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
}
runtimeCard
storageCard
securityCard
ScrollView {
LazyVStack(spacing: 12) {
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
.padding(16)
if let notice = model.notice {
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
}
runtimeCard
storageCard
securityCard
}
.padding(16)
.padding(.bottom, 8)
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("设置")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
@@ -81,7 +80,7 @@ struct SettingsView: View {
} label: {
Label("清除运行日志", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .large, fillsWidth: true))
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
}
}
}
@@ -2,106 +2,113 @@ import SwiftUI
@main
struct MultiSimAdminApp: App {
@State private var tab: AppTab
@State private var tab: AppTab
init() {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("--tab-messages") {
_tab = State(initialValue: .messages)
} else if arguments.contains("--tab-notifications") {
_tab = State(initialValue: .notifications)
} else if arguments.contains("--tab-logs") {
_tab = State(initialValue: .logs)
} else if arguments.contains("--tab-settings") {
_tab = State(initialValue: .settings)
} else {
_tab = State(initialValue: .fleet)
}
init() {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("--tab-messages") {
_tab = State(initialValue: .messages)
} else if arguments.contains("--tab-notifications") {
_tab = State(initialValue: .notifications)
} else if arguments.contains("--tab-logs") {
_tab = State(initialValue: .logs)
} else if arguments.contains("--tab-settings") {
_tab = State(initialValue: .settings)
} else {
_tab = State(initialValue: .fleet)
}
}
var body: some Scene {
WindowGroup {
VStack(spacing: 0) {
switch tab {
case .fleet:
NavigationStack { FleetView().aiInlineTitle() }
case .messages:
NavigationStack { MessagesView().aiInlineTitle() }
case .notifications:
NavigationStack { NotificationsView().aiInlineTitle() }
case .logs:
NavigationStack { LogsView().aiInlineTitle() }
case .settings:
NavigationStack { SettingsView().aiInlineTitle() }
}
AITabBar(selection: $tab)
}
.tint(AITheme.primary)
#if os(iOS)
.toolbarBackground(AITheme.canvas, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
#endif
var body: some Scene {
WindowGroup {
VStack(spacing: 0) {
switch tab {
case .fleet:
NavigationStack { FleetView().aiInlineTitle() }
case .messages:
NavigationStack { MessagesView().aiInlineTitle() }
case .notifications:
NavigationStack { NotificationsView().aiInlineTitle() }
case .logs:
NavigationStack { LogsView().aiInlineTitle() }
case .settings:
NavigationStack { SettingsView().aiInlineTitle() }
}
AITabBar(selection: $tab)
}
.tint(AITheme.primary)
#if os(iOS)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
#endif
}
}
}
#if os(iOS)
private extension View {
func aiInlineTitle() -> some View {
navigationBarTitleDisplayMode(.inline)
extension View {
fileprivate func aiInlineTitle() -> some View {
navigationBarTitleDisplayMode(.inline)
}
}
}
#else
private extension View {
func aiInlineTitle() -> some View { self }
}
extension View {
fileprivate func aiInlineTitle() -> some View { self }
}
#endif
enum AppTab: Hashable {
case fleet
case messages
case notifications
case logs
case settings
case fleet
case messages
case notifications
case logs
case settings
}
struct AITabBar: View {
@Binding var selection: AppTab
@Binding var selection: AppTab
var body: some View {
HStack(spacing: 0) {
tab(.fleet, "节点", "server.rack")
tab(.messages, "消息", "message")
tab(.notifications, "通知", "bell")
tab(.logs, "日志", "doc.text")
tab(.settings, "设置", "gearshape")
}
.frame(height: 58)
.background(AITheme.surface)
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
.background(AITheme.surface.ignoresSafeArea(edges: .bottom))
var body: some View {
HStack(spacing: 0) {
tab(.fleet, "节点", "server.rack")
tab(.messages, "消息", "message")
tab(.notifications, "通知", "bell")
tab(.logs, "日志", "doc.text")
tab(.settings, "设置", "gearshape")
}
.frame(height: 62)
.background {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
Rectangle()
.fill(AITheme.chrome)
}
.ignoresSafeArea(edges: .bottom)
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
.frame(height: 1)
}
}
private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View {
let isSelected = selection == value
return Button {
selection = value
} label: {
VStack(spacing: 4) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
Text(title)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View {
let isSelected = selection == value
return Button {
selection = value
} label: {
VStack(spacing: 4) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
Text(title)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1,98 +1,116 @@
import SwiftUI
public struct AIButtonStyle: ButtonStyle {
public static let pressedScale: CGFloat = 0.97
public static let pressedScale: CGFloat = 0.97
public enum Kind: Hashable {
case primary
case secondary
case danger
public enum Kind: Hashable {
case primary
case secondary
case danger
var foregroundColor: Color { foregroundColor(isEnabled: true) }
var foregroundColor: Color { foregroundColor(isEnabled: true) }
var backgroundColor: Color { backgroundColor(isEnabled: true) }
var backgroundColor: Color { backgroundColor(isEnabled: true) }
func foregroundColor(isEnabled: Bool) -> Color {
switch self {
case .primary, .danger: return .white.opacity(isEnabled ? 1 : 0.72)
case .secondary: return isEnabled ? AITheme.ink : AITheme.muted
}
}
func backgroundColor(isEnabled: Bool) -> Color {
switch self {
case .primary: return isEnabled ? AITheme.primary : AITheme.primary.opacity(0.35)
case .secondary: return isEnabled ? AITheme.control : AITheme.canvas
case .danger: return isEnabled ? AITheme.danger : AITheme.danger.opacity(0.30)
}
}
func foregroundColor(isEnabled: Bool) -> Color {
switch self {
case .primary: return isEnabled ? AITheme.onPrimary : AITheme.muted
case .danger: return isEnabled ? AITheme.onDanger : AITheme.muted
case .secondary: return isEnabled ? AITheme.ink : AITheme.muted
}
}
public enum Size: Hashable {
case compact
case regular
case large
case toolbar
func backgroundColor(isEnabled: Bool) -> Color {
switch self {
case .primary, .secondary, .danger:
return isEnabled ? kindBackground : AITheme.control
}
}
private let kind: Kind
private let size: Size
private let fixedWidthRequest: CGFloat?
let fillsWidth: Bool
@Environment(\.isEnabled) private var isEnabled
public init(
kind: Kind = .secondary,
size: Size = .regular,
fixedWidth: CGFloat? = nil,
fillsWidth: Bool = false
) {
self.kind = kind
self.size = size
self.fixedWidthRequest = fixedWidth
self.fillsWidth = fillsWidth
private var kindBackground: Color {
switch self {
case .primary: return AITheme.primary
case .secondary: return AITheme.control
case .danger: return AITheme.danger
}
}
}
public func makeBody(configuration: Configuration) -> some View {
let height: CGFloat = switch size {
case .compact: AITheme.compactButtonHeight
case .large: AITheme.largeButtonHeight
case .regular: AITheme.buttonHeight
case .toolbar: AITheme.toolbarButtonHeight
}
public enum Size: Hashable {
case compact
case regular
case large
case toolbar
}
configuration.label
.labelStyle(.titleAndIcon)
.font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold))
.lineLimit(1)
.foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
.frame(
minWidth: (!fillsWidth && fixedWidth == nil) ? minimumWidth : 0,
minHeight: height
)
.frame(maxWidth: fillsWidth ? .infinity : nil)
.padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0)
.frame(width: fixedWidth)
.background(kind.backgroundColor(isEnabled: isEnabled))
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear)
}
.scaleEffect(configuration.isPressed ? Self.pressedScale : 1)
.opacity(configuration.isPressed ? 0.82 : 1)
private let kind: Kind
private let size: Size
private let fixedWidthRequest: CGFloat?
let fillsWidth: Bool
@Environment(\.isEnabled) private var isEnabled
public init(
kind: Kind = .secondary,
size: Size = .regular,
fixedWidth: CGFloat? = nil,
fillsWidth: Bool = false
) {
self.kind = kind
self.size = size
self.fixedWidthRequest = fixedWidth
self.fillsWidth = fillsWidth
}
public func makeBody(configuration: Configuration) -> some View {
let height: CGFloat =
switch size {
case .compact: AITheme.compactButtonHeight
case .large: AITheme.largeButtonHeight
case .regular: AITheme.buttonHeight
case .toolbar: AITheme.toolbarButtonHeight
}
configuration.label
.labelStyle(.titleAndIcon)
.font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold))
.lineLimit(1)
.foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
.padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0)
.frame(
minWidth: (!fillsWidth && fixedWidth == nil) ? minimumWidth : 0,
minHeight: height
)
.frame(maxWidth: fillsWidth ? .infinity : nil)
.frame(width: fixedWidth)
.background(
RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous)
.fill(kind.backgroundColor(isEnabled: isEnabled))
.shadow(
color: (kind == .secondary || !isEnabled) ? .clear : .black.opacity(0.10),
radius: 12,
x: 0,
y: 5
)
)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous)
.strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear)
}
.scaleEffect(configuration.isPressed ? Self.pressedScale : 1)
.opacity(configuration.isPressed ? 0.82 : 1)
}
private var minimumWidth: CGFloat {
switch size {
case .compact: return AITheme.compactButtonMinWidth
case .regular: return AITheme.regularButtonMinWidth
case .large: return 92
case .toolbar: return AITheme.toolbarButtonMinWidth
}
}
private var minimumWidth: CGFloat {
switch size {
case .compact: return AITheme.compactButtonMinWidth
case .regular: return AITheme.regularButtonMinWidth
case .large: return 92
case .toolbar: return AITheme.toolbarButtonMinWidth
}
}
private var fixedWidth: CGFloat? {
fixedWidthRequest ?? (size == .toolbar ? AITheme.toolbarButtonWidth : nil)
}
private var fixedWidth: CGFloat? {
fixedWidthRequest ?? (size == .toolbar ? AITheme.toolbarButtonWidth : nil)
}
}
+164 -81
View File
@@ -13,10 +13,10 @@ public struct AICard<Content: View>: View {
content
.padding(padding)
.background(AITheme.surface)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
.shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3)
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius)
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
.strokeBorder(AITheme.line)
}
}
@@ -37,10 +37,14 @@ public struct AITag: View {
.foregroundStyle(tint)
.lineLimit(1)
.truncationMode(.middle)
.padding(.horizontal, 9)
.frame(minHeight: 26)
.padding(.horizontal, 10)
.frame(minHeight: 30)
.background(tint.opacity(0.10))
.clipShape(Capsule())
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(tint.opacity(0.16))
}
}
}
@@ -96,8 +100,10 @@ public struct AISectionTitle: View {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.primary)
.accessibilityHidden(true)
Text(title)
.font(.headline.weight(.bold))
.font(.headline.weight(.semibold))
.kerning(-0.1)
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -149,7 +155,7 @@ public struct AIStatCard: View {
.foregroundStyle(AITheme.muted)
Text(value)
.font(.title3.weight(.bold))
.foregroundStyle(tint)
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
.lineLimit(1)
.minimumScaleFactor(0.75)
if let detail {
@@ -180,16 +186,17 @@ public struct AIStatusBanner: View {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(message)
.font(.subheadline.weight(.medium))
.foregroundStyle(tint)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(14)
.padding(15)
.background(tint.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.cardRadius)
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(tint.opacity(0.22))
}
.accessibilityElement(children: .combine)
@@ -210,13 +217,14 @@ public struct AIEmptyState: View {
public var body: some View {
VStack(spacing: 10) {
Image(systemName: systemImage)
.font(.system(size: 26))
.font(.system(size: 27, weight: .medium))
.foregroundStyle(AITheme.primary)
.frame(width: 54, height: 54)
.background(AITheme.primary.opacity(0.10))
.clipShape(Circle())
.accessibilityHidden(true)
Text(title)
.font(.headline.weight(.bold))
.font(.headline.weight(.semibold))
.foregroundStyle(AITheme.ink)
Text(message)
.font(.subheadline)
@@ -225,7 +233,7 @@ public struct AIEmptyState: View {
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.padding(.vertical, 16)
.accessibilityElement(children: .combine)
}
}
@@ -255,46 +263,47 @@ public struct AIListCard<Footer: View>: View {
}
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)
AICard {
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 let status {
AITag(status, tint: statusTint ?? AITheme.muted)
}
}
}
if Footer.self != EmptyView.self {
footer
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)
}
}
@@ -319,6 +328,77 @@ public struct AILoadingCard: View {
}
}
public struct AIActionRow<Content: View>: View {
private let spacing: CGFloat
private let content: Content
public init(
spacing: CGFloat = 10,
@ViewBuilder content: () -> Content
) {
self.spacing = spacing
self.content = content()
}
public var body: some View {
AIEqualWidthLayout(spacing: spacing) {
content
}
}
}
private struct AIEqualWidthLayout: Layout {
let spacing: CGFloat
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let count = subviews.count
let width: CGFloat
if let proposedWidth = proposal.width {
width = proposedWidth
} else {
let idealWidths = subviews.map { $0.sizeThatFits(.unspecified).width }
width = idealWidths.reduce(0, +) + spacing * CGFloat(count - 1)
}
let equalWidth = max(0, (width - spacing * CGFloat(count - 1)) / CGFloat(count))
let height = subviews.map {
$0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height
}.max() ?? 0
return CGSize(width: width, height: height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard !subviews.isEmpty else { return }
let equalWidth = max(
0,
(bounds.width - spacing * CGFloat(subviews.count - 1)) / CGFloat(subviews.count)
)
var x = bounds.minX
for subview in subviews {
let size = subview.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil))
subview.place(
at: CGPoint(x: x, y: bounds.midY - size.height / 2),
proposal: ProposedViewSize(width: equalWidth, height: size.height)
)
x += equalWidth + spacing
}
}
}
public struct AIKeyValueRow: View {
private let title: String
private let value: String
@@ -377,11 +457,11 @@ public struct AISegmentedControl<Value: Hashable>: View {
.font(.subheadline.weight(isSelected ? .semibold : .medium))
.lineLimit(1)
.minimumScaleFactor(0.75)
.foregroundStyle(isSelected ? .white : AITheme.muted)
.frame(maxWidth: .infinity, minHeight: 34)
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
.frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight)
.background {
if isSelected {
RoundedRectangle(cornerRadius: 9)
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(AITheme.primary)
}
}
@@ -393,9 +473,9 @@ public struct AISegmentedControl<Value: Hashable>: View {
}
.padding(4)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(AITheme.line)
}
.animation(.easeOut(duration: 0.16), value: selection)
@@ -405,10 +485,14 @@ public struct AISegmentedControl<Value: Hashable>: View {
}
public struct AIFieldStyle: TextFieldStyle {
@FocusState private var isFocused: Bool
public init() {}
public func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.textFieldStyle(.plain)
.focused($isFocused)
.aiInputSurface()
}
}
@@ -417,6 +501,7 @@ public struct AISearchField: View {
private let placeholder: String
@Binding private var text: String
private var onSubmit: (() -> Void)?
@FocusState private var isFocused: Bool
public init(
_ placeholder: String,
@@ -432,8 +517,10 @@ public struct AISearchField: View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AITheme.muted)
.accessibilityHidden(true)
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.focused($isFocused)
.submitLabel(.search)
.onSubmit {
onSubmit?()
@@ -448,52 +535,48 @@ public struct AISearchField: View {
.font(.system(size: 16, weight: .medium))
}
.buttonStyle(.plain)
.frame(width: 28, height: 28)
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
.contentShape(Rectangle())
.accessibilityLabel("清除搜索")
}
}
.aiInputSurface(minHeight: AITheme.buttonHeight)
}
}
public struct AIBrandBar: View {
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(AITheme.brand)
.aiInputSurface(minHeight: AITheme.buttonHeight, isFocused: isFocused)
}
}
extension View {
func aiInputSurface(minHeight: CGFloat = AITheme.buttonHeight) -> some View {
func aiInputSurface(
minHeight: CGFloat = AITheme.inputHeight,
isFocused: Bool = false
) -> some View {
self
.padding(.horizontal, 12)
.frame(minHeight: minHeight, alignment: .leading)
.background(AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.background(isFocused ? AITheme.surfaceRaised : AITheme.control)
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
.strokeBorder(isFocused ? AITheme.primary.opacity(0.55) : AITheme.line)
}
.animation(.easeOut(duration: 0.15), value: isFocused)
}
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
self
content()
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 8)
.background(AITheme.canvas)
.padding(.top, 12)
.padding(.bottom, 10)
.background {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
Rectangle()
.fill(AITheme.surface.opacity(0.98))
}
.ignoresSafeArea(edges: .bottom)
}
.overlay(alignment: .top) {
Rectangle()
.fill(AITheme.line)
@@ -1,44 +1,49 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
private enum AIPalette {
#if canImport(UIKit)
#if canImport(UIKit)
private static func dynamicColor(light: UIColor, dark: UIColor) -> Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
Color(
UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
private static func rgba(
_ red: CGFloat,
_ green: CGFloat,
_ blue: CGFloat,
_ alpha: CGFloat
_ red: CGFloat,
_ green: CGFloat,
_ blue: CGFloat,
_ alpha: CGFloat
) -> UIColor {
UIColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha)
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)
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(light: rgba(245, 246, 248, 1), dark: rgba(6, 7, 9, 1))
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(18, 20, 24, 1))
static let surfaceRaised = color(light: rgba(251, 252, 253, 1), dark: rgba(27, 30, 35, 1))
static let control = color(light: rgba(243, 245, 247, 1), dark: rgba(26, 29, 34, 1))
static let chrome = color(light: rgba(255, 255, 255, 0.82), dark: rgba(11, 12, 15, 0.82))
static let ink = color(light: rgba(15, 18, 22, 1), dark: rgba(243, 245, 247, 1))
static let muted = color(light: rgba(98, 107, 119, 1), dark: rgba(151, 160, 172, 1))
static let line = color(light: rgba(227, 230, 234, 1), dark: rgba(255, 255, 255, 0.09))
static let brand = color(light: rgba(13, 15, 18, 1), dark: rgba(8, 9, 11, 1))
static let primary = color(light: rgba(17, 20, 25, 1), dark: rgba(220, 178, 111, 1))
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(12, 14, 17, 1))
static let danger = color(light: rgba(190, 65, 57, 1), dark: rgba(242, 124, 113, 1))
static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(22, 9, 8, 1))
static let warning = color(light: rgba(170, 111, 23, 1), dark: rgba(226, 166, 65, 1))
static let success = color(light: rgba(50, 150, 101, 1), dark: rgba(103, 198, 148, 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)
@@ -46,13 +51,17 @@ private enum AIPalette {
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
static let surfaceRaised = Color(red: 0.984, green: 0.988, blue: 0.992)
static let chrome = Color.white.opacity(0.82)
static let onPrimary = Color.white
static let onDanger = Color.white
static let primary = Color(red: 0.067, green: 0.078, blue: 0.098)
static let danger = Color(red: 0.745, green: 0.255, blue: 0.224)
static let warning = Color(red: 0.667, green: 0.435, blue: 0.09)
static let success = Color(red: 0.196, green: 0.588, blue: 0.396)
#else
private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color {
Color(red: red / 255, green: green / 255, blue: blue / 255)
Color(red: red / 255, green: green / 255, blue: blue / 255)
}
static let canvas = color(243, 245, 248)
@@ -62,37 +71,48 @@ private enum AIPalette {
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
static let surfaceRaised = color(251, 252, 253)
static let chrome = color(255, 255, 255, 0.82)
static let onPrimary = color(255, 255, 255)
static let onDanger = color(255, 255, 255)
static let primary = color(17, 20, 25)
static let danger = color(190, 65, 57)
static let warning = color(170, 111, 23)
static let success = color(50, 150, 101)
#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 canvas = AIPalette.canvas
public static let surface = AIPalette.surface
public static let surfaceRaised = AIPalette.surfaceRaised
public static let control = AIPalette.control
public static let chrome = AIPalette.chrome
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 onPrimary = AIPalette.onPrimary
public static let danger = AIPalette.danger
public static let onDanger = AIPalette.onDanger
public static let warning = AIPalette.warning
public static let success = AIPalette.success
public static let buttonHeight: CGFloat = 44
public static let compactButtonHeight: CGFloat = 40
public static let largeButtonHeight: CGFloat = 50
public static let toolbarButtonHeight: CGFloat = 38
public static let iconButtonSize: CGFloat = 44
public static let buttonRadius: CGFloat = 12
public static let cardRadius: CGFloat = 16
public static let compactButtonMinWidth: CGFloat = 58
public static let regularButtonMinWidth: CGFloat = 76
public static let toolbarButtonMinWidth: CGFloat = 88
public static let toolbarButtonWidth: CGFloat = 100
public static let footerSecondaryButtonWidth: CGFloat = 100
public static let listActionButtonWidth: CGFloat = 72
public static let buttonHeight: CGFloat = 48
public static let compactButtonHeight: CGFloat = 44
public static let largeButtonHeight: CGFloat = 56
public static let toolbarButtonHeight: CGFloat = 44
public static let iconButtonSize: CGFloat = 44
public static let buttonRadius: CGFloat = 16
public static let cardRadius: CGFloat = 22
public static let inputRadius: CGFloat = 16
public static let inputHeight: CGFloat = 52
public static let segmentedControlHeight: CGFloat = 44
public static let compactButtonMinWidth: CGFloat = 72
public static let regularButtonMinWidth: CGFloat = 88
public static let toolbarButtonMinWidth: CGFloat = 96
public static let toolbarButtonWidth: CGFloat = 104
public static let footerSecondaryButtonWidth: CGFloat = 104
public static let listActionButtonWidth: CGFloat = 84
}
@@ -4,84 +4,86 @@ import XCTest
@testable import MultiSimAdmin
final class AIThemeTests: XCTestCase {
func testControlDimensionsMatchIOS() {
XCTAssertEqual(AITheme.buttonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.compactButtonHeight, 40, accuracy: 0.1)
XCTAssertEqual(AITheme.largeButtonHeight, 50, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonHeight, 38, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 12, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonMinWidth, 88, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonWidth, 100, accuracy: 0.1)
XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 100, accuracy: 0.1)
XCTAssertEqual(AITheme.listActionButtonWidth, 72, accuracy: 0.1)
XCTAssertEqual(AIButtonStyle.pressedScale, 0.97, accuracy: 0.01)
func testControlDimensionsMatchIOS() {
XCTAssertEqual(AITheme.buttonHeight, 48, accuracy: 0.1)
XCTAssertEqual(AITheme.compactButtonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.largeButtonHeight, 56, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.segmentedControlHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 16, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonMinWidth, 96, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonWidth, 104, accuracy: 0.1)
XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 104, accuracy: 0.1)
XCTAssertEqual(AITheme.listActionButtonWidth, 84, accuracy: 0.1)
XCTAssertEqual(AIButtonStyle.pressedScale, 0.97, accuracy: 0.01)
}
func testBottomActionsFillEqualWidths() {
let primary = AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)
let secondary = AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)
XCTAssertTrue(primary.fillsWidth)
XCTAssertTrue(secondary.fillsWidth)
}
func testFixedWidthDrawsInsideTheButtonStyle() {
let button = AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
XCTAssertTrue(button.fillsWidth == false)
XCTAssertEqual(AITheme.listActionButtonWidth, 84, accuracy: 0.1)
}
func testToolbarControlsUseUnifiedColorAndGeometry() {
let expected: [(Color, Color, CGFloat, CGFloat)] = [
(AITheme.control, AITheme.ink, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
(AITheme.primary, AITheme.onPrimary, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
(AITheme.danger, AITheme.onDanger, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
]
for (background, foreground, width, height) in expected {
XCTAssertTrue(background == background)
XCTAssertTrue(foreground == foreground)
XCTAssertEqual(width, 104, accuracy: 0.1)
XCTAssertEqual(height, 44, accuracy: 0.1)
}
}
func testBottomActionsFillEqualWidths() {
let primary = AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)
let secondary = AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)
XCTAssertTrue(primary.fillsWidth)
XCTAssertTrue(secondary.fillsWidth)
}
func testButtonKindsHaveDistinctBaseColors() {
let colors = [
AIButtonStyle.Kind.primary,
.secondary,
.danger,
].map(\.backgroundColor)
XCTAssertEqual(Set(colors).count, 3)
}
func testFixedWidthDrawsInsideTheButtonStyle() {
let button = AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
func testSearchFieldExposesClearAction() throws {
let mirror = Mirror(reflecting: AISearchField("搜索", text: .constant("待清理")))
let text = try XCTUnwrap(
mirror.children.first { $0.label == "_text" }?.value as? Binding<String>)
XCTAssertEqual(text.wrappedValue, "待清理")
}
XCTAssertTrue(button.fillsWidth == false)
XCTAssertEqual(AITheme.listActionButtonWidth, 72, accuracy: 0.1)
}
func testDisabledKindsUseReducedButVisibleRendering() {
let disabledKinds = [
AIButtonStyle.Kind.primary,
.secondary,
.danger,
]
let disabledColors = disabledKinds.map { $0.backgroundColor(isEnabled: false) }
func testToolbarControlsUseUnifiedColorAndGeometry() {
let expected: [(Color, Color, CGFloat, CGFloat)] = [
(AITheme.surface, AITheme.ink, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
(AITheme.primary, .white, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
(AITheme.danger, .white, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
]
for (background, foreground, width, height) in expected {
XCTAssertTrue(background == background)
XCTAssertTrue(foreground == foreground)
XCTAssertEqual(width, 100, accuracy: 0.1)
XCTAssertEqual(height, 38, accuracy: 0.1)
}
}
func testButtonKindsHaveDistinctBaseColors() {
let colors = [
AIButtonStyle.Kind.primary,
.secondary,
.danger
].map(\.backgroundColor)
XCTAssertEqual(Set(colors).count, 3)
}
func testSearchFieldExposesClearAction() throws {
let mirror = Mirror(reflecting: AISearchField("搜索", text: .constant("待清理")))
let text = try XCTUnwrap(mirror.children.first { $0.label == "_text" }?.value as? Binding<String>)
XCTAssertEqual(text.wrappedValue, "待清理")
}
func testDisabledKindsUseReducedButVisibleRendering() {
let disabledKinds = [
AIButtonStyle.Kind.primary,
.secondary,
.danger
]
let disabledColors = disabledKinds.map { $0.backgroundColor(isEnabled: false) }
XCTAssertEqual(Set(disabledColors).count, disabledKinds.count)
XCTAssertEqual(
disabledKinds.map { $0.foregroundColor(isEnabled: false) },
[
.white.opacity(0.72),
AITheme.muted,
.white.opacity(0.72)
]
)
}
XCTAssertEqual(Set(disabledColors), [AITheme.control])
XCTAssertEqual(
disabledKinds.map { $0.foregroundColor(isEnabled: false) },
[
AITheme.muted,
AITheme.muted,
AITheme.muted,
]
)
}
}