- Core: Schema (Codable), Store, EventBus, RPC, Engine, I18n - Controls: Text, Title1-6, Label, Input, Textarea, Number, Date, Button, Select VBox, HBox, ScrollPanel, DynamicColumn, Tabular, Form, InlineForm, TabView, Menu, PopupWindow, Html, Image, UrlWidget - Renderer: BricksView (main entry), ControlRenderer (recursive dispatch) - 30+ widget types, 9 bind actiontypes - Form validation (required/minlength/maxlength/min/max/email/number/pattern) - i18n with multi-language JSON files - 18 tests covering schema parsing, store, i18n, events
409 lines
14 KiB
Swift
409 lines
14 KiB
Swift
import Foundation
|
||
import SwiftUI
|
||
import Combine
|
||
|
||
/// Bricks引擎 — 核心控制器
|
||
/// 负责加载JSON schema、管理状态、处理binds事件
|
||
@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)
|
||
}
|
||
|
||
/// 从URL加载JSON
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 按ID查找widget 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 = [:]) 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)
|
||
UIApplication.shared.open(URL(string: url)!)
|
||
#endif
|
||
}
|
||
case .bricks:
|
||
await handleBricksAction(bind)
|
||
case .script:
|
||
// SwiftBricks不支持内联脚本(符合Bricks无代码哲学)
|
||
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 = [:]) 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() {
|
||
// 所有widget类型都通过ControlRenderer处理
|
||
// 这里可以注册自定义widget工厂
|
||
}
|
||
}
|
||
|
||
/// Widget工厂函数类型
|
||
public typealias WidgetFactory = (ControlSchema, BricksEngine) -> AnyView
|
||
|
||
/// 弹窗信息
|
||
public struct PopupInfo: Identifiable {
|
||
public let id = UUID()
|
||
public let schema: ControlSchema
|
||
public let options: PopupOptions
|
||
}
|