feat(ios): refine native ui controls
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user