diff --git a/apps/ios/Design/AppIcon.svg b/apps/ios/Design/AppIcon.svg
new file mode 100644
index 0000000..0200841
--- /dev/null
+++ b/apps/ios/Design/AppIcon.svg
@@ -0,0 +1,73 @@
+
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift
index 4703d9f..b680ebe 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift
@@ -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()
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
index 13db3a7..89ccafb 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift
@@ -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)
+ }
+ }
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift
index 141e83f..bfce793 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift
@@ -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,
- 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,
+ 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
+ }
+ }
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift
index 383887f..d1e5534 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift
@@ -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) {
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift
index dddfc8f..b23482f 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift
@@ -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.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.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
+ )
+ )
+ }
+ }
+ }
+ }
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift
index 13a6eac..83b2488 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift
@@ -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.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
+ }
+ }
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift
index 0cb4a37..3128ea0 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift
@@ -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)
}
}
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift
index 5c09790..fd610d3 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift
@@ -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
+ }
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift
index 9212799..43273bb 100644
--- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift
@@ -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))
}
}
}
diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift
index fae7054..7980ff0 100644
--- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift
+++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift
@@ -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] : [])
+ }
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png
index ab9a750..c07114f 100644
Binary files a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png and b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png differ
diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift
index b3a6a0d..f9a538b 100644
--- a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift
@@ -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)
+ }
}
diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift
index 962bb52..c6488ad 100644
--- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift
@@ -13,10 +13,10 @@ public struct AICard: 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: 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: 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: 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: 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: View {
}
public struct AIFieldStyle: TextFieldStyle {
+ @FocusState private var isFocused: Bool
+
public init() {}
public func _body(configuration: TextField) -> 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(@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)
diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift
index 295198a..19c057f 100644
--- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift
+++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift
@@ -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
}
diff --git a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift
index 2a3cc50..51d4a82 100644
--- a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift
+++ b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift
@@ -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)
+ 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)
- 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,
+ ]
+ )
+ }
}