Files
RouterOS/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift
T
KayandClaude Sonnet 5 3a26f50800 M31: Handbuch in der App (Textanker, DE+EN)
"?"-Hilfe-Buttons in allen 6 Haupt-Tabs, allen 6 Wizard-Schritten und
allen 45 Experte-Menüs öffnen ein Handbuch-Fenster (WKWebView) und
springen per Textanker direkt zur passenden Manual-Stelle.

build-manual.py generalisiert auf beliebig viele Sprachen (LANGUAGES-
Dict) statt hart DE/EN. Manual.en.md: komplette Handübersetzung aller
Fließtext-Kapitel. Kapitel 5 (Experte-Referenz) wird pro Sprache
automatisch übersetzt, indem L10n.swifts eigenes App-Übersetzungs-
Dictionary wiederverwendet wird (714 Einträge geparst) statt einer
zweiten, separat gepflegten Übersetzung.

Live bestätigt (DE und EN).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:15:29 +02:00

175 lines
9.3 KiB
Swift

import SwiftUI
struct LanStepView: View {
@ObservedObject var viewModel: SetupViewModel
let availableInterfaces: [NetworkInterface]
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
Form {
ForEach($viewModel.lanConfigs) { $config in
Section(L10n.t("Netzwerk auf", appLanguage) + " \(config.interfaceName)") {
Picker(L10n.t("Anschluss/Bridge", appLanguage), selection: $config.interfaceName) {
ForEach(availableInterfaces) { interface in
Text(interface.name).tag(interface.name)
}
}
.help(L10n.t("Der interne Netzwerk-Anschluss, an dem deine Geräte hängen (dein lokales Netzwerk, LAN).", appLanguage))
.onChange(of: config.interfaceName) { _, _ in
viewModel.checkPortConflict(for: config.id)
}
.onAppear {
viewModel.checkPortConflict(for: config.id)
}
if viewModel.isCheckingPortConflict.contains(config.id) {
HStack {
ProgressView().controlSize(.small)
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
}
} else if let conflict = viewModel.lanPortConflicts[config.id] {
PortConflictWarningView(
conflict: conflict,
isAcknowledged: viewModel.acknowledgedPortConflicts.contains(config.id),
appLanguage: appLanguage,
onConfirm: { viewModel.acknowledgePortConflict(for: config.id) }
)
}
TextField(L10n.t("Router-Adresse (z.B. 192.168.88.1/24)", appLanguage), text: $config.routerAddress)
.help(L10n.t("Die Adresse, unter der der Router selbst in diesem Netzwerk erreichbar ist.", appLanguage))
TextField(L10n.t("Netzwerk (z.B. 192.168.88.0/24)", appLanguage), text: $config.networkAddress)
.help(L10n.t("Der komplette Adressbereich dieses Netzwerks (z.B. /24 erlaubt bis zu 254 Geräte).", appLanguage))
TextField(L10n.t("Automatische Adressvergabe von", appLanguage), text: $config.poolRangeStart)
.help(L10n.t("Ab welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.", appLanguage))
TextField(L10n.t("bis", appLanguage), text: $config.poolRangeEnd)
.help(L10n.t("Bis zu welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.", appLanguage))
Stepper(
L10n.t("Adresse behalten für", appLanguage) + " \(config.leaseTimeHours) " + L10n.t("Stunden", appLanguage),
value: $config.leaseTimeHours,
in: 1...168
)
.help(L10n.t("Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.", appLanguage))
TextField(L10n.t("DNS-Server", appLanguage), text: $config.dnsServers)
.help(L10n.t("Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.", appLanguage))
if viewModel.mode == .expert {
Toggle(L10n.t("Von anderen Netzwerken isolieren", appLanguage), isOn: $config.isolated)
.help(L10n.t("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.", appLanguage))
if viewModel.lanConfigs.count > 1 {
Button(L10n.t("Netzwerk entfernen", appLanguage), role: .destructive) {
viewModel.removeLan(config.id)
}
}
}
}
}
if viewModel.mode == .expert {
Section {
Button(L10n.t("Weiteres LAN-Netzwerk hinzufügen", appLanguage)) {
viewModel.addLan()
}
}
}
Section {
HStack {
Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() }
Spacer()
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
.disabled(!isStepValid)
}
}
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Heimnetzwerk einrichten", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepLAN) } }
}
private var isStepValid: Bool {
viewModel.lanConfigs.allSatisfy { config in
!config.interfaceName.isEmpty
&& !config.routerAddress.isEmpty
&& !config.networkAddress.isEmpty
&& !config.poolRangeStart.isEmpty
&& !config.poolRangeEnd.isEmpty
&& !viewModel.hasUnresolvedPortConflict(for: config.id)
}
}
}
/// Shown inline under a LAN config's port Picker once `SetupViewModel.checkPortConflict(for:)`
/// finds the chosen port already carries other configuration. Requires two separate confirmations
/// before the app is allowed to clear it — per explicit request: this silently overriding a port's
/// existing role (an active WAN dial-up, a bridge membership, a manually-set address) previously
/// wasn't visible to the user at all beyond `DhcpServerCommandBuilder`'s own unconditional bridge
/// detach; this makes the consequences explicit and opt-in instead.
private struct PortConflictWarningView: View {
let conflict: PortConflict
let isAcknowledged: Bool
let appLanguage: String
let onConfirm: () -> Void
@State private var showConsequencesConfirmation = false
@State private var showFinalConfirmation = false
var body: some View {
if isAcknowledged {
Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield")
.appFont(.caption)
.foregroundStyle(.orange)
} else {
VStack(alignment: .leading, spacing: 6) {
Label(L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("ist nicht frei", appLanguage), systemImage: "exclamationmark.triangle.fill")
.appFont(.subheadline, bold: true)
.foregroundStyle(.red)
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
Text("• \(reason.description)")
.appFont(.caption)
}
Text(L10n.t("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
Button(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) {
showConsequencesConfirmation = true
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08)))
.confirmationDialog(
L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("freimachen?", appLanguage),
isPresented: $showConsequencesConfirmation,
titleVisibility: .visible
) {
Button(L10n.t("Fortfahren", appLanguage), role: .destructive) {
showFinalConfirmation = true
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
Text(consequenceText)
}
.confirmationDialog(
L10n.t("Wirklich sicher?", appLanguage),
isPresented: $showFinalConfirmation,
titleVisibility: .visible
) {
Button(L10n.t("Ja, endgültig freimachen", appLanguage), role: .destructive) {
onConfirm()
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
Text(L10n.t("Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.", appLanguage))
}
}
}
private var consequenceText: String {
([L10n.t("Folgendes wird entfernt, bevor", appLanguage) + " \(conflict.interfaceName) " + L10n.t("als neues Netzwerk eingerichtet wird:", appLanguage)]
+ conflict.reasons.map { "• \($0.description)" }
+ [L10n.t("Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen.", appLanguage)])
.joined(separator: "\n")
}
}