refactor(ui): 服务器表单草稿收成共享 ServerFormFields module

新建/编辑两个表单面此前各持 8 个 @State 字段、逐字复制的密码 reveal、
column helper 与字段 ↔ ServerEditDraft 映射;收进 App/ServerFormFields.swift
(草稿 ObservableObject + 表单栅格视图),caller 只剩 load(from:)/draft。
订阅只读禁用、集外加密追加与插件区 facts 参数化;methodChoices 统一后
新建面行为等价(初值恒在集内)。CONTEXT.md 补 Server form draft 术语。
This commit is contained in:
Qiu Yuzhou
2026-10-03 13:52:33 +08:00
parent aaabb4f7ea
commit cde430c5fc
4 changed files with 162 additions and 197 deletions
+1
View File
@@ -6,6 +6,7 @@
- **Server configuration**: The user-visible connection data for one Shadowsocks server. It is a leaf with an opaque, persistent UUID and a source owner.
- **Configuration group**: A named, ordered container that may contain server configurations and nested configuration groups. A node has at most one parent, and group membership forms an acyclic tree.
- **Configuration group export**: A user-requested, shareable snapshot of one configuration group and all of its descendant groups and server configurations. It carries the credentials and configured plugin options needed to recreate those servers, and may include servers that are not activation candidates. It is available only when the selected subtree contains at least one server configuration.
- **Server form draft**: The UI-held draft of one server configuration's connection fields, shared by the create and edit form faces. It loads from the edit command's resolved form state and commits as one `ServerEditDraft` command; field state, password reveal, and the form grid live in one shared module, so a new form face reuses the same load/commit pair.
- **Manual group**: A configuration group owned by the user. It may contain only manually owned nodes; deleting it recursively deletes its descendants. Deletion is always user-confirmed: one confirmation for an empty manual group, and a second confirmation naming the recursive subtree scale when it is non-empty.
- **Subscription**: A remote configuration source with a persistent opaque UUID and a fixed subscription-group identity. Its URL is a mutable endpoint/credential reference; editing the URL keeps the source identity, while deleting and creating a subscription establishes a new source identity.
- **Subscription group**: The fixed, source-owned configuration subtree provided by one subscription. Users cannot move or structurally edit its nodes. A subscription node's user-visible name/remark is one remote-derived value from `remarks`, falling back to the server address when absent; it has no local alias or note override.
+9 -90
View File
@@ -1,7 +1,8 @@
import SwiftUI
/// 「新建服务器」表单(服务器视图工具栏入口):字段、校验与插件区镜像编辑
/// 表单,默认值端口 8388、加密 aes-256-gcm、插件「无」。落点由父视图按既有
/// 「新建服务器」表单(服务器视图工具栏入口):连接字段草稿与表单栅格走
/// 共享 `ServerFormFields`(与编辑表单同一 interface),默认值端口 8388、
/// 加密 aes-256-gcm、插件「无」。落点由父视图按既有
/// `importTargetParent` 语义在打开时固定;提交成功关闭表单并把新身份交给
/// selection(由父视图选中);校验/提交失败留在表单,错误经共享呈现器点名。
struct NewServerSheet: View {
@@ -11,14 +12,9 @@ struct NewServerSheet: View {
@Binding var selection: NodeID?
@Environment(\.dismiss) private var dismiss
@State private var address = ""
@State private var port = 8388
@State private var encryptionMethod = "aes-256-gcm"
@State private var password = ""
@State private var remark = ""
@State private var pluginChoice: PluginSelection = .none
@State private var pluginOptionsText = ""
@State private var showPassword = false
/// 连接字段草稿由共享 module 持有(与编辑表单同一 interface),默认值
/// 端口 8388、加密 aes-256-gcm、插件「无」。
@StateObject private var fields = ServerFormFields.newForm()
var body: some View {
VStack(alignment: .leading, spacing: 14) {
@@ -30,63 +26,7 @@ struct NewServerSheet: View {
.foregroundStyle(.secondary)
}
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 18) {
GridRow {
column("服务器地址") {
TextField("服务器地址", text: $address)
.textFieldStyle(.roundedBorder)
}
column("端口") {
TextField("端口", value: $port, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
}
}
GridRow {
column("加密方式") {
Picker("加密方式", selection: $encryptionMethod) {
ForEach(EncryptionMethodCatalog.supported.sorted(), id: \.self) {
Text($0).tag($0)
}
}
}
column("备注") {
TextField("备注", text: $remark)
.textFieldStyle(.roundedBorder)
}
}
GridRow {
column("密码") {
HStack(spacing: 8) {
Group {
if showPassword {
TextField("密码", text: $password)
} else {
SecureField("密码", text: $password)
}
}
.textFieldStyle(.roundedBorder)
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
}
.buttonStyle(.borderless)
.help(showPassword ? "隐藏密码" : "显示密码")
}
}
.gridCellColumns(2)
}
GridRow {
column("受管理插件与参数") {
ServerPluginSection(
selection: $pluginChoice,
optionsText: $pluginOptionsText,
plugin: pluginSection,
isEditable: true)
}
.gridCellColumns(2)
}
}
ServerFormFieldsGrid(fields: fields, plugin: pluginSection, isEditable: true)
HStack {
Spacer()
@@ -104,7 +44,7 @@ struct NewServerSheet: View {
/// 语义拒绝并在编辑面呈现。
private var pluginSection: PluginSectionState {
PluginSectionState(
selection: pluginChoice,
selection: fields.pluginChoice,
managed: ManagedPluginCatalog.plugins,
provided: true,
optionsPresent: false,
@@ -114,16 +54,7 @@ struct NewServerSheet: View {
private func create() {
Task {
do {
let id = try await workflow.createServer(
ServerEditDraft(
address: address,
port: port,
encryptionMethod: encryptionMethod,
password: password,
remark: remark,
plugin: pluginChoice,
pluginOptions: pluginOptionsText),
into: parent)
let id = try await workflow.createServer(fields.draft, into: parent)
selection = id
dismiss()
} catch {
@@ -131,16 +62,4 @@ struct NewServerSheet: View {
}
}
}
private func column<Content: View>(
_ label: String, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
+8 -107
View File
@@ -15,14 +15,9 @@ struct ServerDetailView: View {
let errors: ErrorAlertPresenter
let clipboard: any TextClipboard
@State private var address = ""
@State private var port = 8388
@State private var encryptionMethod = ""
@State private var password = ""
@State private var remark = ""
@State private var pluginChoice: PluginSelection = .none
@State private var pluginOptionsText = ""
@State private var showPassword = false
/// 连接字段草稿由共享 module 持有(与新建表单同一 interface),经显式
/// 编辑命令装载(凭据明文仅在编辑动作中出现)。
@StateObject private var fields = ServerFormFields()
// 分享/二维码状态由同 module 的 ServerDetailView+Share.swift 扩展驱动。
@State var showQR = false
@State var qrImage: NSImage?
@@ -39,15 +34,6 @@ struct ServerDetailView: View {
workflow.tree.node(withID: serverID)
}
/// 当前 sslocal 能力目录;目录中既有的未知方法原样追加显示,便于用户修复。
private var methodChoices: [String] {
var choices = EncryptionMethodCatalog.supported.sorted()
if !encryptionMethod.isEmpty && !choices.contains(encryptionMethod) {
choices.append(encryptionMethod)
}
return choices
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
@@ -123,66 +109,9 @@ struct ServerDetailView: View {
.foregroundStyle(.secondary)
.padding(.top, 16)
}
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 18) {
GridRow {
column("服务器地址") {
TextField("服务器地址", text: $address)
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
}
column("端口") {
TextField("端口", value: $port, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
}
}
GridRow {
column("加密方式") {
Picker("加密方式", selection: $encryptionMethod) {
ForEach(methodChoices, id: \.self) { Text($0).tag($0) }
}
.disabled(!isEditable)
}
column("备注") {
TextField("备注", text: $remark)
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
}
}
GridRow {
column("密码") {
HStack(spacing: 8) {
Group {
if showPassword {
TextField("密码", text: $password)
} else {
SecureField("密码", text: $password)
}
}
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
}
.buttonStyle(.borderless)
.help(showPassword ? "隐藏密码" : "显示密码")
}
}
.gridCellColumns(2)
}
GridRow {
column("受管理插件与参数") {
ServerPluginSection(
selection: $pluginChoice,
optionsText: $pluginOptionsText,
plugin: formState?.plugin,
isEditable: isEditable)
}
.gridCellColumns(2)
}
}
ServerFormFieldsGrid(
fields: fields, plugin: formState?.plugin, isEditable: isEditable
)
.padding(.top, 20)
shareSection
@@ -241,44 +170,16 @@ struct ServerDetailView: View {
private func loadForm() {
guard let state = formState else { return }
address = state.address
port = state.port
encryptionMethod = state.encryptionMethod
password = state.password
remark = state.remark
pluginChoice = state.plugin.selection
pluginOptionsText = state.plugin.options
showPassword = false
fields.load(from: state)
}
private func save() {
Task {
do {
try await workflow.updateServer(
serverID,
draft: ServerEditDraft(
address: address,
port: port,
encryptionMethod: encryptionMethod,
password: password,
remark: remark,
plugin: pluginChoice,
pluginOptions: pluginOptionsText))
try await workflow.updateServer(serverID, draft: fields.draft)
} catch {
errors.present(error)
}
}
}
private func column<Content: View>(
_ label: String, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
+144
View File
@@ -0,0 +1,144 @@
import SwiftUI
/// 服务器表单草稿(UI 持有):连接字段草稿状态与「字段 ↔ ServerEditForm/
/// ServerEditDraft 命令」的映射只在此写一次;新建与编辑两个表单面各自
/// `@StateObject` 持有一份,经 `load(from:)` 装载、`draft` 提交,第三个表单
/// 面接入只需学这一对方法。栅格渲染见 `ServerFormFieldsGrid`;订阅只读禁用
/// 与插件区 facts 由 caller 注入。
@MainActor
final class ServerFormFields: ObservableObject {
@Published var address = ""
@Published var port = 8388
@Published var encryptionMethod = ""
@Published var password = ""
@Published var remark = ""
@Published var pluginChoice: PluginSelection = .none
@Published var pluginOptionsText = ""
@Published var showPassword = false
/// 新建表单的默认字段:端口 8388、加密 aes-256-gcm、插件「无」。
static func newForm() -> ServerFormFields {
let fields = ServerFormFields()
fields.encryptionMethod = "aes-256-gcm"
return fields
}
/// 当前 sslocal 能力目录;目录中既有的未知方法原样追加显示,便于用户修复。
/// 新建面初值恒在集内,追加分支不会触发,与仅列集内值行为一致。
var methodChoices: [String] {
var choices = EncryptionMethodCatalog.supported.sorted()
if !encryptionMethod.isEmpty && !choices.contains(encryptionMethod) {
choices.append(encryptionMethod)
}
return choices
}
/// 从编辑面状态装载(凭据明文只在编辑命令中出现,story 10/11);重置密码
/// 明文显示。
func load(from state: ServerEditForm) {
address = state.address
port = state.port
encryptionMethod = state.encryptionMethod
password = state.password
remark = state.remark
pluginChoice = state.plugin.selection
pluginOptionsText = state.plugin.options
showPassword = false
}
/// 提交载荷:配置与凭据字段作为一个逻辑变更(story 12)。
var draft: ServerEditDraft {
ServerEditDraft(
address: address,
port: port,
encryptionMethod: encryptionMethod,
password: password,
remark: remark,
plugin: pluginChoice,
pluginOptions: pluginOptionsText)
}
}
/// 共享表单栅格:地址/端口、加密/备注、密码全宽(reveal)、插件区全宽。
/// `isEditable` 为假时整表禁用(订阅节点远端管理);插件区 facts 由 caller
/// 注入——新建面无既有引用,编辑面用编辑命令解析出的状态。
struct ServerFormFieldsGrid: View {
@ObservedObject var fields: ServerFormFields
let plugin: PluginSectionState?
let isEditable: Bool
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 18) {
GridRow {
column("服务器地址") {
TextField("服务器地址", text: $fields.address)
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
}
column("端口") {
TextField("端口", value: $fields.port, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
}
}
GridRow {
column("加密方式") {
Picker("加密方式", selection: $fields.encryptionMethod) {
ForEach(fields.methodChoices, id: \.self) { Text($0).tag($0) }
}
.disabled(!isEditable)
}
column("备注") {
TextField("备注", text: $fields.remark)
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
}
}
GridRow {
column("密码") {
HStack(spacing: 8) {
Group {
if fields.showPassword {
TextField("密码", text: $fields.password)
} else {
SecureField("密码", text: $fields.password)
}
}
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
Button {
fields.showPassword.toggle()
} label: {
Image(systemName: fields.showPassword ? "eye.slash" : "eye")
}
.buttonStyle(.borderless)
.help(fields.showPassword ? "隐藏密码" : "显示密码")
}
}
.gridCellColumns(2)
}
GridRow {
column("受管理插件与参数") {
ServerPluginSection(
selection: $fields.pluginChoice,
optionsText: $fields.pluginOptionsText,
plugin: plugin,
isEditable: isEditable)
}
.gridCellColumns(2)
}
}
}
private func column<Content: View>(
_ label: String, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}