feat(ios): refine native ui controls

This commit is contained in:
chick
2026-09-09 11:14:39 +08:00
parent c568e496b2
commit 995206ac23
18 changed files with 397 additions and 212 deletions
@@ -2,99 +2,130 @@ import SwiftUI
struct FleetView: View { struct FleetView: View {
@StateObject private var model: FleetViewModel @StateObject private var model: FleetViewModel
@Environment(\.refresh) private var refreshEnvironment @State private var showsDeleteDialog = false
init(model: FleetViewModel? = nil) { init(model: FleetViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel()) _model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel())
} }
var body: some View { var body: some View {
NavigationStack { ZStack {
ZStack { AITheme.canvas.ignoresSafeArea()
AITheme.canvas.ignoresSafeArea() ScrollView {
ScrollView { LazyVStack(spacing: 12) {
LazyVStack(spacing: 12) { searchField
searchField if model.selectionMode {
if model.rows.isEmpty && !model.isLoading { selectionBar
AICard { }
VStack(spacing: 10) { if model.rows.isEmpty && !model.isLoading {
Image(systemName: "server.rack") AICard {
.font(.system(size: 28)) VStack(spacing: 10) {
.foregroundStyle(AITheme.muted) Image(systemName: "server.rack")
Text("暂无实例") .font(.system(size: 28))
.font(.headline) .foregroundStyle(AITheme.muted)
Text("添加 SimAdmin 实例后即可开始管理。") Text("暂无实例")
.font(.subheadline) .font(.headline)
.foregroundStyle(AITheme.muted) Text("添加 SimAdmin 实例后即可开始管理。")
} .font(.subheadline)
.frame(maxWidth: .infinity) .foregroundStyle(AITheme.muted)
.padding(.vertical, 18)
} }
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
} }
ForEach(model.rows) { row in }
if model.selectionMode { ForEach(model.rows) { row in
if model.selectionMode {
instanceCard(row)
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row) instanceCard(row)
} else {
NavigationLink {
InstanceDetailView(instance: row.instance)
} label: {
instanceCard(row)
}
.buttonStyle(.plain)
} }
.buttonStyle(.plain)
} }
} }
.padding(16)
} }
.padding(16)
} }
.navigationTitle("节点总览") }
.toolbar { .navigationTitle("节点总览")
ToolbarItemGroup(placement: .primaryAction) { .toolbar {
Button { ToolbarItemGroup(placement: .primaryAction) {
model.selectionMode.toggle() Button {
model.selectedIDs.removeAll() model.selectionMode.toggle()
} label: { model.selectedIDs.removeAll()
Label("批量选择", systemImage: model.selectionMode ? "xmark" : "checklist") } label: {
} Label(model.selectionMode ? "完成" : "批量选择", systemImage: model.selectionMode ? "xmark" : "checklist")
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
Button { Button {
model.showingForm = true model.showingForm = true
} label: { } label: {
Label("添加实例", systemImage: "plus") Label("添加实例", systemImage: "plus")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
} }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
} }
.refreshable { }
await model.refresh() .confirmationDialog(
} "删除已选实例?登录信息也会一并清除。",
.task { isPresented: $showsDeleteDialog,
await model.refresh() titleVisibility: .visible
} ) {
.sheet(isPresented: $model.showingForm) { Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) {
InstanceFormView(model: model) 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 { private var searchField: some View {
HStack(spacing: 8) { AISearchField("搜索实例或地址", text: $model.searchText) {
Image(systemName: "magnifyingglass") Task { await model.refresh() }
.foregroundStyle(AITheme.muted)
TextField("搜索实例或地址", text: $model.searchText)
.textFieldStyle(.plain)
.onSubmit {
Task { await model.refresh() }
}
} }
.padding(.horizontal, 14) }
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.surface) private var selectionBar: some View {
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) AICard {
.overlay { HStack(spacing: 10) {
RoundedRectangle(cornerRadius: AITheme.buttonRadius) Button {
.strokeBorder(AITheme.line) 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)
Button {
showsDeleteDialog = true
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
.disabled(model.selectedIDs.isEmpty)
}
} }
} }
@@ -79,6 +79,10 @@ final class FleetViewModel: ObservableObject {
await refresh() await refresh()
} }
func deleteSelected() async {
await delete(Array(selectedIDs))
}
private func applyProbe(_ id: UUID, _ result: ConnectionResult?) { private func applyProbe(_ id: UUID, _ result: ConnectionResult?) {
guard let index = rows.firstIndex(where: { $0.id == id }) else { return } guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
rows[index].status = result?.status ?? .unknown rows[index].status = result?.status ?? .unknown
@@ -9,6 +9,11 @@ struct InstanceFormView: View {
@State private var password = "" @State private var password = ""
@State private var validationMessage: String? @State private var validationMessage: String?
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
@@ -20,19 +25,15 @@ struct InstanceFormView: View {
field("名称", text: $name, placeholder: "Home Node") field("名称", text: $name, placeholder: "Home Node")
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
field("标签", text: $tagsText, placeholder: "home, primary") field("标签", text: $tagsText, placeholder: "home, primary")
SecureField("实例密码(可选)", text: $password) field("实例密码", text: $password, placeholder: "可选,保存到钥匙串", secure: true)
.textContentType(.password)
.padding(.horizontal, 12)
.frame(minHeight: 38)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
} }
} }
if let validationMessage { if let validationMessage {
Text(validationMessage) AICard {
.font(.subheadline) Label(validationMessage, systemImage: "exclamationmark.triangle")
.foregroundStyle(AITheme.danger) .font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(AITheme.danger)
}
} }
} }
.padding(16) .padding(16)
@@ -49,26 +50,35 @@ struct InstanceFormView: View {
save() save()
} }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) .buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.disabled(!canSave)
} }
} }
} }
} }
private func field(_ title: String, text: Binding<String>, placeholder: String) -> some View { private func field(
_ title: String,
text: Binding<String>,
placeholder: String,
secure: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(title) Text(title)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink) .foregroundStyle(AITheme.ink)
TextField(placeholder, text: text) if secure {
.textFieldStyle(.plain) SecureField(placeholder, text: text)
.padding(.horizontal, 12) .textFieldStyle(AIFieldStyle())
.frame(minHeight: 38) .textContentType(.password)
.background(AITheme.canvas) } else {
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) TextField(placeholder, text: text)
.textFieldStyle(AIFieldStyle())
}
} }
} }
private func save() { private func save() {
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)), guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
origin.scheme == "http" || origin.scheme == "https", origin.scheme == "http" || origin.scheme == "https",
origin.host != nil origin.host != nil
@@ -81,7 +91,7 @@ struct InstanceFormView: View {
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
let input = InstanceInput( let input = InstanceInput(
name: name, name: normalizedName,
origin: origin, origin: origin,
tags: tags, tags: tags,
password: password.isEmpty ? nil : password password: password.isEmpty ? nil : password
@@ -9,6 +9,7 @@ struct LogsView: View {
@StateObject private var model: LogsViewModel @StateObject private var model: LogsViewModel
@State private var tab: Tab = .runtime @State private var tab: Tab = .runtime
@State private var showsPruneDialog = false
init(model: LogsViewModel? = nil) { init(model: LogsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel()) _model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
@@ -54,10 +55,22 @@ struct LogsView: View {
} label: { } label: {
Label("刷新", systemImage: "arrow.clockwise") Label("刷新", systemImage: "arrow.clockwise")
} }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
.disabled(model.isLoading) .disabled(model.isLoading)
} }
} }
.confirmationDialog(
"清理 7 天前的连接日志?操作不可恢复。",
isPresented: $showsPruneDialog,
titleVisibility: .visible
) {
Button("清理连接日志", role: .destructive) {
Task {
_ = await model.pruneConnections(olderThanDays: 7)
}
}
Button("取消", role: .cancel) {}
}
.refreshable { .refreshable {
await model.refresh() await model.refresh()
} }
@@ -81,16 +94,7 @@ struct LogsView: View {
private var filterBar: some View { private var filterBar: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
TextField("搜索日志、错误码或实例", text: $model.searchText) AISearchField("搜索日志、错误码或实例", text: $model.searchText)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
Picker("实例", selection: $model.selectedInstanceID) { Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none) Text("全部实例").tag(Optional<UUID>.none)
@@ -168,9 +172,7 @@ struct LogsView: View {
HStack { HStack {
sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条") sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条")
Button { Button {
Task { showsPruneDialog = true
_ = await model.pruneConnections(olderThanDays: 7)
}
} label: { } label: {
Label("清理", systemImage: "trash") Label("清理", systemImage: "trash")
} }
@@ -2,6 +2,7 @@ import SwiftUI
struct MessagesView: View { struct MessagesView: View {
@StateObject private var model: MessagesViewModel @StateObject private var model: MessagesViewModel
@State private var pendingDeleteMessage: SMSMessage?
init(model: MessagesViewModel? = nil) { init(model: MessagesViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel()) _model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
@@ -54,6 +55,20 @@ struct MessagesView: View {
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) .buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
} }
} }
.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 { .refreshable {
await model.refresh() await model.refresh()
} }
@@ -83,16 +98,7 @@ struct MessagesView: View {
} }
.pickerStyle(.menu) .pickerStyle(.menu)
TextField("搜索号码或内容", text: $model.searchText) AISearchField("搜索号码或内容", text: $model.searchText)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
} }
} }
} }
@@ -115,7 +121,7 @@ struct MessagesView: View {
.foregroundStyle(AITheme.muted) .foregroundStyle(AITheme.muted)
Spacer() Spacer()
Button("删除") { Button("删除") {
Task { await model.delete(message) } pendingDeleteMessage = message
} }
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact)) .buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
} }
@@ -7,6 +7,18 @@ struct SendMessageFormView: View {
@State private var content = "" @State private var content = ""
@State private var validationMessage: String? @State private var validationMessage: String?
private var trimmedPhoneNumber: String {
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedContent: String {
content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSend: Bool {
!trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
@@ -26,16 +38,19 @@ struct SendMessageFormView: View {
.pickerStyle(.menu) .pickerStyle(.menu)
TextField("收件号码", text: $phoneNumber) TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(.plain)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight) .frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas) .background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
TextField("消息内容", text: $content, axis: .vertical) RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
.lineLimit(4...8) .lineLimit(4...8)
.padding(12)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
} }
} }
if let validationMessage { if let validationMessage {
@@ -57,6 +72,7 @@ struct SendMessageFormView: View {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("发送") { send() } Button("发送") { send() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) .buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.disabled(!canSend)
} }
} }
} }
@@ -65,7 +81,7 @@ struct SendMessageFormView: View {
private func send() { private func send() {
Task { Task {
do { do {
try await model.send(phoneNumber: phoneNumber, content: content) try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
phoneNumber = "" phoneNumber = ""
content = "" content = ""
validationMessage = nil validationMessage = nil
@@ -2,6 +2,7 @@ import SwiftUI
struct NotificationsView: View { struct NotificationsView: View {
@StateObject private var model: NotificationsViewModel @StateObject private var model: NotificationsViewModel
@State private var pendingDeleteQueueID: String?
init(model: NotificationsViewModel? = nil) { init(model: NotificationsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel()) _model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
@@ -58,6 +59,24 @@ struct NotificationsView: View {
.disabled(model.isLoading) .disabled(model.isLoading)
} }
} }
.confirmationDialog(
"删除这条通知队列记录?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteQueueID != nil },
set: { if !$0 { pendingDeleteQueueID = nil } }
),
titleVisibility: .visible
) {
Button("删除队列记录", role: .destructive) {
guard let id = pendingDeleteQueueID else { return }
Task {
if let item = model.overview?.queue.items.first(where: { $0.id == id }) {
await model.deleteQueue(item)
}
}
}
Button("取消", role: .cancel) {}
}
.refreshable { .refreshable {
await model.refresh() await model.refresh()
} }
@@ -189,7 +208,7 @@ struct NotificationsView: View {
.disabled(model.busyQueueID == item.id) .disabled(model.busyQueueID == item.id)
Button { Button {
Task { await model.deleteQueue(item) } pendingDeleteQueueID = item.id
} label: { } label: {
Label("删除", systemImage: "trash") Label("删除", systemImage: "trash")
} }
@@ -31,7 +31,6 @@ struct SettingsView: View {
runtimeCard runtimeCard
storageCard storageCard
securityCard securityCard
backgroundCard
} }
.padding(16) .padding(16)
} }
@@ -71,10 +70,8 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
sectionTitle("应用信息", systemImage: "info.circle") sectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime { if let runtime = model.runtime {
infoRow("名称", runtime.appName)
infoRow("版本", "\(runtime.marketingVersion) (\(runtime.buildVersion))") infoRow("版本", "\(runtime.marketingVersion) (\(runtime.buildVersion))")
infoRow("标识符", AppMetadata.bundleIdentifier) infoRow("设备", runtime.deviceModel)
infoRow("设备", "\(runtime.platform) · \(runtime.deviceModel)")
} else { } else {
Text("正在读取应用信息…") Text("正在读取应用信息…")
.font(.subheadline) .font(.subheadline)
@@ -88,15 +85,15 @@ struct SettingsView: View {
AICard { AICard {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
sectionTitle("本地数据", systemImage: "externaldrive") sectionTitle("本地数据", systemImage: "externaldrive")
infoRow("实例配置", "App Documents/instances.json") infoRow("存储位置", "仅保存在本机应用沙盒")
infoRow("日志中心", "App Documents/log-center.json") infoRow("当前占用", model.storageLabel)
infoRow("日志数量", model.storageLabel)
Button { Button {
showsClearDialog = true showsClearDialog = true
} label: { } label: {
Label("清除运行日志", systemImage: "trash") Label("清除运行日志", systemImage: "trash")
} }
.buttonStyle(AIButtonStyle(kind: .danger, size: .regular)) .frame(maxWidth: .infinity)
.buttonStyle(AIButtonStyle(kind: .danger, size: .large))
} }
} }
} }
@@ -107,25 +104,6 @@ struct SettingsView: View {
sectionTitle("安全与登录", systemImage: "lock.shield") sectionTitle("安全与登录", systemImage: "lock.shield")
infoRow("实例密码", "保存在 iOS 钥匙串") infoRow("实例密码", "保存在 iOS 钥匙串")
infoRow("登录会话", "仅保存在内存") infoRow("登录会话", "仅保存在内存")
infoRow("访问密码", "已停用")
}
}
}
private var backgroundCard: some View {
AICard {
VStack(alignment: .leading, spacing: 12) {
sectionTitle("后台能力", systemImage: "clock.badge.exclamationmark")
Text(model.serverCapabilitiesNotice)
.font(.subheadline)
.foregroundStyle(AITheme.muted)
if model.serverCapabilities.isEmpty {
AITag("无后台任务", tint: AITheme.muted)
} else {
ForEach(model.serverCapabilities) { capability in
infoRow(capability.name, capability.detail)
}
}
} }
} }
} }
@@ -9,19 +9,11 @@ struct SettingsRuntime: Equatable {
let deviceModel: String let deviceModel: String
} }
struct ServerCapability: Equatable, Identifiable {
let id: String
let name: String
let detail: String
}
@MainActor @MainActor
final class SettingsViewModel: ObservableObject { final class SettingsViewModel: ObservableObject {
@Published private(set) var runtime: SettingsRuntime? @Published private(set) var runtime: SettingsRuntime?
@Published private(set) var serverCapabilities: [ServerCapability] = []
@Published private(set) var isLoading = false @Published private(set) var isLoading = false
@Published var storageLabel = "--" @Published var storageLabel = "--"
@Published var serverCapabilitiesNotice = ""
@Published var notice: String? @Published var notice: String?
@Published var errorMessage: String? @Published var errorMessage: String?
@@ -42,8 +34,6 @@ final class SettingsViewModel: ObservableObject {
platform: "iOS", platform: "iOS",
deviceModel: Self.deviceModel() deviceModel: Self.deviceModel()
) )
serverCapabilities = []
serverCapabilitiesNotice = "定时任务与后台刷新已在 iOS 版停用。"
await loadStorageLabel() await loadStorageLabel()
isLoading = false isLoading = false
@@ -5,26 +5,36 @@ struct MultiSimAdminApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
TabView { TabView {
FleetView() NavigationStack {
.tabItem { FleetView()
Label("节点", systemImage: "server.rack") }
} .tabItem {
MessagesView() Label("节点", systemImage: "server.rack")
.tabItem { }
Label("消息", systemImage: "message") NavigationStack {
} MessagesView()
NotificationsView() }
.tabItem { .tabItem {
Label("通知", systemImage: "bell") Label("消息", systemImage: "message")
} }
LogsView() NavigationStack {
.tabItem { NotificationsView()
Label("日志", systemImage: "doc.text") }
} .tabItem {
SettingsView() Label("通知", systemImage: "bell")
.tabItem { }
Label("设置", systemImage: "gearshape") NavigationStack {
} LogsView()
}
.tabItem {
Label("日志", systemImage: "doc.text")
}
NavigationStack {
SettingsView()
}
.tabItem {
Label("设置", systemImage: "gearshape")
}
} }
.tint(AITheme.primary) .tint(AITheme.primary)
} }
@@ -12,6 +12,7 @@
<key>CFBundleShortVersionString</key><string>0.1.0</string> <key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundleVersion</key><string>1</string> <key>CFBundleVersion</key><string>1</string>
<key>LSRequiresIPhoneOS</key><true/> <key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key><true/> <key>NSAllowsArbitraryLoads</key><true/>
@@ -1,6 +1,10 @@
import Foundation import Foundation
enum DefaultContainer { enum DefaultContainer {
private static let logStore: any LogStore = FileLogStore(
location: documentFileURL("log-center.json")
)
static func documentFileURL(_ name: String) -> URL { static func documentFileURL(_ name: String) -> URL {
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return directory.appendingPathComponent(name) return directory.appendingPathComponent(name)
@@ -41,7 +45,7 @@ enum DefaultContainer {
@MainActor @MainActor
static func makeLogsModel() -> LogsViewModel { static func makeLogsModel() -> LogsViewModel {
let store = FileLogStore(location: documentFileURL("log-center.json")) let store = logStore
return LogsViewModel( return LogsViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")), repository: FileInstanceRepository(location: documentFileURL("instances.json")),
service: LogCenterService( service: LogCenterService(
@@ -56,7 +60,7 @@ enum DefaultContainer {
@MainActor @MainActor
static func makeSettingsModel() -> SettingsViewModel { static func makeSettingsModel() -> SettingsViewModel {
SettingsViewModel(store: FileLogStore(location: documentFileURL("log-center.json"))) SettingsViewModel(store: logStore)
} }
@MainActor @MainActor
@@ -6,18 +6,22 @@ public struct AIButtonStyle: ButtonStyle {
case secondary case secondary
case danger case danger
var foregroundColor: Color { var foregroundColor: Color { foregroundColor(isEnabled: true) }
var backgroundColor: Color { backgroundColor(isEnabled: true) }
func foregroundColor(isEnabled: Bool) -> Color {
switch self { switch self {
case .primary, .danger: return .white case .primary, .danger: return .white.opacity(isEnabled ? 1 : 0.72)
case .secondary: return AITheme.ink case .secondary: return isEnabled ? AITheme.ink : AITheme.muted
} }
} }
var backgroundColor: Color { func backgroundColor(isEnabled: Bool) -> Color {
switch self { switch self {
case .primary: return AITheme.primary case .primary: return isEnabled ? AITheme.primary : AITheme.primary.opacity(0.35)
case .secondary: return AITheme.surface case .secondary: return isEnabled ? AITheme.surface : AITheme.canvas
case .danger: return AITheme.danger case .danger: return isEnabled ? AITheme.danger : AITheme.danger.opacity(0.30)
} }
} }
} }
@@ -30,6 +34,7 @@ public struct AIButtonStyle: ButtonStyle {
private let kind: Kind private let kind: Kind
private let size: Size private let size: Size
@Environment(\.isEnabled) private var isEnabled
public init(kind: Kind = .secondary, size: Size = .regular) { public init(kind: Kind = .secondary, size: Size = .regular) {
self.kind = kind self.kind = kind
@@ -43,17 +48,25 @@ public struct AIButtonStyle: ButtonStyle {
case .regular: AITheme.buttonHeight case .regular: AITheme.buttonHeight
} }
configuration.label configuration.label
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(kind.foregroundColor) .foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
.frame(minWidth: 44, minHeight: height) .frame(minWidth: minimumWidth, minHeight: height)
.padding(.horizontal, 14) .padding(.horizontal, kind == .secondary ? 12 : 14)
.background(kind.backgroundColor) .background(kind.backgroundColor(isEnabled: isEnabled))
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay { .overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius) RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(kind == .secondary ? AITheme.line : .clear) .strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear)
} }
.opacity(configuration.isPressed ? 0.78 : 1) .opacity(configuration.isPressed ? 0.78 : 1)
} }
private var minimumWidth: CGFloat {
switch size {
case .compact: return AITheme.compactButtonMinWidth
case .regular: return AITheme.regularButtonMinWidth
case .large: return 92
}
}
} }
@@ -39,6 +39,58 @@ public struct AITag: View {
} }
} }
public struct AIFieldStyle: TextFieldStyle {
public init() {}
public func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
}
}
public struct AISearchField: View {
private let placeholder: String
@Binding private var text: String
private var onSubmit: (() -> Void)?
public init(
_ placeholder: String,
text: Binding<String>,
onSubmit: (() -> Void)? = nil
) {
self.placeholder = placeholder
self._text = text
self.onSubmit = onSubmit
}
public var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AITheme.muted)
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.onSubmit {
onSubmit?()
}
}
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.surface)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
}
}
public struct AIBrandBar: View { public struct AIBrandBar: View {
public init() {} public init() {}
@@ -12,10 +12,12 @@ public enum AITheme {
public static let warning = Color(red: 0.722, green: 0.471, blue: 0.067) public static let warning = Color(red: 0.722, green: 0.471, blue: 0.067)
public static let success = Color(red: 0.082, green: 0.584, blue: 0.439) public static let success = Color(red: 0.082, green: 0.584, blue: 0.439)
public static let buttonHeight: CGFloat = 38 public static let buttonHeight: CGFloat = 44
public static let compactButtonHeight: CGFloat = 34 public static let compactButtonHeight: CGFloat = 40
public static let largeButtonHeight: CGFloat = 42 public static let largeButtonHeight: CGFloat = 50
public static let iconButtonSize: CGFloat = 40 public static let iconButtonSize: CGFloat = 44
public static let buttonRadius: CGFloat = 10 public static let buttonRadius: CGFloat = 12
public static let cardRadius: CGFloat = 14 public static let cardRadius: CGFloat = 16
public static let compactButtonMinWidth: CGFloat = 58
public static let regularButtonMinWidth: CGFloat = 76
} }
@@ -4,12 +4,12 @@ import XCTest
@testable import MultiSimAdmin @testable import MultiSimAdmin
final class AIThemeTests: XCTestCase { final class AIThemeTests: XCTestCase {
func testControlDimensionsMatchWebScale() { func testControlDimensionsMatchIOS() {
XCTAssertEqual(AITheme.buttonHeight, 38, accuracy: 0.1) XCTAssertEqual(AITheme.buttonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.compactButtonHeight, 34, accuracy: 0.1) XCTAssertEqual(AITheme.compactButtonHeight, 40, accuracy: 0.1)
XCTAssertEqual(AITheme.largeButtonHeight, 42, accuracy: 0.1) XCTAssertEqual(AITheme.largeButtonHeight, 50, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 10, accuracy: 0.1) XCTAssertEqual(AITheme.buttonRadius, 12, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 40, accuracy: 0.1) XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1)
} }
func testButtonKindsHaveDistinctBaseColors() { func testButtonKindsHaveDistinctBaseColors() {
@@ -20,4 +20,23 @@ final class AIThemeTests: XCTestCase {
].map(\.backgroundColor) ].map(\.backgroundColor)
XCTAssertEqual(Set(colors).count, 3) XCTAssertEqual(Set(colors).count, 3)
} }
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)
]
)
}
} }
@@ -52,6 +52,36 @@ final class FleetViewModelTests: XCTestCase {
XCTAssertTrue(model.rows.isEmpty) XCTAssertTrue(model.rows.isEmpty)
XCTAssertNil(credentials.saved[row.instance.id]) XCTAssertNil(credentials.saved[row.instance.id])
} }
func testDeleteSelectedClearsSelectionAndCredential() async throws {
let repository = MemoryInstanceRepository()
let credentials = NoopCredentialStore()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: credentials
)
try await model.add(InstanceInput(
name: "Selected",
origin: URL(string: "http://192.168.1.50:3000")!,
password: "pass"
))
try await model.add(InstanceInput(
name: "Other",
origin: URL(string: "http://192.168.1.60:3000")!,
password: nil
))
let selectedID = model.rows[0].id
model.selectionMode = true
model.selectedIDs = [selectedID]
await model.deleteSelected()
XCTAssertEqual(model.rows.map(\.instance.name), ["Selected"])
XCTAssertEqual(credentials.removed, [selectedID])
XCTAssertFalse(model.selectionMode)
XCTAssertTrue(model.selectedIDs.isEmpty)
}
} }
struct AlwaysOnlineClient: SimAdminClientProtocol { struct AlwaysOnlineClient: SimAdminClientProtocol {
@@ -4,7 +4,7 @@ import XCTest
final class SettingsViewModelTests: XCTestCase { final class SettingsViewModelTests: XCTestCase {
@MainActor @MainActor
func testLoadsRuntimeAppAndDisabledServerCapabilities() async throws { func testLoadsRuntimeWithoutServerCapabilities() async throws {
let store = SettingsMemoryLogStore() let store = SettingsMemoryLogStore()
let model = SettingsViewModel(store: store) let model = SettingsViewModel(store: store)
@@ -16,8 +16,6 @@ final class SettingsViewModelTests: XCTestCase {
XCTAssertEqual(model.runtime?.platform, "iOS") XCTAssertEqual(model.runtime?.platform, "iOS")
XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false) XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false)
XCTAssertTrue(model.serverCapabilities.isEmpty)
XCTAssertEqual(model.serverCapabilitiesNotice, "定时任务与后台刷新已在 iOS 版停用。")
XCTAssertNil(model.errorMessage) XCTAssertNil(model.errorMessage)
} }