Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/ServerPluginSection.swift
T
Qiu Yuzhou d2d20d27a6 feat(ui): 服务器表单输入限制与端口十进制草稿
issue #81 第一部分:手动表单产品上限(名称 128、地址 255、密码 1024
用户可见字符;插件参数 65,536 UTF-8 字节;端口最多五位十进制数字)。
校验收进 ServerFormFields 唯一入口 validateForSubmit(),字段错误
typed 呈现、修正即清除、首错按栅格顺序定位;validateName/hasNameError
退役。端口改十进制字符串草稿,draft 在草稿不可解析时为 nil、仅在
校验通过后消费,数值等价改写不算变更——无效输入不再悄悄提交旧绑定
值。与装载基线逐字相同的超长字段放行(历史数据兼容);导入与订阅
路径不受限。新错误文案入 Localizable.xcstrings(中英)。
2026-10-04 04:36:32 +08:00

115 lines
3.9 KiB
Swift

import SwiftUI
/// 服务器详情的插件区(issue #38/D10,地图 #52 票 #55):受管选择器——
/// 「无」+ 受管列表;集外引用(Legacy 导入/订阅带入)追加显式「本版本未
/// 提供」项使当前状态可见并原样保留。选中受管项才显示参数输入与可用性警告。
struct ServerPluginSection: View {
@Binding var selection: PluginSelection
@Binding var optionsText: String
let plugin: PluginSectionState?
let isEditable: Bool
/// 参数字段的行内校验错误(issue #81,如字节超限)。
let optionsError: ServerFormFieldError?
let optionsFocus: FocusState<ServerFormField?>.Binding
var body: some View {
VStack(alignment: .leading, spacing: 6) {
picker
detail
}
}
@ViewBuilder
private var picker: some View {
if let plugin {
Picker("插件", selection: $selection) {
Text("无").tag(PluginSelection.none)
ForEach(plugin.managed, id: \.program) { info in
Text(info.program).tag(PluginSelection.managed(program: info.program))
}
if case .unknown(let program) = plugin.selection {
Text("\(program)(本版本未提供)").tag(PluginSelection.unknown(program: program))
}
}
.labelsHidden()
.accessibilityLabel("插件")
.disabled(!isEditable)
} else {
Text("不可用").foregroundStyle(.secondary)
}
}
@ViewBuilder
private var detail: some View {
if let plugin {
switch selection {
case .none:
EmptyView()
case .managed(let program):
managedDetails(program: program, plugin: plugin)
case .unknown(let program):
unknownNotice(program: program, plugin: plugin)
}
}
}
/// 选中受管项:参数输入与可用性警告。
@ViewBuilder
private func managedDetails(program: String, plugin: PluginSectionState) -> some View {
if plugin.managed.contains(where: { $0.program == program }) {
VStack(alignment: .leading, spacing: 8) {
if !plugin.provided {
Label(
"受管插件可执行文件缺失:激活会被点名拒绝;请重新安装本 app。",
systemImage: "exclamationmark.triangle.fill"
)
.font(.footnote)
.foregroundStyle(.orange)
}
Text("插件参数")
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
TextField(
"插件参数",
text: $optionsText,
prompt: Text("选择插件后填写参数(如 mode=websocket;host=…)")
)
.textFieldStyle(.roundedBorder)
.focused(optionsFocus, equals: .pluginOptions)
.disabled(!isEditable)
if let optionsError {
Text(optionsErrorText(optionsError))
.font(.footnote)
.foregroundStyle(.red)
}
}
}
}
/// 集外引用:原样保留并明确指出当前 app 无法提供该插件。
@ViewBuilder
private func unknownNotice(program: String, plugin: PluginSectionState) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(
"本版本未提供「\(program)」:引用原样保留,激活包含该服务器会被点名拒绝;可改选「无」或受管插件。",
systemImage: "exclamationmark.triangle.fill"
)
.font(.footnote)
.foregroundStyle(.orange)
if plugin.optionsPresent {
Text("插件参数已配置(存于钥匙串,原样保留)。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
/// 参数错误文案:只呈现原因与上限,不回显参数内容。
private func optionsErrorText(_ error: ServerFormFieldError) -> LocalizedStringKey {
switch error {
case .tooManyBytes(let limit): return "插件参数最多 \(limit) 个字节"
case .missingName, .tooManyCharacters, .invalidPort: return ""
}
}
}