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