feat(ui): 服务器分享上收为分区工具栏按钮

- 工具栏最右新增「分享」(square.and.arrow.up),行为复用原二维码弹窗
  (220×220 QR + 内嵌复制 ss:// 链接,后台线程生成);详情区底部
  「复制 ss:// 链接」「二维码…」两按钮删除
- disabled 单谓词 sharePayload()==nil:空选择/选中分组/凭据不可解析
  一律不可用
- 弹窗锚在工具栏按钮;选中变化即收起,生成前清旧图防闪旧二维码
- ServerDetailView 失去 clipboard 参数;ServersView.errors 放宽
  internal 供跨文件扩展呈现失败
- CONTEXT.md Vocabulary 新增 Server share 词条(_Avoid_: export,
  export 保留给文件快照:分组 SIP-008 导出、诊断报告)
This commit is contained in:
Qiu Yuzhou
2026-10-04 14:08:56 +08:00
parent b3fa498aaf
commit fab2004aa5
4 changed files with 45 additions and 37 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, a source owner, and a required user-visible name.
- **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 share**: The user-requested presentation of one server configuration's `ss://` URI for transfer to other devices, via an on-screen QR code or the clipboard. It reads the committed configuration and resolves credentials at request time and writes no file; a server whose credentials cannot be resolved cannot be shared. It applies to a single server configuration leaf only. _Avoid_: export (in the UI, export is reserved for file snapshots such as the configuration group export and the diagnostics report).
- **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.
- **Plugin parameter draft**: The server form draft's session state for plugin options: an ordered list of parameter rows with stable row identities, each carrying a parameter name and an optional value that distinguishes a no-equals flag from an equals-with-empty-string value. Parsing and assembly follow the managed v2ray-plugin SIP003 escaping semantics. The raw options string remains the only payload crossing the persistence, credential, and runtime boundaries; viewing, mode switching, and unedited rows keep their original writing verbatim, and only rows the user explicitly edits are re-encoded. Recognizable syntax serves editing only and does not judge the parameter valid for the plugin.
- **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.
+2 -28
View File
@@ -1,14 +1,14 @@
import SwiftUI
/// 服务器详情:标题反映已保存名称,字段持有独立草稿;订阅服务器只读。
/// 保存成功后重载提交值,失败保留草稿;分享始终经显式命令读取已保存配置。
/// 保存成功后重载提交值,失败保留草稿;分享经分区工具栏命令读取已保存
/// 配置(ServersView+Share.swift)。
struct ServerDetailView: View {
@ObservedObject var workflow: CatalogWorkflow
let serverID: NodeID
/// 运行时事实(活动目标标记):由父视图从既有接缝传入,详情面不持控制器。
let isActiveTarget: Bool
let errors: ErrorAlertPresenter
let clipboard: any TextClipboard
/// 连接字段草稿由共享 module 持有(与新建表单同一 interface),经显式
/// 编辑命令装载(凭据明文仅在编辑动作中出现)。
@@ -16,9 +16,6 @@ struct ServerDetailView: View {
@State private var isSubmitting = false
@State private var loadedServerID: NodeID?
@FocusState private var fieldFocus: ServerFormField?
// 分享/二维码状态由同 module 的 ServerDetailView+Share.swift 扩展驱动。
@State var showQR = false
@State var qrImage: NSImage?
private var formState: ServerEditForm? {
workflow.serverEditForm(for: serverID)
@@ -49,9 +46,6 @@ struct ServerDetailView: View {
detailFooter
}
}
.popover(isPresented: $showQR) {
qrPopover
}
.onAppear(perform: loadForm)
.onChange(of: serverID) { _, _ in loadForm() }
}
@@ -113,9 +107,6 @@ struct ServerDetailView: View {
fieldFocus: $fieldFocus
)
.padding(.top, 20)
shareSection
.padding(.top, 24)
}
private func invalidBanner(_ node: CatalogTreeNode) -> some View {
@@ -133,23 +124,6 @@ struct ServerDetailView: View {
.padding(.top, 16)
}
private var shareSection: some View {
HStack(spacing: 10) {
Button {
copySsUri()
} label: {
Label("复制 ss:// 链接", systemImage: "doc.on.doc")
}
Button {
generateQR()
} label: {
Label("二维码…", systemImage: "qrcode")
}
.disabled(qrPayload() == nil)
Spacer(minLength: 0)
}
}
/// 底部操作区:重置恢复已保存值,保存提交草稿。
private var detailFooter: some View {
VStack(spacing: 0) {
@@ -1,8 +1,11 @@
import SwiftUI
/// 服务器详情的分享与二维码呈现(issue #16/D10,地图 #52 票 #55):
/// 服务器分区的分享与二维码呈现(issue #16/D10,地图 #52 票 #55):
/// 复制 ss:// 与二维码弹窗;分享是显式命令,二维码在后台线程生成。
extension ServerDetailView {
/// 2026-10-04 自详情区底部「复制 ss:// 链接」「二维码…」两按钮上收为分区
/// 工具栏「分享」按钮,作用于当前选中的服务器叶子:分组与空选择被 seam
/// 点名拒绝,凭据无法解析同样不可用,两者共用 nil 载荷这一 disabled 判据。
extension ServersView {
var qrPopover: some View {
VStack(spacing: 12) {
if let qrImage {
@@ -23,20 +26,26 @@ extension ServerDetailView {
.padding(20)
}
func qrPayload() -> String? {
try? workflow.shareURI(for: serverID)
/// 分享载荷:仅服务器叶子可产生(分组与未知节点被 seam 点名拒绝);
/// nil 即分享按钮不可用的统一判据。
func sharePayload() -> String? {
guard let selection else { return nil }
return try? workflow.shareURI(for: selection)
}
func copySsUri() {
guard let payload = sharePayload() else { return }
do {
try clipboard.write(workflow.shareURI(for: serverID))
try clipboard.write(payload)
} catch {
errors.present(error)
}
}
func generateQR() {
guard let payload = qrPayload() else { return }
guard let payload = sharePayload() else { return }
// 重置旧图:换选中项重开弹窗时先呈进度,不闪现上一台服务器的二维码。
qrImage = nil
showQR = true
Task.detached(priority: .userInitiated) {
let image = (try? QrCodeCodec.generatePNG(for: payload)).flatMap { NSImage(data: $0) }
+27 -3
View File
@@ -22,7 +22,8 @@ struct ServersView: View {
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
/// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。
@StateObject private var errors = ErrorAlertPresenter()
/// 非 private:ServersView+Share.swift 扩展经它呈现分享失败。
@StateObject var errors = ErrorAlertPresenter()
// 纯窗口状态(issue #41):selection/sheet/alert 不进目录 module。
@State private var renameTarget: NodeID?
@@ -34,6 +35,10 @@ struct ServersView: View {
@State private var deleteTarget: NodeID?
@State private var moveTarget: NodeID?
@State private var rootDropHovering = false
// 分享/二维码状态:由 ServersView+Share.swift 扩展驱动,弹窗锚在工具栏
// 分享按钮上(原详情区底部分享区已于 2026-10-04 上收至此)。
@State var showQR = false
@State var qrImage: NSImage?
var body: some View {
// 主窗口外壳(票 #53)已提供分栏框架;本分区用扁平双栏承载目录与详情
@@ -229,7 +234,7 @@ extension ServersView {
} else {
ServerDetailView(
workflow: workflow, serverID: id, isActiveTarget: activeTargetID == id,
errors: errors, clipboard: clipboard)
errors: errors)
}
} else {
ContentUnavailableView(
@@ -238,7 +243,7 @@ extension ServersView {
}
}
// MARK: - 工具栏(新建服务器 / 新建分组)
// MARK: - 工具栏(新建服务器 / 新建分组 / 分享)
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
@@ -260,6 +265,25 @@ extension ServersView {
.labelStyle(.iconOnly)
.help("新建分组")
}
// 分享(分享/导出术语见 CONTEXT.md):作用于选中的服务器叶子,弹窗内
// 提供二维码与复制 ss:// 链接两条通路;无有效载荷即不可用。
ToolbarItem(placement: .primaryAction) {
Button {
generateQR()
} label: {
Label("分享", systemImage: "square.and.arrow.up")
}
.labelStyle(.iconOnly)
.help("分享")
.disabled(sharePayload() == nil)
.popover(isPresented: $showQR) {
qrPopover
}
// 弹窗开着时切换选中项:立即收起,避免内容滞留为旧服务器。
.onChange(of: selection) {
showQR = false
}
}
}
// MARK: - 告警动作