409 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import SwiftUI
import Combine
/// Bricks
/// JSON schemabinds
@MainActor
public final class BricksEngine: ObservableObject {
/// schema
@Published public var rootSchema: ControlSchema?
///
public let store = BricksStore()
/// 线
public let eventBus = BricksEventBus()
/// RPC
public let rpc = BricksRPC()
///
public let i18n = BricksI18n()
/// Widget: widgettype factory
private var widgetRegistry: [String: WidgetFactory] = [:]
/// Widget: id schema
@Published public var widgetIndex: [String: ControlSchema] = [:]
///
@Published public var activePopup: PopupInfo?
/// urlwidget
@Published public var navigationStack: [String: ControlSchema] = [:]
///
@Published public var isLoading: Bool = false
///
@Published public var errorMessage: String?
public init() {
registerBuiltInWidgets()
setupBuiltinEvents()
}
// MARK: - JSON
/// JSON
public func loadJSON(_ jsonString: String) throws {
let data = jsonString.data(using: .utf8)!
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
loadSchema(schema)
}
/// URLJSON
public func loadFromURL(_ urlString: String) async throws {
isLoading = true
defer { isLoading = false }
let response = try await rpc.get(urlString)
guard response.isSuccess else {
throw BricksRPCError.httpError(response.statusCode, response.text)
}
let schema = try JSONDecoder().decode(ControlSchema.self, from: response.data)
loadSchema(schema)
}
/// schema
public func loadSchema(_ schema: ControlSchema) {
rootSchema = schema
indexWidgets(schema)
setupBinds(schema)
}
// MARK: - Widget
/// widget
public func registerWidget(type: String, factory: @escaping WidgetFactory) {
widgetRegistry[type] = factory
}
/// widget
public func getWidgetFactory(type: String) -> WidgetFactory? {
widgetRegistry[type]
}
// MARK: - Widget
/// widget
private func indexWidgets(_ schema: ControlSchema) {
let id = schema.effectiveId
widgetIndex[id] = schema
if let subwidgets = schema.subwidgets {
for sub in subwidgets {
indexWidgets(sub)
}
}
}
/// IDwidget schema
public func findWidget(id: String) -> ControlSchema? {
// "app.content"
if id.contains(".") {
let parts = id.split(separator: ".")
if let lastPart = parts.last {
return widgetIndex[String(lastPart)]
}
}
// "-"
let cleanId = id.hasPrefix("-") ? String(id.dropFirst()) : id
return widgetIndex[cleanId]
}
// MARK: - Binds
/// binds
private func setupBinds(_ schema: ControlSchema) {
if let binds = schema.binds {
for bind in binds {
setupBind(bind, sourceId: schema.effectiveId)
}
}
if let subwidgets = schema.subwidgets {
for sub in subwidgets {
setupBinds(sub)
}
}
}
/// bind
private func setupBind(_ bind: BindSchema, sourceId: String) {
let wid = bind.wid == "self" ? sourceId : bind.wid
eventBus.on("\(wid).\(bind.event)") { [weak self] data in
guard let self else { return }
await self.handleBind(bind, sourceId: sourceId, eventData: data)
}
}
/// bind
public func handleBind(_ bind: BindSchema, sourceId: String, eventData: EventData = EventData()) async {
guard let actionType = ActionType(rawValue: bind.actiontype) else {
print("[SwiftBricks] Invalid actiontype: \(bind.actiontype)")
return
}
switch actionType {
case .urlwidget:
await handleUrlWidget(bind, sourceId: sourceId, eventData: eventData)
case .urldata:
await handleUrlData(bind, sourceId: sourceId)
case .method:
await handleMethod(bind, sourceId: sourceId, eventData: eventData)
case .event:
await handleEvent(bind, eventData: eventData)
case .newwindow:
if let url = bind.options?.url {
#if os(macOS)
NSWorkspace.shared.open(URL(string: url)!)
#elseif os(iOS)
await UIApplication.shared.open(URL(string: url)!)
#endif
}
case .bricks:
await handleBricksAction(bind)
case .script:
// SwiftBricksBricks
print("[SwiftBricks] actiontype 'script' not supported in SwiftBricks")
case .iframe, .registerfunction:
// Swift
break
}
}
// MARK: -
private func handleUrlWidget(_ bind: BindSchema, sourceId: String, eventData: EventData) async {
guard let url = bind.options?.url else { return }
//
var params: [String: String] = bind.options?.params ?? [:]
//
for (key, value) in eventData.asDictionary {
if let strVal = value as? String {
params[key] = strVal
} else if let intVal = value as? Int {
params[key] = "\(intVal)"
}
}
// ${field}$
let resolvedURL = resolveTemplate(url, params: params)
// toolbar
if let selectedData = store.getSelectedRowData(tabularId: sourceId) {
for (key, value) in selectedData {
if let strVal = value as? String {
params[key] = strVal
}
}
}
do {
isLoading = true
let response = try await rpc.get(resolvedURL, params: params)
isLoading = false
if response.isSuccess {
if let schema = try? JSONDecoder().decode(ControlSchema.self, from: response.data) {
if let target = bind.target {
if target == "PopupWindow" || target == "Popup" {
let opts = bind.popup_options ?? PopupOptions()
activePopup = PopupInfo(schema: schema, options: opts)
} else {
navigationStack[target] = schema
}
}
}
}
} catch {
isLoading = false
errorMessage = error.localizedDescription
}
}
private func handleUrlData(_ bind: BindSchema, sourceId: String) async {
guard let url = bind.options?.url else { return }
let params = bind.options?.params ?? [:]
let resolvedURL = resolveTemplate(url, params: params)
do {
let response = try await rpc.get(resolvedURL, params: params)
if response.isSuccess, let target = bind.target {
store.setValue(id: target, value: response.text)
}
} catch {
errorMessage = error.localizedDescription
}
}
private func handleMethod(_ bind: BindSchema, sourceId: String, eventData: EventData) async {
guard let methodName = bind.method, let target = bind.target else { return }
//
switch methodName {
case "render":
// Tabular
if let schema = findWidget(id: target), let url = schema.options.data_url {
await reloadTabular(id: target, url: url, params: eventData.asDictionary as? [String: String] ?? [:])
}
case "toggle_collapse":
if store.collapsedMenus.contains(target) {
store.collapsedMenus.remove(target)
} else {
store.collapsedMenus.insert(target)
}
case "setValue":
if let value = eventData["value"] as? String {
store.setValue(id: target, value: value)
}
case "reset":
store.clearForm(formId: target)
default:
await eventBus.dispatch("\(target).\(methodName)", data: eventData)
}
}
private func handleEvent(_ bind: BindSchema, eventData: EventData) async {
if let target = bind.target {
await eventBus.dispatch(target, data: eventData)
}
}
private func handleBricksAction(_ bind: BindSchema) async {
// bricks actiontype: widget
print("[SwiftBricks] actiontype 'bricks' - remote widget instantiation not yet supported")
}
// MARK: - Tabular
public func reloadTabular(id: String, url: String, params: [String: String] = [:]) async {
let resolvedURL = resolveTemplate(url, params: params)
do {
let response = try await rpc.get(resolvedURL, params: params)
if response.isSuccess, let dict = response.dictionary, let rows = dict["rows"] as? [[String: Any]] {
store.setTableData(id: id, rows: rows)
}
} catch {
errorMessage = "加载数据失败: \(error.localizedDescription)"
}
}
// MARK: -
public func resolveTemplate(_ template: String, params: [String: String]) -> String {
var result = template
for (key, value) in params {
result = result.replacingOccurrences(of: "${\(key)}$", with: value)
}
return result
}
// MARK: -
/// widget
public func triggerEvent(widgetId: String, event: String, data: EventData = EventData()) async {
await eventBus.dispatch("\(widgetId).\(event)", data: data)
}
// MARK: -
public func submitForm(formId: String, submitURL: String) async -> Bool {
let values = store.getFormValues(formId: formId)
//
if let schema = findWidget(id: formId), let fields = schema.options.fields {
for field in fields {
if let rules = field.rules {
let value = values[field.name] ?? ""
for rule in rules {
if !validateRule(rule, value: value) {
errorMessage = rule.message ?? "验证失败: \(field.effectiveLabel)"
return false
}
}
}
if field.required == true {
let value = values[field.name] ?? ""
if value.isEmpty {
errorMessage = "\(field.effectiveLabel)不能为空"
return false
}
}
}
}
//
do {
let response = try await rpc.postForm(submitURL, fields: values)
if response.isSuccess {
return true
} else {
errorMessage = "提交失败: HTTP \(response.statusCode)"
return false
}
} catch {
errorMessage = error.localizedDescription
return false
}
}
// MARK: -
private func validateRule(_ rule: ValidationRule, value: String) -> Bool {
switch rule.type {
case "required":
return !value.isEmpty
case "minlength":
guard let minLen = Int(rule.value ?? "0") else { return true }
return value.count >= minLen
case "maxlength":
guard let maxLen = Int(rule.value ?? "0") else { return true }
return value.count <= maxLen
case "min":
guard let minVal = Double(rule.value ?? "0"), let val = Double(value) else { return true }
return val >= minVal
case "max":
guard let maxVal = Double(rule.value ?? "0"), let val = Double(value) else { return true }
return val <= maxVal
case "email":
let emailRegex = #"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"#
return value.range(of: emailRegex, options: .regularExpression) != nil
case "number":
return Double(value) != nil
case "pattern":
guard let pattern = rule.value else { return true }
return value.range(of: pattern, options: .regularExpression) != nil
default:
return true
}
}
// MARK: -
private func setupBuiltinEvents() {
//
}
// MARK: - Widget
private func registerBuiltInWidgets() {
// widgetControlRenderer
// widget
}
}
/// Widget
public typealias WidgetFactory = (ControlSchema, BricksEngine) -> AnyView
///
public struct PopupInfo: Identifiable {
public let id = UUID()
public let schema: ControlSchema
public let options: PopupOptions
}