mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 05:33:45 +00:00
feat(ng2): restyle server detail and catalog rows (#55)
ServersView 扁平双栏承接目录与详情;ServerDetailView 重排为详情头(图标 + 名称 + 来源说明,活动目标由父视图传入)+ 两列 Grid 表单 + 底部取消/保存, 插件区拆 ServerPluginSection、分享/二维码拆 +Share 扩展;SidebarRow 分组行 加「手动/订阅分组 · N 项」副题;GroupDetailView 加同款详情头。行为语义不变。 地图 #52 · 票 #55;task build + test 全绿。
This commit is contained in:
@@ -18,49 +18,77 @@ struct GroupDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("分组") {
|
||||
TextField("名称", text: $name)
|
||||
.disabled(!isManual)
|
||||
if isManual {
|
||||
Button("保存名称") { saveName() }
|
||||
VStack(spacing: 0) {
|
||||
// 详情头(票 #55):与服务器详情同款图标 + 名称 + 来源说明。
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
Image(systemName: "folder")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
.frame(width: 38, height: 38)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(workflow.displayName(for: groupID))
|
||||
.font(.title2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(isManual ? "手动分组" : "订阅分组")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Section {
|
||||
LabeledContent("直接子节点", value: "\(directChildCount)")
|
||||
if let eligibility, eligibility.skippedInvalidCount > 0 {
|
||||
LabeledContent("已知无效服务器", value: "\(eligibility.skippedInvalidCount)")
|
||||
}
|
||||
LabeledContent("来源", value: isManual ? "手动" : "订阅")
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
_ = try await workflow.activate(groupID)
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 32)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
Divider()
|
||||
Form {
|
||||
Section("分组") {
|
||||
TextField("名称", text: $name)
|
||||
.disabled(!isManual)
|
||||
if isManual {
|
||||
Button("保存名称") { saveName() }
|
||||
}
|
||||
} label: {
|
||||
Label("激活此分组", systemImage: "bolt.fill")
|
||||
}
|
||||
.disabled(!(eligibility?.canActivate ?? false))
|
||||
if directChildCount == 0 {
|
||||
Text("空分组保留可编辑,但不能激活。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if eligibility?.canActivate == false {
|
||||
Text("分组中没有可激活的有效服务器。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let eligibility, eligibility.skippedInvalidCount > 0 {
|
||||
Text("激活时将跳过 \(eligibility.skippedInvalidCount) 个存在已知阻塞问题的服务器。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Section {
|
||||
LabeledContent("直接子节点", value: "\(directChildCount)")
|
||||
if let eligibility, eligibility.skippedInvalidCount > 0 {
|
||||
LabeledContent("已知无效服务器", value: "\(eligibility.skippedInvalidCount)")
|
||||
}
|
||||
LabeledContent("来源", value: isManual ? "手动" : "订阅")
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
_ = try await workflow.activate(groupID)
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("激活此分组", systemImage: "bolt.fill")
|
||||
}
|
||||
.disabled(!(eligibility?.canActivate ?? false))
|
||||
if directChildCount == 0 {
|
||||
Text("空分组保留可编辑,但不能激活。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if eligibility?.canActivate == false {
|
||||
Text("分组中没有可激活的有效服务器。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let eligibility, eligibility.skippedInvalidCount > 0 {
|
||||
Text("激活时将跳过 \(eligibility.skippedInvalidCount) 个存在已知阻塞问题的服务器。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(.leading, 20)
|
||||
.padding(.trailing, 24)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(workflow.displayName(for: groupID))
|
||||
.onAppear(perform: loadName)
|
||||
.onChange(of: groupID) { _, _ in loadName() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 服务器详情的分享与二维码呈现(issue #16/D10,地图 #52 票 #55):
|
||||
/// 复制 ss:// 与二维码弹窗;分享是显式命令,二维码在后台线程生成。
|
||||
extension ServerDetailView {
|
||||
var qrPopover: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let qrImage {
|
||||
Image(nsImage: qrImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 220, height: 220)
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(width: 220, height: 220)
|
||||
}
|
||||
Text("用其他设备的客户端扫描此二维码")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("复制 ss:// 链接") { copySsUri() }
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
func qrPayload() -> String? {
|
||||
try? workflow.shareURI(for: serverID)
|
||||
}
|
||||
|
||||
func copySsUri() {
|
||||
do {
|
||||
try clipboard.write(workflow.shareURI(for: serverID))
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
|
||||
func generateQR() {
|
||||
guard let payload = qrPayload() else { return }
|
||||
showQR = true
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let image = (try? QrCodeCodec.generatePNG(for: payload)).flatMap { NSImage(data: $0) }
|
||||
await MainActor.run {
|
||||
qrImage = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 服务器详情表单(issue #32/#38/#41):连接字段与插件选择可编辑(仅手动节
|
||||
/// 点)。表单状态经工作流的显式编辑命令解析(凭据明文仅在编辑动作中出现);
|
||||
/// 插件区为受管选择器(D10)——「无」+ 受管列表,选中受管项才显示参数输入;
|
||||
/// 集外引用以显式「本版本未提供」呈现并原样保留;分享区(二维码 + 复制 ss://,
|
||||
/// 显式分享命令)。订阅服务器整表只读。
|
||||
/// 服务器详情表单(issue #32/#38/#41,地图 #52 票 #55):按原型重排为详情头
|
||||
/// (图标 + 名称 + 来源说明)+ 两列表单栅格(地址/端口、加密/备注、密码全宽、
|
||||
/// 插件区)+ 底部操作区(取消恢复草稿 / 保存)。表单状态经工作流的显式编辑
|
||||
/// 命令解析(凭据明文仅在编辑动作中出现);插件区为受管选择器(D10)——
|
||||
/// 「无」+ 受管列表,选中受管项才显示参数输入;集外引用以显式「本版本未
|
||||
/// 提供」呈现并原样保留;分享区(二维码 + 复制 ss://,显式分享命令)。
|
||||
/// 订阅服务器整表只读(无底部操作区,横幅说明)。
|
||||
struct ServerDetailView: View {
|
||||
let workflow: CatalogWorkflow
|
||||
let serverID: NodeID
|
||||
/// 运行时事实(活动目标标记):由父视图从既有接缝传入,详情面不持控制器。
|
||||
let isActiveTarget: Bool
|
||||
let errors: ErrorAlertPresenter
|
||||
let clipboard: any TextClipboard
|
||||
|
||||
@@ -19,8 +23,9 @@ struct ServerDetailView: View {
|
||||
@State private var pluginChoice: PluginSelection = .none
|
||||
@State private var pluginOptionsText = ""
|
||||
@State private var showPassword = false
|
||||
@State private var showQR = false
|
||||
@State private var qrImage: NSImage?
|
||||
// 分享/二维码状态由同 module 的 ServerDetailView+Share.swift 扩展驱动。
|
||||
@State var showQR = false
|
||||
@State var qrImage: NSImage?
|
||||
|
||||
private var formState: ServerEditForm? {
|
||||
workflow.serverEditForm(for: serverID)
|
||||
@@ -44,81 +49,21 @@ struct ServerDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("连接") {
|
||||
TextField("地址", text: $address)
|
||||
.disabled(!isEditable)
|
||||
TextField("端口", value: $port, format: .number.grouping(.never))
|
||||
.disabled(!isEditable)
|
||||
Picker("加密方法", selection: $encryptionMethod) {
|
||||
ForEach(methodChoices, id: \.self) { Text($0).tag($0) }
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
detailHeader
|
||||
formContent
|
||||
}
|
||||
.disabled(!isEditable)
|
||||
HStack {
|
||||
if showPassword {
|
||||
TextField("密码", text: $password)
|
||||
.disabled(!isEditable)
|
||||
} else {
|
||||
SecureField("密码", text: $password)
|
||||
.disabled(!isEditable)
|
||||
}
|
||||
Button {
|
||||
showPassword.toggle()
|
||||
} label: {
|
||||
Image(systemName: showPassword ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(showPassword ? "隐藏密码" : "显示密码")
|
||||
}
|
||||
TextField("备注", text: $remark)
|
||||
.disabled(!isEditable)
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 32)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
|
||||
if let node, node.isInvalid {
|
||||
Section("激活状态") {
|
||||
ForEach(Array(node.invalidReasons.enumerated()), id: \.offset) { _, reason in
|
||||
Label(
|
||||
AppPresentation.message(
|
||||
for: ActivationFailure.invalidLeaf(node: node.id, reason: reason)),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("插件") {
|
||||
pluginSection
|
||||
}
|
||||
|
||||
if isEditable {
|
||||
Section {
|
||||
Button("保存修改") { save() }
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Label("订阅节点由远端管理:连接字段只读。", systemImage: "info.circle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("分享") {
|
||||
Button {
|
||||
copySsUri()
|
||||
} label: {
|
||||
Label("复制 ss:// 链接", systemImage: "doc.on.doc")
|
||||
}
|
||||
Button {
|
||||
generateQR()
|
||||
} label: {
|
||||
Label("二维码…", systemImage: "qrcode")
|
||||
}
|
||||
.disabled(qrPayload() == nil)
|
||||
detailFooter
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(workflow.displayName(for: serverID))
|
||||
.popover(isPresented: $showQR) {
|
||||
qrPopover
|
||||
}
|
||||
@@ -126,94 +71,174 @@ struct ServerDetailView: View {
|
||||
.onChange(of: serverID) { _, _ in loadForm() }
|
||||
}
|
||||
|
||||
/// 受管选择器(D10):「无」+ 受管列表;集外现有引用追加显式「本版本未提供」
|
||||
/// 项使当前状态可见。选中受管项才显示供应链事实与参数输入。
|
||||
@ViewBuilder
|
||||
private var pluginSection: some View {
|
||||
if let plugin = formState?.plugin {
|
||||
Picker("插件", selection: $pluginChoice) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
.disabled(!isEditable)
|
||||
// MARK: - 详情头与表单
|
||||
|
||||
switch pluginChoice {
|
||||
case .none:
|
||||
Text("不使用插件:生成的配置不含 plugin 字段。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
case .managed(let program):
|
||||
managedPluginDetails(program: program, plugin: plugin)
|
||||
case .unknown(let program):
|
||||
unknownPluginNotice(program: program, plugin: plugin)
|
||||
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")
|
||||
.foregroundStyle(.orange)
|
||||
.help("该服务器存在已知阻塞问题,激活会被点名拒绝")
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中受管项:供应链事实(来源项目、许可证、固定版本、重签)与参数输入。
|
||||
private var sourceDescription: String {
|
||||
guard let node else { return "" }
|
||||
if node.source == .subscription {
|
||||
return "订阅节点 · 远端管理"
|
||||
}
|
||||
return isActiveTarget ? "手动服务器 · 活动目标" : "手动服务器"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func managedPluginDetails(program: String, plugin: PluginSectionState) -> some View {
|
||||
if let info = plugin.managed.first(where: { $0.program == program }) {
|
||||
LabeledContent("来源项目", value: info.project)
|
||||
LabeledContent("许可证", value: info.license)
|
||||
LabeledContent("固定版本", value: info.release)
|
||||
Text("随 app 打包,构建期经 Developer ID 重签:\(info.signIdentifier)")
|
||||
private var formContent: some View {
|
||||
if let node, node.isInvalid {
|
||||
invalidBanner(node)
|
||||
}
|
||||
if !isEditable {
|
||||
Label("订阅节点由远端管理:连接字段只读。", systemImage: "info.circle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if !plugin.provided {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
|
||||
shareSection
|
||||
.padding(.top, 24)
|
||||
}
|
||||
|
||||
private func invalidBanner(_ node: CatalogTreeNode) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(node.invalidReasons.enumerated()), id: \.offset) { _, reason in
|
||||
Label(
|
||||
"受管插件可执行文件缺失:激活会被点名拒绝;请重新安装本 app。",
|
||||
AppPresentation.message(
|
||||
for: ActivationFailure.invalidLeaf(node: node.id, reason: reason)),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
TextField(
|
||||
"插件参数",
|
||||
text: $pluginOptionsText,
|
||||
prompt: Text("如 mode=websocket;host=example.com(留空即无参数)")
|
||||
)
|
||||
.disabled(!isEditable)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
/// 集外引用(Legacy 导入/订阅带入):原样保留并明确指出当前 app 无法提供该插件。
|
||||
@ViewBuilder
|
||||
private func unknownPluginNotice(program: String, plugin: PluginSectionState) -> some View {
|
||||
Label(
|
||||
"本版本未提供「\(program)」:引用原样保留,激活包含该服务器会被点名拒绝;可改选「无」或受管插件。",
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.foregroundStyle(.orange)
|
||||
if plugin.optionsPresent {
|
||||
LabeledContent("插件参数", value: "已配置(存于钥匙串,原样保留)")
|
||||
}
|
||||
}
|
||||
|
||||
private var qrPopover: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let qrImage {
|
||||
Image(nsImage: qrImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 220, height: 220)
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(width: 220, height: 220)
|
||||
private var shareSection: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
copySsUri()
|
||||
} label: {
|
||||
Label("复制 ss:// 链接", systemImage: "doc.on.doc")
|
||||
}
|
||||
Text("用其他设备的客户端扫描此二维码")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("复制 ss:// 链接") { copySsUri() }
|
||||
Button {
|
||||
generateQR()
|
||||
} label: {
|
||||
Label("二维码…", systemImage: "qrcode")
|
||||
}
|
||||
.disabled(qrPayload() == nil)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
/// 底部操作区:取消恢复已保存值,保存提交草稿。
|
||||
private var detailFooter: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Button("取消") { loadForm() }
|
||||
Button("保存") { save() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
// MARK: - 表单装载与提交
|
||||
|
||||
private func loadForm() {
|
||||
guard let state = formState else { return }
|
||||
address = state.address
|
||||
@@ -245,26 +270,15 @@ struct ServerDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func qrPayload() -> String? {
|
||||
try? workflow.shareURI(for: serverID)
|
||||
}
|
||||
|
||||
private func copySsUri() {
|
||||
do {
|
||||
try clipboard.write(workflow.shareURI(for: serverID))
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func generateQR() {
|
||||
guard let payload = qrPayload() else { return }
|
||||
showQR = true
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let image = (try? QrCodeCodec.generatePNG(for: payload)).flatMap { NSImage(data: $0) }
|
||||
await MainActor.run {
|
||||
qrImage = image
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
.disabled(!isEditable)
|
||||
} else {
|
||||
Text("不可用").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
if let plugin {
|
||||
switch selection {
|
||||
case .none:
|
||||
Text("不使用插件:生成的配置不含 plugin 字段。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
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 let info = plugin.managed.first(where: { $0.program == program }) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("来源项目 \(info.project) · 许可证 \(info.license) · 固定版本 \(info.release)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("随 app 打包,构建期经 Developer ID 重签:\(info.signIdentifier)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if !plugin.provided {
|
||||
Label(
|
||||
"受管插件可执行文件缺失:激活会被点名拒绝;请重新安装本 app。",
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
TextField(
|
||||
"插件参数",
|
||||
text: $optionsText,
|
||||
prompt: Text("选择插件后填写参数(如 mode=websocket;host=…)")
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(!isEditable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 集外引用:原样保留并明确指出当前 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,16 @@ struct SidebarRow: View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: node.isGroup ? "folder" : "server.rack")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(node.name)
|
||||
.foregroundStyle(node.isInvalid ? .secondary : .primary)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(node.name)
|
||||
.foregroundStyle(node.isInvalid ? .secondary : .primary)
|
||||
if node.isGroup {
|
||||
// 分组行尾数量说明(票 #55):原型「手动分组 · N 项」口径。
|
||||
Text("\(node.isManual ? "手动分组" : "订阅分组") · \(node.childCount) 项")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
if node.isInvalid {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
@@ -184,7 +184,7 @@ struct ServersView: View {
|
||||
errors: errors)
|
||||
} else {
|
||||
ServerDetailView(
|
||||
workflow: workflow, serverID: id,
|
||||
workflow: workflow, serverID: id, isActiveTarget: proxyController.activeTargetID == id,
|
||||
errors: errors, clipboard: clipboard)
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user