mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-30 14:13:50 +00:00
feat(servers): separate details from edit forms
This commit is contained in:
@@ -69,6 +69,13 @@ final class CatalogWorkflow: ObservableObject {
|
||||
subscriptionServerRefreshSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private let serverUpdateSubject = PassthroughSubject<Set<NodeID>, Never>()
|
||||
|
||||
/// 成功保存或订阅刷新后装载详情参数,包括只改变凭据而树值不变的提交。
|
||||
var serverDetailChanges: AnyPublisher<Set<NodeID>, Never> {
|
||||
subscriptionServerRefreshSubject.merge(with: serverUpdateSubject).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func publishSubscriptionServerRefresh(_ id: NodeID) {
|
||||
guard let summary = subscriptions.first(where: { $0.id == id }),
|
||||
let group = tree.node(withID: summary.groupID)
|
||||
@@ -97,6 +104,25 @@ final class CatalogWorkflow: ObservableObject {
|
||||
isEditable: entry.source == .manual, plugin: pluginPresentation(for: fields))
|
||||
}
|
||||
|
||||
/// 已保存详情事实;普通渲染不读取密码或参数。
|
||||
func serverDetailPresentation(for id: NodeID) -> ServerDetailPresentation? {
|
||||
guard let entry = dependencies.coordinator.committedCatalog.entry(for: id),
|
||||
case .server(let fields) = entry.kind
|
||||
else { return nil }
|
||||
return ServerDetailPresentation(
|
||||
name: entry.displayName, address: fields.address, port: fields.port,
|
||||
encryptionMethod: fields.encryptionMethod, plugin: pluginPresentation(for: fields))
|
||||
}
|
||||
|
||||
/// 详情参数装载独立于密码和插件可用性;未知插件仍可核对原文。
|
||||
func serverDetailPluginOptions(for id: NodeID) throws -> String {
|
||||
guard let entry = dependencies.coordinator.committedCatalog.entry(for: id),
|
||||
case .server(let fields) = entry.kind
|
||||
else { throw ServerFormLoadError.notFound }
|
||||
guard let reference = fields.pluginOptionsRef else { return "" }
|
||||
return try requiredServerSecret(reference)
|
||||
}
|
||||
|
||||
/// 服务器编辑面(显式命令,story 11):解析密码与目录中的具名插件参数明文。
|
||||
/// 节点不存在或不是服务器叶子为 `nil`。
|
||||
func serverEditForm(for id: NodeID) throws -> ServerEditForm? {
|
||||
@@ -273,6 +299,7 @@ final class CatalogWorkflow: ObservableObject {
|
||||
credentials: dependencies.credentials, journal: &journal)
|
||||
try catalog.updateServer(id, with: fields)
|
||||
}
|
||||
serverUpdateSubject.send([id])
|
||||
} catch {
|
||||
throw CommitError(underlying: error, credentialRollback: journal.rollback())
|
||||
}
|
||||
|
||||
@@ -412,3 +412,12 @@ struct ServerFormPresentation: Equatable {
|
||||
self.plugin = facts
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情的已保存展示事实,参数明文通过独立读取 interface 装载。
|
||||
struct ServerDetailPresentation {
|
||||
let name: String
|
||||
let address: String
|
||||
let port: Int
|
||||
let encryptionMethod: String
|
||||
let plugin: PluginSectionState
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
extension PluginSectionState {
|
||||
/// 详情和编辑表单共用可用性解释;未知引用的操作建议由各视图呈现。
|
||||
func availabilityWarning(for program: String) -> String? {
|
||||
if mappingsUnreadable {
|
||||
return "无法读取用户插件映射,插件暂不可用。请修复映射后重试。"
|
||||
}
|
||||
guard let facts = programs.first(where: { $0.program == program }) else { return nil }
|
||||
switch facts.availability {
|
||||
case .available: return nil
|
||||
case .missing: return "插件文件不存在,请检查用户插件路径或应用安装。"
|
||||
case .notExecutable: return "插件文件不可执行,请检查文件类型和执行权限。"
|
||||
case .unreadable: return "无法读取插件文件信息,请检查路径和访问权限。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 「新建服务器」表单(服务器视图工具栏入口):连接字段草稿与表单栅格走
|
||||
/// 共享 `ServerFormFields`(与编辑表单同一 interface),默认值端口 8388、
|
||||
/// 加密 aes-256-gcm、插件「无」。落点由父视图按既有
|
||||
/// `importTargetParent` 语义在打开时固定;提交成功关闭表单并把新身份交给
|
||||
/// selection(由父视图选中);校验/提交失败留在表单,错误经共享呈现器点名。
|
||||
struct NewServerSheet: View {
|
||||
let workflow: CatalogWorkflow
|
||||
let errors: ErrorAlertPresenter
|
||||
let parent: NodeID?
|
||||
let onCreated: (NodeID) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// 连接字段草稿由共享 module 持有(与编辑表单同一 interface),默认值
|
||||
/// 端口 8388、加密 aes-256-gcm、插件「无」。
|
||||
@StateObject private var fields = ServerFormFields.newForm()
|
||||
@State private var isSubmitting = false
|
||||
@FocusState private var fieldFocus: ServerFormField?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("新建服务器")
|
||||
.font(.title2)
|
||||
|
||||
// isEditable 恒真:提交期间的禁用由外层 .disabled(isSubmitting) 负责,
|
||||
// 避免参数编辑区在提交瞬间闪换成订阅只读视图。
|
||||
ServerFormFieldsGrid(
|
||||
fields: fields, plugin: pluginSection, isEditable: true,
|
||||
fieldFocus: $fieldFocus)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("取消", role: .cancel) { dismiss() }
|
||||
Button("创建") { create() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.disabled(isSubmitting)
|
||||
.interactiveDismissDisabled(isSubmitting)
|
||||
.defaultFocus($fieldFocus, .name)
|
||||
.padding(20)
|
||||
.frame(minWidth: 520, minHeight: 400)
|
||||
}
|
||||
|
||||
/// 插件区状态经 workflow 投影(与编辑面同缝),选中态跟随草稿。
|
||||
private var pluginSection: PluginSectionState {
|
||||
workflow.newFormPluginSection(selection: fields.pluginChoice)
|
||||
}
|
||||
|
||||
private func create() {
|
||||
guard !isSubmitting else { return }
|
||||
guard fields.validateForSubmit(), let draft = fields.draft else {
|
||||
fieldFocus = fields.firstErrorField
|
||||
return
|
||||
}
|
||||
isSubmitting = true
|
||||
Task {
|
||||
defer { isSubmitting = false }
|
||||
do {
|
||||
let id = try await workflow.createServer(draft, into: parent)
|
||||
onCreated(id)
|
||||
dismiss()
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ struct ServerPluginSection: View {
|
||||
Picker("插件", selection: $selection) {
|
||||
Text("无").tag(PluginSelection.none)
|
||||
ForEach(plugin.programs.map(\.program), id: \.self) { program in
|
||||
Text(program).tag(PluginSelection.named(program: program))
|
||||
Text(program).tag(
|
||||
selection == .unknown(program: program)
|
||||
? selection : PluginSelection.named(program: program))
|
||||
}
|
||||
if let program = plugin.unresolvedProgram(for: selection) {
|
||||
Text(program).tag(selection)
|
||||
@@ -96,19 +98,8 @@ struct ServerPluginSection: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func availabilityNotice(program: String, plugin: PluginSectionState) -> some View {
|
||||
if plugin.mappingsUnreadable {
|
||||
warning(Text("无法读取用户插件映射,插件暂不可用。请修复映射后重试。"))
|
||||
} else if let facts = plugin.programs.first(where: { $0.program == program }) {
|
||||
switch facts.availability {
|
||||
case .available:
|
||||
EmptyView()
|
||||
case .missing:
|
||||
warning(Text("插件文件不存在,请检查用户插件路径或应用安装。"))
|
||||
case .notExecutable:
|
||||
warning(Text("插件文件不可执行,请检查文件类型和执行权限。"))
|
||||
case .unreadable:
|
||||
warning(Text("无法读取插件文件信息,请检查路径和访问权限。"))
|
||||
}
|
||||
if let message = plugin.availabilityWarning(for: program) {
|
||||
warning(Text(message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +326,7 @@ private struct PluginOptionsEditor: View {
|
||||
|
||||
/// 订阅节点的参数只读呈现(issue #81):Table 控件的表格展现与原始文本
|
||||
/// 只读,可切换查看模式,无任何编辑入口。
|
||||
private struct PluginOptionsReadOnlyEditor: View {
|
||||
struct PluginOptionsReadOnlyEditor: View {
|
||||
@ObservedObject var draft: PluginOptionsDraft
|
||||
|
||||
private var visibleRows: [PluginOptionsDraft.Row] {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Combine
|
||||
|
||||
/// 只读详情的参数会话:切换身份清空旧值,刷新失败不展示过时参数。
|
||||
@MainActor
|
||||
final class ServerDetailParameters: ObservableObject {
|
||||
let options = PluginOptionsDraft()
|
||||
@Published private(set) var failure: ServerFormLoadError?
|
||||
private var serverID: NodeID?
|
||||
|
||||
func showServer(_ id: NodeID, load: (NodeID) throws -> String) {
|
||||
serverID = id
|
||||
reload(load: load)
|
||||
}
|
||||
|
||||
func reload(load: (NodeID) throws -> String) {
|
||||
guard let serverID else { return }
|
||||
options.load("")
|
||||
do {
|
||||
options.load(try load(serverID))
|
||||
failure = nil
|
||||
} catch {
|
||||
failure = (error as? ServerFormLoadError) ?? .credentialsUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func didRefresh(affectedServers: Set<NodeID>, load: (NodeID) throws -> String) {
|
||||
guard let serverID, affectedServers.contains(serverID) else { return }
|
||||
reload(load: load)
|
||||
}
|
||||
}
|
||||
@@ -1,202 +1,116 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 服务器详情:标题反映已保存名称,字段持有独立草稿;订阅服务器只读。
|
||||
/// 保存成功后重载提交值,失败保留草稿;分享经分区工具栏命令读取已保存
|
||||
/// 配置(ServersView+Share.swift)。
|
||||
/// 已保存服务器资料;详情只装载插件参数,不读取密码或编辑草稿。
|
||||
struct ServerDetailView: View {
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
let serverID: NodeID
|
||||
/// 运行时事实(活动目标标记):由父视图从既有接缝传入,详情面不持控制器。
|
||||
let isActiveTarget: Bool
|
||||
let errors: ErrorAlertPresenter
|
||||
@StateObject private var parameters = ServerDetailParameters()
|
||||
|
||||
/// 连接字段草稿由共享 module 持有(与新建表单同一 interface),经显式
|
||||
/// 编辑命令装载(凭据明文仅在编辑动作中出现)。
|
||||
@StateObject private var fields = ServerFormFields()
|
||||
@State private var isSubmitting = false
|
||||
@FocusState private var fieldFocus: ServerFormField?
|
||||
|
||||
private var isEditable: Bool {
|
||||
fields.presentation?.isEditable ?? false
|
||||
}
|
||||
|
||||
private var node: CatalogTreeNode? {
|
||||
workflow.tree.node(withID: serverID)
|
||||
}
|
||||
private var node: CatalogTreeNode? { workflow.tree.node(withID: serverID) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
detailHeader
|
||||
formContent
|
||||
ScrollView {
|
||||
if let facts = workflow.serverDetailPresentation(for: serverID) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
header(facts)
|
||||
if let node, node.isInvalid {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(node.invalidReasons.enumerated()), id: \.offset) { _, reason in
|
||||
Label(
|
||||
AppPresentation.message(
|
||||
for: ActivationFailure.invalidLeaf(node: node.id, reason: reason)),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("连接信息").font(.headline)
|
||||
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 12) {
|
||||
informationRow("地址", value: facts.address)
|
||||
informationRow("端口", value: String(facts.port))
|
||||
informationRow("加密方式", value: facts.encryptionMethod)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
pluginSection(facts.plugin)
|
||||
}
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 32)
|
||||
.disabled(isSubmitting)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
if isEditable {
|
||||
detailFooter
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
}
|
||||
.onAppear { fields.showServer(serverID, load: workflow.serverEditForm) }
|
||||
.onChange(of: serverID) { _, _ in
|
||||
fields.showServer(serverID, load: workflow.serverEditForm)
|
||||
}
|
||||
.onReceive(workflow.$tree) { _ in
|
||||
fields.updatePresentation(
|
||||
workflow.serverFormPresentation(for: serverID), load: workflow.serverEditForm)
|
||||
}
|
||||
.onReceive(workflow.subscriptionServerRefreshes) { affected in
|
||||
fields.updatePresentation(
|
||||
workflow.serverFormPresentation(for: serverID), load: workflow.serverEditForm)
|
||||
fields.subscriptionDidRefresh(affectedServers: affected, load: workflow.serverEditForm)
|
||||
.onAppear { parameters.showServer(serverID, load: workflow.serverDetailPluginOptions) }
|
||||
.onReceive(workflow.serverDetailChanges) { affected in
|
||||
parameters.didRefresh(affectedServers: affected, load: workflow.serverDetailPluginOptions)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 详情头与表单
|
||||
private func header(_ facts: ServerDetailPresentation) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(facts.name)
|
||||
.font(.title2.weight(.semibold))
|
||||
.textSelection(.enabled)
|
||||
HStack(spacing: 8) {
|
||||
Text(node?.source == .subscription ? "订阅节点 · 远端管理" : "手动服务器")
|
||||
if isActiveTarget {
|
||||
Label("活动目标", systemImage: "bolt.fill")
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var detailHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
Image(systemName: "server.rack")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
.frame(width: 38, height: 38)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(workflow.displayName(for: serverID))
|
||||
.font(.title2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(sourceDescription)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let node, node.isInvalid {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
private func informationRow(_ label: String, value: String) -> some View {
|
||||
GridRow(alignment: .top) {
|
||||
Text(label).foregroundStyle(.secondary)
|
||||
Text(value).textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func pluginSection(_ plugin: PluginSectionState) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("插件").font(.headline)
|
||||
switch plugin.selection {
|
||||
case .none:
|
||||
Text("无").foregroundStyle(.secondary)
|
||||
case .named(let program), .unknown(let program):
|
||||
Text(program).textSelection(.enabled)
|
||||
if let notice = plugin.availabilityWarning(for: program) {
|
||||
Label(notice, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.help("该服务器存在已知阻塞问题,激活会被点名拒绝")
|
||||
} else if plugin.unresolvedProgram(for: plugin.selection) != nil {
|
||||
Label("未找到插件“\(program)”。", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text("插件参数")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
if parameters.failure != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("无法读取插件参数", systemImage: "exclamationmark.triangle")
|
||||
Button("重新加载") { parameters.reload(load: workflow.serverDetailPluginOptions) }
|
||||
}
|
||||
} else if plugin.optionsPresent {
|
||||
PluginOptionsReadOnlyEditor(draft: parameters.options)
|
||||
} else {
|
||||
Text("无").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceDescription: String {
|
||||
guard let node else { return "" }
|
||||
if node.source == .subscription {
|
||||
return "订阅节点 · 远端管理"
|
||||
}
|
||||
return isActiveTarget ? "手动服务器 · 活动目标" : "手动服务器"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var formContent: some View {
|
||||
if let node, node.isInvalid {
|
||||
invalidBanner(node)
|
||||
}
|
||||
if fields.hasLoadedServer {
|
||||
if let failure = fields.loadFailure {
|
||||
loadFailureNotice(failure, preservingDraft: true)
|
||||
}
|
||||
if !isEditable {
|
||||
Label("订阅节点由远端管理:连接字段只读。", systemImage: "info.circle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
ServerFormFieldsGrid(
|
||||
fields: fields, plugin: fields.presentation?.plugin, isEditable: isEditable,
|
||||
fieldFocus: $fieldFocus
|
||||
)
|
||||
.padding(.top, 20)
|
||||
} else if let failure = fields.loadFailure {
|
||||
loadFailureNotice(failure, preservingDraft: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFailureNotice(
|
||||
_ failure: ServerFormLoadError, preservingDraft: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("无法加载服务器资料", systemImage: "exclamationmark.triangle")
|
||||
.font(.headline)
|
||||
if preservingDraft {
|
||||
Text("重新加载失败。当前草稿已保留,重新加载成功后才能保存。")
|
||||
} else if failure == .notFound {
|
||||
Text("未选择节点")
|
||||
} else {
|
||||
Text("无法读取服务器凭据。请重新加载后再试。")
|
||||
}
|
||||
Button("重新加载", action: loadForm)
|
||||
.disabled(isSubmitting)
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
private func invalidBanner(_ node: CatalogTreeNode) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(node.invalidReasons.enumerated()), id: \.offset) { _, reason in
|
||||
Label(
|
||||
AppPresentation.message(
|
||||
for: ActivationFailure.invalidLeaf(node: node.id, reason: reason)),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
/// 底部操作区:重置恢复已保存值,保存提交草稿。
|
||||
private var detailFooter: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Button("重置") { loadForm() }
|
||||
.disabled(!fields.hasChanges || isSubmitting)
|
||||
Button("保存") { save() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!fields.hasChanges || !fields.canSaveServer || isSubmitting)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
// MARK: - 表单装载与提交
|
||||
|
||||
private func loadForm() {
|
||||
fields.reloadServer(load: workflow.serverEditForm)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard !isSubmitting, fields.canSaveServer, fields.hasChanges else { return }
|
||||
guard fields.validateForSubmit(), let draft = fields.draft else {
|
||||
fieldFocus = fields.firstErrorField
|
||||
return
|
||||
}
|
||||
let id = serverID
|
||||
isSubmitting = true
|
||||
Task {
|
||||
defer { isSubmitting = false }
|
||||
do {
|
||||
try await workflow.updateServer(id, draft: draft)
|
||||
fields.didSaveServer(id, load: workflow.serverEditForm)
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ enum ServerFormFieldError: Equatable {
|
||||
/// 服务器表单草稿(UI 持有):连接字段草稿状态与「字段 ↔ ServerEditForm/
|
||||
/// ServerEditDraft 命令」的映射只在此写一次;新建与编辑两个表单面各自
|
||||
/// `@StateObject` 持有一份,经 `load(from:)` 装载、`draft` 提交,第三个表单
|
||||
/// 面接入只需学这一对方法。栅格渲染见 `ServerFormFieldsGrid`;订阅只读禁用
|
||||
/// 面接入只需学这一对方法。栅格渲染见 `ServerFormFieldsGrid`;编辑资格
|
||||
/// 与插件区 facts 由 caller 注入。
|
||||
@MainActor
|
||||
final class ServerFormFields: ObservableObject {
|
||||
@@ -136,6 +136,7 @@ final class ServerFormFields: ObservableObject {
|
||||
let fields = ServerFormFields()
|
||||
fields.portText = "8388"
|
||||
fields.encryptionMethod = "aes-256-gcm"
|
||||
fields.savedDraft = fields.draft
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -192,8 +193,14 @@ final class ServerFormFields: ObservableObject {
|
||||
|
||||
func updatePresentation(
|
||||
_ facts: ServerFormPresentation?,
|
||||
preservingDraft: Bool = false,
|
||||
load: ((NodeID) throws -> ServerEditForm?)? = nil
|
||||
) {
|
||||
// Sheet 更新资格和可用性事实,保持打开时的草稿与基线。
|
||||
if preservingDraft {
|
||||
if presentation != facts { presentation = facts }
|
||||
return
|
||||
}
|
||||
if let selection = facts?.plugin.selection,
|
||||
presentation?.plugin.selection != selection
|
||||
{
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 新建与编辑共用表单生命周期;编辑身份与新建落点均在打开时固定。
|
||||
struct ServerFormSheet: View {
|
||||
enum Operation: Identifiable {
|
||||
case create(parent: NodeID?)
|
||||
case edit(NodeID)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .create: return "create"
|
||||
case .edit(let id): return "edit-\(id.rawValue)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@StateObject private var errors = ErrorAlertPresenter()
|
||||
let operation: Operation
|
||||
let onCreated: (NodeID) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var fields: ServerFormFields
|
||||
@State private var isSubmitting = false
|
||||
@State private var isConfirmingDiscard = false
|
||||
@FocusState private var fieldFocus: ServerFormField?
|
||||
|
||||
init(
|
||||
workflow: CatalogWorkflow, operation: Operation,
|
||||
onCreated: @escaping (NodeID) -> Void
|
||||
) {
|
||||
self.workflow = workflow
|
||||
self.operation = operation
|
||||
self.onCreated = onCreated
|
||||
// 独立草稿只在 sheet 打开时初始化,父视图更新不重建。
|
||||
switch operation {
|
||||
case .create: _fields = StateObject(wrappedValue: ServerFormFields.newForm())
|
||||
case .edit: _fields = StateObject(wrappedValue: ServerFormFields())
|
||||
}
|
||||
}
|
||||
|
||||
private var editID: NodeID? {
|
||||
if case .edit(let id) = operation { return id }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if editID != nil { return fields.hasChanges && fields.canSaveServer }
|
||||
return true
|
||||
}
|
||||
|
||||
private var plugin: PluginSectionState? {
|
||||
if editID != nil { return fields.presentation?.plugin }
|
||||
return workflow.newFormPluginSection(selection: fields.pluginChoice)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(editID == nil ? "新建服务器" : "编辑服务器")
|
||||
.font(.title2)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if editID != nil, fields.hasLoadedServer, fields.presentation == nil {
|
||||
Label("服务器已不存在,无法保存。", systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if let failure = fields.loadFailure {
|
||||
loadFailureNotice(failure)
|
||||
}
|
||||
if editID == nil || fields.hasLoadedServer {
|
||||
ServerFormFieldsGrid(
|
||||
fields: fields, plugin: plugin, isEditable: true,
|
||||
fieldFocus: $fieldFocus)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// 焦点框绘制在控件边界外,需在滚动区内部预留空间。
|
||||
.padding(8)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("取消", role: .cancel, action: requestDismiss)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
if editID != nil {
|
||||
Button("重置") { fields.reloadServer(load: workflow.serverEditForm) }
|
||||
.disabled(!fields.hasChanges || fields.presentation?.isEditable != true)
|
||||
}
|
||||
Button(editID == nil ? "创建" : "保存", action: submit)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
.disabled(isSubmitting)
|
||||
.interactiveDismissDisabled(isSubmitting || fields.hasChanges)
|
||||
.onExitCommand(perform: requestDismiss)
|
||||
.alert("放弃更改?", isPresented: $isConfirmingDiscard) {
|
||||
Button("继续编辑", role: .cancel) {}
|
||||
Button("放弃更改", role: .destructive) { dismiss() }
|
||||
}
|
||||
.presentingErrors(errors)
|
||||
.defaultFocus($fieldFocus, .name)
|
||||
.padding(20)
|
||||
.frame(minWidth: 600, idealWidth: 640, minHeight: 400, idealHeight: 620)
|
||||
.onAppear {
|
||||
if let editID { fields.showServer(editID, load: workflow.serverEditForm) }
|
||||
}
|
||||
.onReceive(workflow.$tree) { _ in
|
||||
if let editID {
|
||||
fields.updatePresentation(
|
||||
workflow.serverFormPresentation(for: editID), preservingDraft: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFailureNotice(_ failure: ServerFormLoadError) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("无法加载服务器资料", systemImage: "exclamationmark.triangle")
|
||||
.font(.headline)
|
||||
if fields.hasLoadedServer {
|
||||
Text("重新加载失败。当前草稿已保留,重新加载成功后才能保存。")
|
||||
} else if failure == .notFound {
|
||||
Text("服务器已不存在,无法保存。")
|
||||
} else {
|
||||
Text("无法读取服务器凭据。请重新加载后再试。")
|
||||
}
|
||||
Button("重新加载") { fields.reloadServer(load: workflow.serverEditForm) }
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
private func requestDismiss() {
|
||||
guard !isSubmitting else { return }
|
||||
if fields.hasChanges { isConfirmingDiscard = true } else { dismiss() }
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
guard !isSubmitting, canSubmit else { return }
|
||||
guard fields.validateForSubmit(), let draft = fields.draft else {
|
||||
fieldFocus = fields.firstErrorField
|
||||
return
|
||||
}
|
||||
isSubmitting = true
|
||||
Task {
|
||||
defer { isSubmitting = false }
|
||||
do {
|
||||
switch operation {
|
||||
case .create(let parent):
|
||||
onCreated(try await workflow.createServer(draft, into: parent))
|
||||
case .edit(let id):
|
||||
try await workflow.updateServer(id, draft: draft)
|
||||
}
|
||||
dismiss()
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ struct ServerSidebarRow: View {
|
||||
let activation: ActivationFeedbackState
|
||||
let activeTargetID: NodeID?
|
||||
let errors: ErrorAlertPresenter
|
||||
let onEdit: (NodeID) -> Void
|
||||
let onRename: (NodeID) -> Void
|
||||
let onNewGroup: (NodeID?) -> Void
|
||||
let onMove: (NodeID) -> Void
|
||||
@@ -85,6 +86,8 @@ struct ServerSidebarRow: View {
|
||||
Divider()
|
||||
if node.isGroup {
|
||||
Button("重命名…") { onRename(node.id) }
|
||||
} else {
|
||||
Button("编辑…") { onEdit(node.id) }
|
||||
}
|
||||
Button("新建分组…") {
|
||||
onNewGroup(node.isGroup ? node.id : node.parentID)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 服务器 destination(spec #21 D11,issue #32/#34/#35/#41):承载服务器目录树、
|
||||
/// 详情编辑与分组命令。workspace destination 由外层主窗口壳持有(新壳在
|
||||
/// MainWindowView.swift,票 #53);全局导入表单由窗口壳呈现,本视图持有服务器
|
||||
/// 功能的 sheet、alert、确认弹窗与编辑草稿边界;selection 由外层绑定,以便订阅
|
||||
/// 删除仍能清除失效的服务器选择。
|
||||
/// 服务器目录、只读详情与分组命令;本视图持有新建/编辑 sheet 和确认弹窗。
|
||||
/// selection 由主窗口绑定,订阅删除可清除失效选择。
|
||||
struct ServersView: View {
|
||||
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@@ -31,9 +28,8 @@ struct ServersView: View {
|
||||
@State private var renameTarget: NodeID?
|
||||
@State private var renameText = ""
|
||||
@State private var newGroupParent: NodeID?
|
||||
@State private var newServerParent: NodeID?
|
||||
@State private var isPresentingNewGroup = false
|
||||
@State private var isPresentingNewServer = false
|
||||
@State private var serverFormOperation: ServerFormSheet.Operation?
|
||||
@State private var deleteTarget: NodeID?
|
||||
@State private var moveTarget: NodeID?
|
||||
@State private var rootDropHovering = false
|
||||
@@ -41,8 +37,6 @@ struct ServersView: View {
|
||||
@State var shareContext: ShareContext?
|
||||
|
||||
var body: some View {
|
||||
// 主窗口外壳(票 #53)已提供分栏框架;本分区用扁平双栏承载目录与详情
|
||||
// (票 #55 再按原型重排)。
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
serverSidebar
|
||||
.frame(minWidth: 240, idealWidth: 280, maxWidth: 340)
|
||||
@@ -86,10 +80,10 @@ struct ServersView: View {
|
||||
) { context in
|
||||
MoveNodeSheet(workflow: workflow, errors: errors, nodeID: context.nodeID)
|
||||
}
|
||||
.sheet(isPresented: $isPresentingNewServer) {
|
||||
NewServerSheet(
|
||||
workflow: workflow, errors: errors,
|
||||
parent: newServerParent, onCreated: selectCreatedNode)
|
||||
.sheet(item: $serverFormOperation) { operation in
|
||||
ServerFormSheet(
|
||||
workflow: workflow, operation: operation,
|
||||
onCreated: selectCreatedNode)
|
||||
}
|
||||
// 切换选中项时立即收起分享,避免显示旧服务器。
|
||||
.onChange(of: selection) {
|
||||
@@ -151,6 +145,7 @@ struct ServersView: View {
|
||||
activation: activation,
|
||||
activeTargetID: activeTargetID,
|
||||
errors: errors,
|
||||
onEdit: presentEditServer,
|
||||
onRename: { id in
|
||||
renameTarget = id
|
||||
renameText = workflow.displayName(for: id)
|
||||
@@ -238,13 +233,14 @@ extension ServersView {
|
||||
errors: errors)
|
||||
} else {
|
||||
ServerDetailView(
|
||||
workflow: workflow, serverID: id, isActiveTarget: activeTargetID == id,
|
||||
errors: errors)
|
||||
workflow: workflow, serverID: id, isActiveTarget: activeTargetID == id
|
||||
)
|
||||
.id(id)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"未选择节点", systemImage: "sidebar.left",
|
||||
description: Text("在左侧选择服务器或分组查看与编辑详情"))
|
||||
description: Text("在左侧选择服务器或分组查看详情"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +264,16 @@ extension ServersView {
|
||||
}
|
||||
.help("新建分组")
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
if let selection { presentEditServer(selection) }
|
||||
} label: {
|
||||
Label("编辑服务器", systemImage: "pencil")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.help("编辑服务器")
|
||||
.disabled(selection.flatMap { workflow.serverFormPresentation(for: $0) }?.isEditable != true)
|
||||
}
|
||||
// 删除:作用于当前选中的服务器叶子或手动分组,确认弹窗与右键菜单共用
|
||||
// deleteTarget 流(档位见 deleteFacts);无选中或选中项不可删(订阅节点
|
||||
// 结构只读,GLOSSARY.md)即禁用,资格判定归政策 seam。
|
||||
@@ -322,8 +328,12 @@ extension ServersView {
|
||||
}
|
||||
|
||||
private func presentNewServer(in parent: NodeID?) {
|
||||
newServerParent = parent
|
||||
isPresentingNewServer = true
|
||||
serverFormOperation = .create(parent: parent)
|
||||
}
|
||||
|
||||
private func presentEditServer(_ id: NodeID) {
|
||||
guard workflow.serverFormPresentation(for: id)?.isEditable == true else { return }
|
||||
serverFormOperation = .edit(id)
|
||||
}
|
||||
|
||||
private func selectCreatedNode(_ id: NodeID) {
|
||||
|
||||
@@ -214,6 +214,159 @@ struct ServerFormLoadingTests {
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerFormLoadingTests {
|
||||
@Test
|
||||
func newFormStartsUnchangedAndReturningToDefaultsClearsChanges() {
|
||||
let fields = ServerFormFields.newForm()
|
||||
#expect(!fields.hasChanges)
|
||||
fields.remark = "未保存"
|
||||
#expect(fields.hasChanges)
|
||||
fields.remark = ""
|
||||
#expect(!fields.hasChanges)
|
||||
fields.portText = "bad"
|
||||
#expect(fields.hasChanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
func detailFactsAndOptionsRemainReadableWithoutPassword() async throws {
|
||||
let credentials = FormCredentialStore()
|
||||
try await Self.withWorkflow(credentials: credentials) { workflow in
|
||||
let program = ManagedPluginCatalog.plugins[0].program
|
||||
let id = try await workflow.createServer(
|
||||
ServerEditDraft(
|
||||
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
|
||||
password: "pw", remark: "服务器", plugin: .named(program: program),
|
||||
pluginOptions: "tls;host=example.com;host=other.example;empty="), into: nil)
|
||||
let passwordRef = try #require(
|
||||
credentials.storage.storageSnapshot.first(where: { $0.value == "pw" })?.key)
|
||||
var changedServers: [Set<NodeID>] = []
|
||||
let observation = workflow.serverDetailChanges.sink { changedServers.append($0) }
|
||||
defer { observation.cancel() }
|
||||
let saved = try #require(try workflow.serverEditForm(for: id))
|
||||
try await workflow.updateServer(
|
||||
id,
|
||||
draft: ServerEditDraft(
|
||||
address: saved.address, port: saved.port, encryptionMethod: saved.encryptionMethod,
|
||||
password: "pw", remark: saved.remark, plugin: saved.plugin.selection,
|
||||
pluginOptions: "tls;host=example.com;host=other.example;empty="))
|
||||
#expect(changedServers == [[id]])
|
||||
try credentials.delete(CredentialReference(rawValue: passwordRef))
|
||||
credentials.reads = 0
|
||||
let facts = try #require(workflow.serverDetailPresentation(for: id))
|
||||
#expect(facts.name == "服务器")
|
||||
#expect(facts.address == "203.0.113.1")
|
||||
#expect(facts.plugin.options.isEmpty)
|
||||
#expect(credentials.reads == 0)
|
||||
#expect(
|
||||
try workflow.serverDetailPluginOptions(for: id)
|
||||
== "tls;host=example.com;host=other.example;empty=")
|
||||
#expect(credentials.reads == 1)
|
||||
#expect(throws: ServerFormLoadError.credentialsUnavailable) {
|
||||
_ = try workflow.serverEditForm(for: id)
|
||||
}
|
||||
credentials.failReads = true
|
||||
#expect(throws: ServerFormLoadError.credentialsUnavailable) {
|
||||
_ = try workflow.serverDetailPluginOptions(for: id)
|
||||
}
|
||||
#expect(workflow.serverDetailPresentation(for: id)?.address == "203.0.113.1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func unknownPluginDetailReadsOptionsWithoutLoadingPassword() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = CatalogFileStore(fileURL: directory.appendingPathComponent("catalog.json"))
|
||||
let credentials = FormCredentialStore()
|
||||
let optionsRef = CredentialReference.fresh()
|
||||
try credentials.save("opaque=one;opaque=two", for: optionsRef)
|
||||
var catalog = ConfigurationCatalog()
|
||||
let id = try catalog.addServer(
|
||||
ServerFields(
|
||||
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
|
||||
passwordRef: .fresh(), remark: "未知插件服务器",
|
||||
pluginProgram: "unknown-test-plugin", pluginOptionsRef: optionsRef))
|
||||
try store.save(CatalogDocument(catalog: catalog))
|
||||
let workflow = makeCatalogWorkflow(
|
||||
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()),
|
||||
credentials: credentials)
|
||||
credentials.reads = 0
|
||||
#expect(
|
||||
workflow.serverDetailPresentation(for: id)?.plugin.selection
|
||||
== .unknown(program: "unknown-test-plugin"))
|
||||
#expect(credentials.reads == 0)
|
||||
#expect(try workflow.serverDetailPluginOptions(for: id) == "opaque=one;opaque=two")
|
||||
#expect(credentials.reads == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func parameterSessionSwitchesIdentityAndOnlyReloadsAffectedRefreshes() {
|
||||
let parameters = ServerDetailParameters()
|
||||
let first = NodeID(rawValue: "first")
|
||||
let second = NodeID(rawValue: "second")
|
||||
var reads = 0
|
||||
parameters.showServer(first) { _ in
|
||||
reads += 1
|
||||
return "tls;host=one;host=two;empty="
|
||||
}
|
||||
#expect(parameters.options.rows.filter { !$0.isBlank }.count == 4)
|
||||
parameters.showServer(first) { _ in
|
||||
reads += 1
|
||||
return "tls;host=one;host=two;empty="
|
||||
}
|
||||
parameters.didRefresh(affectedServers: [second]) { _ in
|
||||
Issue.record("不相关刷新不应重载")
|
||||
return ""
|
||||
}
|
||||
parameters.didRefresh(affectedServers: [first]) { _ in
|
||||
reads += 1
|
||||
return "host=new"
|
||||
}
|
||||
#expect(reads == 3)
|
||||
#expect(parameters.options.composedString == "host=new")
|
||||
parameters.showServer(second) { _ in throw ServerFormLoadError.credentialsUnavailable }
|
||||
#expect(parameters.options.composedString == "")
|
||||
#expect(parameters.failure == .credentialsUnavailable)
|
||||
parameters.reload { _ in "trailing\\" }
|
||||
#expect(parameters.failure == nil)
|
||||
#expect(parameters.options.mode == .rawText)
|
||||
#expect(parameters.options.rawText == "trailing\\")
|
||||
parameters.didRefresh(affectedServers: [second]) { _ in
|
||||
throw ServerFormLoadError.credentialsUnavailable
|
||||
}
|
||||
#expect(parameters.options.rawText.isEmpty)
|
||||
#expect(parameters.failure == .credentialsUnavailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
func editingPresentationChangesNeverReplaceDraftAndDeletionBlocksSaving() {
|
||||
let fields = ServerFormFields()
|
||||
let id = NodeID(rawValue: "manual")
|
||||
let form = ServerEditForm(
|
||||
address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm",
|
||||
password: "pw", remark: "服务器",
|
||||
plugin: PluginSectionState(
|
||||
selection: .unknown(program: "plugin"), programs: [], mappingsUnreadable: false,
|
||||
optionsPresent: true, options: ""), isEditable: true)
|
||||
fields.showServer(id) { _ in form }
|
||||
fields.remark = "未保存名称"
|
||||
let updatedPlugin = PluginSectionState(
|
||||
selection: .named(program: "plugin"), programs: [], mappingsUnreadable: false,
|
||||
optionsPresent: true, options: "")
|
||||
fields.updatePresentation(
|
||||
ServerFormPresentation(isEditable: true, plugin: updatedPlugin), preservingDraft: true)
|
||||
#expect(fields.pluginChoice == .unknown(program: "plugin"))
|
||||
#expect(fields.remark == "未保存名称")
|
||||
#expect(fields.canSaveServer)
|
||||
fields.updatePresentation(nil, preservingDraft: true)
|
||||
#expect(!fields.canSaveServer)
|
||||
#expect(fields.hasLoadedServer)
|
||||
#expect(fields.password == "pw")
|
||||
#expect(fields.remark == "未保存名称")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final class FormCredentialStore: CredentialStoring {
|
||||
let storage = InMemoryCredentialStore()
|
||||
var reads = 0
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 服务器详情与编辑凭据装载分离
|
||||
|
||||
Status: Accepted (2026-10-06).
|
||||
|
||||
服务器详情展示已保存连接资料和插件参数,不展示或读取密码;完整编辑草稿只在编辑 sheet 中装载。详情通过独立的参数读取 interface 获取插件参数,读取失败仅影响参数区。这使密码不可读时普通资料仍可浏览,也避免详情为展示参数而依赖完整编辑草稿。
|
||||
|
||||
插件参数仍属于敏感信息,但用户明确选择在只读详情中直接查看,以支持订阅服务器及未知插件的参数核对。参数明文仅在详情会话中持有,不加入非秘密展示投影或持久快照。相较于只显示“已配置”,这里接受主动浏览详情时读取参数的取舍;相较于复用编辑装载,这里保留密码与参数各自独立的读取范围和失败处理。
|
||||
|
||||
ADR-0028 的密码自动填充关闭策略继续适用于新建与编辑 sheet 中共用的密码输入字段;原有详情页表单被只读呈现替代。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 服务器只读详情与编辑 sheet
|
||||
|
||||
日期:2026-10-06。交互决策与实现范围已确认。本文件记录本次任务规格,不代表当前代码现状。
|
||||
|
||||
## 已确认交互
|
||||
|
||||
- 详情显示已保存资料,采用分区标题与标签/值排版,不使用禁用表单控件。顶部显示名称、来源与活动目标标记;连接信息显示地址、端口和加密方式,文本可选择复制;插件区显示插件名称、可用性及参数;已知阻塞问题独立呈现。详情移除密码、保存与重置操作。
|
||||
- 插件参数沿用只读 table 的“参数名/值/类型”列,保留顺序、重复参数、开关与空值的区别。保留“参数列表/原始文本”切换,长值可从原始文本完整查看;不可解析时显示原始文本。未知或未安装插件仍显示已保存参数。
|
||||
- 详情不读取密码。参数在进入详情及当前服务器保存或对应订阅刷新成功后加载;读取失败仅影响参数区,提供“重新加载”,其他信息继续展示。切换服务器时不得显示上一服务器的参数。
|
||||
- 工具栏增加 `pencil` 图标编辑按钮,仅选中手动服务器时可用;手动服务器右键菜单增加“编辑…”,作用于被点击的服务器。订阅服务器工具栏编辑禁用,菜单省略编辑项。已知有阻塞问题的手动服务器仍可编辑。
|
||||
- 编辑 sheet 共用新建表单的字段布局、草稿映射与校验。底部为“取消/重置/保存”;有修改且成功装载可编辑服务器时才允许保存;重置恢复已保存值。保留已有凭据加载失败提示、重载和保存门禁。
|
||||
- 新建与编辑共用的表单滚动内容留出 8pt padding,避免焦点框被裁切;sheet 最小宽度为 580pt、建议宽度为 640pt,为参数列表留出更多空间。插件可用性分支与警告文案共用一份映射,未知引用的操作说明由各视图保留。
|
||||
- 保存成功关闭 sheet 并更新详情;失败保留草稿。提交期间禁止重复提交、重置和关闭。
|
||||
- 新建与编辑统一处理未保存修改:取消或 Escape 在有修改时显示“放弃更改?”,按钮为“继续编辑/放弃更改”;无修改直接关闭。其他可关闭 sheet 的路径采用相同放弃规则。
|
||||
- 编辑目标在打开时固定。每次重新打开读取最新保存值,关闭后不保留草稿;目录更新不覆盖当前编辑内容。目标被删除时禁止保存并提示。
|
||||
|
||||
## Module 与 seam
|
||||
|
||||
只读详情与编辑表单具有独立生命周期。详情从已提交配置获取展示事实,通过单独的参数读取 interface 获取已保存插件参数;参数读取失败不阻断普通事实展示。不得用读取完整 `ServerEditForm` 的方式满足详情需求。
|
||||
|
||||
新建与编辑共用 `ServerFormFields` 和字段栅格;共用 sheet 的布局、提交门禁与放弃修改行为,创建/更新命令保持各自意图。sheet 在 `ServersView` 中持有固定的编辑身份,工具栏和右键菜单调用同一个编辑入口。避免为两种操作各复制一套校验和关闭规则,也避免建立仅转发既有命令的 adapter。
|
||||
|
||||
只读参数视图复用解析与呈现能力,不复用编辑动作;保留旧数据的无损表示。参数明文仅在当前详情会话中持有,不加入非秘密投影或持久快照。
|
||||
|
||||
## 验证范围
|
||||
|
||||
验证详情不读取密码、参数独立加载及失败隔离、未知插件参数可见、切换服务器与订阅刷新行为。验证编辑目标固定、手动/订阅资格、草稿保留与重载、创建/保存成功关闭、失败保留以及放弃修改行为。按仓库要求执行格式、lint、相关 hostless 测试和构建;原生布局、table、sheet 与 Escape 交互需单独验证,不能以 hostless 测试代替。
|
||||
|
||||
## 本次验证记录
|
||||
|
||||
2026-10-06:格式化、lint 与 diff 检查完成;lint 有长度和复杂度等非阻断警告。全量 arm64 hostless 测试通过 958 个测试、1,038 次执行,零失败、零跳过;两组离线规则转换检查共 21 个测试通过。ad-hoc 应用构建通过。
|
||||
|
||||
原生详情布局、table、sheet、Escape 与其他关闭路径尚未人工验证;上述测试与构建结果不等于原生 UI 或真实系统代理行为验证。本次不涉及 sslocal 运行契约调整,未运行 smoke。
|
||||
Reference in New Issue
Block a user