SwiftBricks: Bricks JSON UI framework native Swift/SwiftUI implementation
- 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
This commit is contained in:
commit
36967370fd
27
Package.swift
Normal file
27
Package.swift
Normal file
@ -0,0 +1,27 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "SwiftBricks",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "SwiftBricks",
|
||||
targets: ["SwiftBricks"]
|
||||
)
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SwiftBricks",
|
||||
dependencies: []
|
||||
),
|
||||
.testTarget(
|
||||
name: "SwiftBricksTests",
|
||||
dependencies: ["SwiftBricks"]
|
||||
)
|
||||
]
|
||||
)
|
||||
177
README.md
Normal file
177
README.md
Normal file
@ -0,0 +1,177 @@
|
||||
# SwiftBricks
|
||||
|
||||
Bricks JSON-based UI框架的 Swift/SwiftUI 原生实现。
|
||||
|
||||
支持 macOS 14+、iOS 17+、iPadOS 17+。
|
||||
|
||||
## 核心思想
|
||||
|
||||
与Bricks一致:**JSON驱动、声明式、无代码**。
|
||||
UI从JSON schema渲染,交互通过binds声明,无需手写SwiftUI视图代码。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
SwiftBricks/
|
||||
├── Core/
|
||||
│ ├── Schema.swift — JSON schema Codable模型
|
||||
│ ├── Store.swift — 数据存储(widget值/表单/表格/弹窗状态)
|
||||
│ ├── EventBus.swift — 事件发布/订阅
|
||||
│ ├── RPC.swift — 网络层(GET/POST/Form/认证)
|
||||
│ ├── Engine.swift — 核心引擎(加载/索引/binds/验证/提交)
|
||||
│ └── I18n.swift — 国际化(多语言JSON文件)
|
||||
├── Controls/
|
||||
│ ├── TextControl.swift — Text, Title1-6, Label
|
||||
│ ├── InputControl.swift — Input, Textarea, Number, Date
|
||||
│ ├── ButtonControl.swift — Button, Select
|
||||
│ ├── VBoxControl.swift — VBox, HBox, ScrollPanel, DynamicColumn
|
||||
│ ├── TabularControl.swift — Tabular/DataViewer(表格)
|
||||
│ ├── FormControl.swift — Form, InlineForm(表单+验证)
|
||||
│ └── TabViewControl.swift — TabView, Menu, PopupWindow, Html, Image, UrlWidget
|
||||
├── Renderer/
|
||||
│ ├── BricksView.swift — 主入口视图(JSON→渲染+弹窗层+错误处理)
|
||||
│ └── ControlRenderer.swift — 递归widget分发渲染器
|
||||
└── SwiftBricks.swift — 公开API + 类型别名
|
||||
```
|
||||
|
||||
## 支持的Widget类型(30+)
|
||||
|
||||
| 类别 | Widget |
|
||||
|------|--------|
|
||||
| 文本 | Text, Title1-6, Label |
|
||||
| 输入 | Input, Textarea, UiStr, UiNumber, UiDate, UiText, UiCode, Select |
|
||||
| 按钮 | Button |
|
||||
| 布局 | VBox, HBox, VScrollPanel, HScrollPanel, Filler, DynamicColumn, Card |
|
||||
| 数据 | Tabular, DataViewer, Form, InlineForm |
|
||||
| 导航 | TabView, Menu |
|
||||
| 弹窗 | PopupWindow |
|
||||
| 其他 | Html, Image, urlwidget |
|
||||
|
||||
## 支持的Bind ActionType(9种)
|
||||
|
||||
`newwindow` `iframe` `urlwidget` `urldata` `bricks` `registerfunction` `method` `script` `event`
|
||||
|
||||
## 用法
|
||||
|
||||
### 基本使用
|
||||
|
||||
```swift
|
||||
import SwiftBricks
|
||||
|
||||
// 1. 创建引擎
|
||||
let engine = BricksEngine()
|
||||
engine.rpc.baseURL = "https://your-api.com"
|
||||
engine.rpc.authToken = "your-token"
|
||||
|
||||
// 2. 渲染JSON
|
||||
BricksView(json: """
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": { "css": "card", "padding": "16px" },
|
||||
"subwidgets": [
|
||||
{ "widgettype": "Title2", "options": { "text": "Hello" } },
|
||||
{ "widgettype": "Button", "options": { "label": "Click Me" },
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "event",
|
||||
"target": "say_hello"}] }
|
||||
]
|
||||
}
|
||||
""", engine: engine)
|
||||
```
|
||||
|
||||
### 从远程URL加载
|
||||
|
||||
```swift
|
||||
BricksView(url: "/api/page.ui", engine: engine)
|
||||
```
|
||||
|
||||
### 国际化
|
||||
|
||||
```swift
|
||||
// 加载语言文件: {basePath}/i18n/en/i18n.json
|
||||
engine.i18n.basePath = "https://your-cdn.com"
|
||||
await engine.i18n.loadLocale("en")
|
||||
|
||||
// JSON中 i18n 属性控制是否翻译
|
||||
{ "widgettype": "Text", "options": { "text": "你好", "i18n": true } }
|
||||
```
|
||||
|
||||
### 表单验证
|
||||
|
||||
```json
|
||||
{
|
||||
"widgettype": "Form",
|
||||
"options": {
|
||||
"submit_url": "/api/submit.dspy",
|
||||
"fields": [
|
||||
{ "name": "email", "label": "邮箱", "uitype": "str", "required": true,
|
||||
"rules": [
|
||||
{ "type": "required", "message": "邮箱必填" },
|
||||
{ "type": "email", "message": "格式不正确" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 表格(Tabular)
|
||||
|
||||
```json
|
||||
{
|
||||
"widgettype": "Tabular",
|
||||
"options": {
|
||||
"data_url": "/api/list.dspy",
|
||||
"page_rows": 20,
|
||||
"row_options": {
|
||||
"fields": [
|
||||
{ "name": "id", "label": "ID", "uitype": "str" },
|
||||
{ "name": "name", "label": "名称", "cwidth": 12, "uitype": "str" }
|
||||
],
|
||||
"browserfields": {
|
||||
"exclouded": ["id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 侧边栏菜单
|
||||
|
||||
```json
|
||||
{
|
||||
"widgettype": "Menu",
|
||||
"options": {
|
||||
"items": [
|
||||
{ "name": "home", "label": "首页", "icon": "🏠",
|
||||
"binds": [{"wid":"self","event":"click","actiontype":"urlwidget",
|
||||
"target":"content","options":{"url":"/home.ui"}}] },
|
||||
{ "name": "settings", "label": "设置", "icon": "⚙️",
|
||||
"submenu": [
|
||||
{ "name": "profile", "label": "个人信息" },
|
||||
{ "name": "security", "label": "安全" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 与Bricks Web版的差异
|
||||
|
||||
| 特性 | Bricks (Web) | SwiftBricks |
|
||||
|------|-------------|-------------|
|
||||
| 渲染 | DOM | SwiftUI |
|
||||
| 状态 | widget实例属性 | ObservableObject Store |
|
||||
| 事件 | DOM事件+dispatch | EventBus发布/订阅 |
|
||||
| 网络 | bricks.tget/fetch | BricksRPC (URLSession) |
|
||||
| 脚本 | actiontype:script可用 | 不支持(遵循无代码哲学) |
|
||||
| 平台 | 浏览器 | macOS/iOS/iPadOS |
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
swift build
|
||||
swift test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
152
Sources/SwiftBricks/Controls/ButtonControl.swift
Normal file
152
Sources/SwiftBricks/Controls/ButtonControl.swift
Normal file
@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Button Control
|
||||
|
||||
struct ButtonControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var isLoading: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: handleAction) {
|
||||
HStack(spacing: 6) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
if let icon = schema.options.icon {
|
||||
Text(icon)
|
||||
}
|
||||
Text(displayLabel)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(buttonStyle)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
|
||||
private var displayLabel: String {
|
||||
let raw = schema.options.label ?? ""
|
||||
return (schema.options.i18n != false) ? engine.i18n.t(raw) : raw
|
||||
}
|
||||
|
||||
private var buttonStyle: BricksButtonStyle {
|
||||
let css = schema.options.css ?? ""
|
||||
if css.contains("primary") {
|
||||
return BricksButtonStyle(variant: .primary)
|
||||
} else if css.contains("danger") {
|
||||
return BricksButtonStyle(variant: .danger)
|
||||
} else if css.contains("text") {
|
||||
return BricksButtonStyle(variant: .text)
|
||||
}
|
||||
return BricksButtonStyle(variant: .default)
|
||||
}
|
||||
|
||||
private func handleAction() {
|
||||
// 如果有binds,触发click事件
|
||||
Task {
|
||||
await engine.triggerEvent(widgetId: schema.effectiveId, event: "click")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Button Style
|
||||
|
||||
struct BricksButtonStyle: ButtonStyle {
|
||||
enum Variant { case `default`, primary, danger, text }
|
||||
let variant: Variant
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundColor(textColor)
|
||||
.background(backgroundColor(configuration.isPressed))
|
||||
.cornerRadius(6)
|
||||
.opacity(configuration.isPressed ? 0.8 : 1.0)
|
||||
}
|
||||
|
||||
private var textColor: Color {
|
||||
switch variant {
|
||||
case .primary: return .white
|
||||
case .danger: return .white
|
||||
case .text: return .accentColor
|
||||
case .default: return .primary
|
||||
}
|
||||
}
|
||||
|
||||
private func backgroundColor(_ pressed: Bool) -> Color {
|
||||
switch variant {
|
||||
case .primary: return pressed ? .blue.opacity(0.7) : .blue
|
||||
case .danger: return pressed ? .red.opacity(0.7) : .red
|
||||
case .text: return .clear
|
||||
case .default: return pressed ? Color.secondary.opacity(0.2) : Color.secondary.opacity(0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Select Control
|
||||
|
||||
struct SelectControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var selectedValue: String = ""
|
||||
@State private var items: [CodeItem] = []
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let label = schema.options.label ?? schema.options.title {
|
||||
Text(engine.i18n.t(label))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Picker("", selection: $selectedValue) {
|
||||
ForEach(items, id: \.value) { item in
|
||||
Text(engine.i18n.t(item.text)).tag(item.value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.onAppear {
|
||||
loadItems()
|
||||
selectedValue = schema.options.value ?? ""
|
||||
}
|
||||
.onChange(of: selectedValue) { _, newValue in
|
||||
engine.store.setValue(id: schema.effectiveId, value: newValue)
|
||||
Task {
|
||||
await engine.triggerEvent(widgetId: schema.effectiveId, event: "change",
|
||||
data: EventData(["value": newValue]))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadRemoteData()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadItems() {
|
||||
if let data = schema.options.data ?? schema.options.codes {
|
||||
items = data
|
||||
} else {
|
||||
items = []
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRemoteData() async {
|
||||
guard let dataurl = schema.options.dataurl else { return }
|
||||
do {
|
||||
let response = try await engine.rpc.get(dataurl)
|
||||
if response.isSuccess, let dict = response.dictionary {
|
||||
if let rows = dict["rows"] as? [[String: Any]] {
|
||||
let vf = schema.options.valueField ?? "value"
|
||||
let tf = schema.options.textField ?? "text"
|
||||
items = rows.compactMap { row in
|
||||
guard let v = row[vf] as? String, let t = row[tf] as? String else { return nil }
|
||||
return CodeItem(value: v, text: t)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("[SwiftBricks] Select data load failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
282
Sources/SwiftBricks/Controls/FormControl.swift
Normal file
282
Sources/SwiftBricks/Controls/FormControl.swift
Normal file
@ -0,0 +1,282 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Form Control
|
||||
|
||||
struct FormControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var fieldValues: [String: String] = [:]
|
||||
@State private var errors: [String: String] = [:]
|
||||
@State private var isSubmitting: Bool = false
|
||||
@State private var submitMessage: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// 标题
|
||||
if let title = schema.options.title {
|
||||
Text(engine.i18n.t(title))
|
||||
.font(.title3.bold())
|
||||
}
|
||||
|
||||
// 字段
|
||||
ForEach(fields, id: \.name) { field in
|
||||
formField(field)
|
||||
}
|
||||
|
||||
// 错误信息
|
||||
if !submitMessage.isEmpty {
|
||||
Text(submitMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
// 按钮栏
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("重置") { resetForm() }
|
||||
.buttonStyle(.bordered)
|
||||
Button("提交") { submitForm() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isSubmitting)
|
||||
if isSubmitting {
|
||||
ProgressView().scaleEffect(0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.onAppear { initializeValues() }
|
||||
}
|
||||
|
||||
// MARK: - 表单字段渲染
|
||||
|
||||
@ViewBuilder
|
||||
private func formField(_ field: FieldSchema) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Text(field.effectiveLabel)
|
||||
.font(.subheadline)
|
||||
if field.required == true {
|
||||
Text("*").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
switch field.effectiveUitype {
|
||||
case "text":
|
||||
TextEditor(text: Binding(
|
||||
get: { fieldValues[field.name] ?? "" },
|
||||
set: { fieldValues[field.name] = $0 }
|
||||
))
|
||||
.frame(minHeight: 80)
|
||||
.border(Color.secondary.opacity(0.3))
|
||||
|
||||
case "code", "select":
|
||||
Picker("", selection: Binding(
|
||||
get: { fieldValues[field.name] ?? "" },
|
||||
set: { fieldValues[field.name] = $0 }
|
||||
)) {
|
||||
Text("--请选择--").tag("")
|
||||
if let codes = field.codes ?? field.data {
|
||||
ForEach(codes, id: \.value) { item in
|
||||
Text(item.text).tag(item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
case "number":
|
||||
TextField("", text: Binding(
|
||||
get: { fieldValues[field.name] ?? "" },
|
||||
set: { fieldValues[field.name] = $0 }
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
#if os(iOS)
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
|
||||
case "date":
|
||||
DatePicker("", selection: Binding(
|
||||
get: { parseDate(fieldValues[field.name] ?? "") },
|
||||
set: { fieldValues[field.name] = formatDate($0) }
|
||||
), displayedComponents: [.date])
|
||||
.labelsHidden()
|
||||
|
||||
default: // str
|
||||
TextField(field.placeholder ?? field.effectiveLabel, text: Binding(
|
||||
get: { fieldValues[field.name] ?? "" },
|
||||
set: { fieldValues[field.name] = $0 }
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
if let error = errors[field.name] {
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 字段列表
|
||||
|
||||
private var fields: [FieldSchema] {
|
||||
schema.options.fields ?? []
|
||||
}
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
private func initializeValues() {
|
||||
for field in fields {
|
||||
fieldValues[field.name] = field.value ?? field.defaultvalue ?? ""
|
||||
}
|
||||
engine.store.setFormValues(formId: schema.effectiveId, data: fieldValues)
|
||||
}
|
||||
|
||||
// MARK: - 验证
|
||||
|
||||
private func validate() -> Bool {
|
||||
errors = [:]
|
||||
for field in fields {
|
||||
let value = fieldValues[field.name] ?? ""
|
||||
|
||||
if field.required == true && value.isEmpty {
|
||||
errors[field.name] = "\(field.effectiveLabel)不能为空"
|
||||
continue
|
||||
}
|
||||
|
||||
if let rules = field.rules {
|
||||
for rule in rules {
|
||||
if !validateRule(rule, value: value) {
|
||||
errors[field.name] = rule.message ?? "验证失败"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.isEmpty
|
||||
}
|
||||
|
||||
private func validateRule(_ rule: ValidationRule, value: String) -> Bool {
|
||||
switch rule.type {
|
||||
case "required": return !value.isEmpty
|
||||
case "minlength": return value.count >= (Int(rule.value ?? "0") ?? 0)
|
||||
case "maxlength": return value.count <= (Int(rule.value ?? "999999") ?? 999999)
|
||||
case "min": return (Double(value) ?? -999999) >= (Double(rule.value ?? "0") ?? 0)
|
||||
case "max": return (Double(value) ?? 999999) <= (Double(rule.value ?? "999999") ?? 999999)
|
||||
case "email":
|
||||
return value.range(of: #"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"#, options: .regularExpression) != nil
|
||||
case "number": return Double(value) != nil
|
||||
case "pattern": return value.range(of: rule.value ?? "", options: .regularExpression) != nil
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 提交
|
||||
|
||||
private func submitForm() {
|
||||
guard validate() else { return }
|
||||
guard let url = schema.options.submit_url else { return }
|
||||
|
||||
isSubmitting = true
|
||||
submitMessage = ""
|
||||
engine.store.setFormValues(formId: schema.effectiveId, data: fieldValues)
|
||||
|
||||
Task {
|
||||
do {
|
||||
let response = try await engine.rpc.postForm(url, fields: fieldValues)
|
||||
isSubmitting = false
|
||||
if response.isSuccess {
|
||||
submitMessage = "提交成功"
|
||||
await engine.triggerEvent(widgetId: schema.effectiveId, event: "submit",
|
||||
data: EventData(fieldValues as [String: Any]))
|
||||
} else {
|
||||
submitMessage = "提交失败: HTTP \(response.statusCode)"
|
||||
}
|
||||
} catch {
|
||||
isSubmitting = false
|
||||
submitMessage = "提交失败: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetForm() {
|
||||
fieldValues = [:]
|
||||
errors = [:]
|
||||
submitMessage = ""
|
||||
engine.store.clearForm(formId: schema.effectiveId)
|
||||
}
|
||||
|
||||
// MARK: - 日期工具
|
||||
|
||||
private func parseDate(_ str: String) -> Date {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f.date(from: str) ?? Date()
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - InlineForm Control
|
||||
|
||||
struct InlineFormControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var fieldValues: [String: String] = [:]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(fields, id: \.name) { field in
|
||||
inlineField(field)
|
||||
}
|
||||
|
||||
Button(submitLabel) {
|
||||
engine.store.setFormValues(formId: schema.effectiveId, data: fieldValues)
|
||||
Task {
|
||||
await engine.triggerEvent(widgetId: schema.effectiveId, event: "submit",
|
||||
data: EventData(fieldValues as [String: Any]))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inlineField(_ field: FieldSchema) -> some View {
|
||||
switch field.effectiveUitype {
|
||||
case "code", "select":
|
||||
Picker("", selection: Binding(
|
||||
get: { fieldValues[field.name] ?? "" },
|
||||
set: { fieldValues[field.name] = $0 }
|
||||
)) {
|
||||
Text(field.placeholder ?? "全部").tag("")
|
||||
if let codes = field.codes ?? field.data {
|
||||
ForEach(codes, id: \.value) { item in
|
||||
Text(item.text).tag(item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.fixedSize()
|
||||
default:
|
||||
TextField(field.placeholder ?? field.name, text: Binding(
|
||||
get: { fieldValues[field.name] ?? "" },
|
||||
set: { fieldValues[field.name] = $0 }
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
}
|
||||
}
|
||||
|
||||
private var fields: [FieldSchema] { schema.options.fields ?? [] }
|
||||
private var submitLabel: String {
|
||||
let label = schema.options.submit_label ?? "提交"
|
||||
return (schema.options.i18n != false) ? engine.i18n.t(label) : label
|
||||
}
|
||||
}
|
||||
174
Sources/SwiftBricks/Controls/InputControl.swift
Normal file
174
Sources/SwiftBricks/Controls/InputControl.swift
Normal file
@ -0,0 +1,174 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Input Control
|
||||
|
||||
struct InputControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var text: String = ""
|
||||
@State private var isFocused: Bool = false
|
||||
@State private var errorMessage: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if showLabel {
|
||||
Text(effectiveLabel)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
TextField(placeholder, text: $text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: text) { _, newValue in
|
||||
let formId = findParentFormId()
|
||||
if let formId, let name = schema.options.name {
|
||||
engine.store.setFormField(formId: formId, field: name, value: newValue)
|
||||
}
|
||||
engine.store.setValue(id: schema.effectiveId, value: newValue)
|
||||
if isFocused { clearError() }
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(errorMessage.isEmpty ? Color.clear : Color.red, lineWidth: 1)
|
||||
)
|
||||
|
||||
if !errorMessage.isEmpty {
|
||||
Text(errorMessage)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
text = schema.options.value ?? schema.options.defaultvalue ?? ""
|
||||
let formId = findParentFormId()
|
||||
if let formId, let name = schema.options.name {
|
||||
text = engine.store.getFormField(formId: formId, field: name)
|
||||
if text.isEmpty {
|
||||
text = schema.options.value ?? schema.options.defaultvalue ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var effectiveLabel: String {
|
||||
schema.options.label ?? schema.options.title ?? schema.options.name ?? ""
|
||||
}
|
||||
|
||||
private var placeholder: String {
|
||||
let raw = schema.options.placeholder ?? ""
|
||||
return raw.isEmpty ? effectiveLabel : engine.i18n.t(raw)
|
||||
}
|
||||
|
||||
private var showLabel: Bool { true }
|
||||
|
||||
private func findParentFormId() -> String? {
|
||||
// 简化实现:使用schema id作为form标识
|
||||
schema.options.name
|
||||
}
|
||||
|
||||
private func clearError() {
|
||||
errorMessage = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Textarea Control
|
||||
|
||||
struct TextareaControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var text: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let label = schema.options.label ?? schema.options.title {
|
||||
Text(engine.i18n.t(label))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
TextEditor(text: $text)
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
#else
|
||||
TextEditor(text: $text)
|
||||
.frame(minHeight: 100)
|
||||
.border(Color.secondary.opacity(0.3))
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
text = schema.options.value ?? ""
|
||||
}
|
||||
.onChange(of: text) { _, newValue in
|
||||
engine.store.setValue(id: schema.effectiveId, value: newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Number Control
|
||||
|
||||
struct NumberControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var value: Double = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let label = schema.options.label ?? schema.options.title {
|
||||
Text(engine.i18n.t(label))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("−") { value -= step }
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Text(String(format: format, value))
|
||||
.frame(minWidth: 60)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button("+") { value += step }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
value = Double(schema.options.value ?? "0") ?? 0
|
||||
}
|
||||
.onChange(of: value) { _, newValue in
|
||||
engine.store.setValue(id: schema.effectiveId, value: "\(newValue)")
|
||||
}
|
||||
}
|
||||
|
||||
private var step: Double { 1 }
|
||||
private var format: String { "%g" }
|
||||
}
|
||||
|
||||
// MARK: - Date Control
|
||||
|
||||
struct DateControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var date: Date = Date()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let label = schema.options.label ?? schema.options.title {
|
||||
Text(engine.i18n.t(label))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
DatePicker("", selection: $date, displayedComponents: [.date])
|
||||
.labelsHidden()
|
||||
}
|
||||
.onChange(of: date) { _, newValue in
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
engine.store.setValue(id: schema.effectiveId, value: formatter.string(from: newValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
302
Sources/SwiftBricks/Controls/TabViewControl.swift
Normal file
302
Sources/SwiftBricks/Controls/TabViewControl.swift
Normal file
@ -0,0 +1,302 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - TabView Control
|
||||
|
||||
struct TabViewControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var selectedIndex: Int = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Tab按钮栏
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(tabs.enumerated()), id: \.offset) { idx, tab in
|
||||
Button(action: { selectedIndex = idx }) {
|
||||
HStack(spacing: 4) {
|
||||
if let icon = tab.icon { Text(icon) }
|
||||
Text(engine.i18n.t(tab.label))
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.foregroundColor(selectedIndex == idx ? .accentColor : .secondary)
|
||||
.background(selectedIndex == idx ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(selectedIndex == idx ? Color.accentColor : Color.clear)
|
||||
.frame(height: 2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
|
||||
// 内容区
|
||||
if selectedIndex < tabs.count {
|
||||
let tab = tabs[selectedIndex]
|
||||
if let content = tab.content {
|
||||
ControlRenderer(schema: content, engine: engine)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedIndex = schema.options.activeTab ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private var tabs: [TabItem] {
|
||||
schema.options.tabs ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu Control (侧边栏菜单)
|
||||
|
||||
struct MenuControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var expandedItems: Set<String> = []
|
||||
@State private var selectedItem: String = ""
|
||||
|
||||
var body: some View {
|
||||
let isCollapsed = engine.store.collapsedMenus.contains(schema.effectiveId)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let items = schema.options.items {
|
||||
ForEach(items, id: \.name) { item in
|
||||
menuItemView(item, depth: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.frame(width: isCollapsed ? 50 : nil)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.animation(.easeInOut(duration: 0.2), value: isCollapsed)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func menuItemView(_ item: MenuItemSchema, depth: Int) -> some View {
|
||||
let hasChildren = !(item.submenu?.isEmpty ?? true)
|
||||
let isExpanded = expandedItems.contains(item.name)
|
||||
let isSelected = selectedItem == item.name
|
||||
|
||||
Button(action: { handleMenuClick(item, hasChildren: hasChildren) }) {
|
||||
HStack(spacing: 8) {
|
||||
if let icon = item.icon {
|
||||
Text(icon)
|
||||
.frame(width: 20)
|
||||
}
|
||||
Text(engine.i18n.t(item.label))
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if hasChildren {
|
||||
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12 + CGFloat(depth * 16))
|
||||
.padding(.vertical, 8)
|
||||
.foregroundColor(isSelected ? .accentColor : .primary)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// 子菜单
|
||||
if hasChildren && isExpanded {
|
||||
if let subitems = item.submenu {
|
||||
ForEach(subitems, id: \.name) { sub in
|
||||
menuItemView(sub, depth: depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMenuClick(_ item: MenuItemSchema, hasChildren: Bool) {
|
||||
if hasChildren {
|
||||
if expandedItems.contains(item.name) {
|
||||
expandedItems.remove(item.name)
|
||||
} else {
|
||||
expandedItems.insert(item.name)
|
||||
}
|
||||
} else {
|
||||
selectedItem = item.name
|
||||
// 触发binds
|
||||
Task {
|
||||
if let binds = item.binds {
|
||||
for bind in binds {
|
||||
if bind.event == "click" {
|
||||
await engine.handleBind(bind, sourceId: schema.effectiveId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PopupWindow Control
|
||||
|
||||
struct PopupWindowControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// 标题栏
|
||||
HStack {
|
||||
Text(engine.i18n.t(schema.options.title ?? ""))
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button(action: close) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption.bold())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
|
||||
// 内容
|
||||
if let subwidgets = schema.subwidgets {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SubWidgetsView(subwidgets: subwidgets, engine: engine)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 8)
|
||||
}
|
||||
|
||||
private func close() {
|
||||
engine.activePopup = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTML Control
|
||||
|
||||
struct HtmlControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
let html = schema.options.html ?? ""
|
||||
#if os(iOS)
|
||||
Text(html) // iOS不支持AttributedString from HTML直接渲染
|
||||
.font(.body)
|
||||
#else
|
||||
if let data = html.data(using: .utf8),
|
||||
let attrStr = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) {
|
||||
Text(AttributedString(attrStr))
|
||||
} else {
|
||||
Text(html)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Control
|
||||
|
||||
struct ImageControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var image: Image? = nil
|
||||
@State private var isLoading: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
image.resizable().aspectRatio(contentMode: .fit)
|
||||
} else if isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.1))
|
||||
.overlay(Text(schema.options.alt ?? "🖼️"))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadImage()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadImage() async {
|
||||
guard let src = schema.options.src, let url = URL(string: src) else { return }
|
||||
isLoading = true
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
#if os(iOS)
|
||||
if let uiImage = UIImage(data: data) {
|
||||
image = Image(uiImage: uiImage)
|
||||
}
|
||||
#else
|
||||
if let nsImage = NSImage(data: data) {
|
||||
image = Image(nsImage: nsImage)
|
||||
}
|
||||
#endif
|
||||
} catch {
|
||||
print("[SwiftBricks] Image load failed: \(error)")
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UrlWidget Control (加载远程widget内容)
|
||||
|
||||
struct UrlWidgetControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var loadedSchema: ControlSchema? = nil
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var errorMessage: String? = nil
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = errorMessage {
|
||||
VStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.orange)
|
||||
Text(error).font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
} else if let schema = loadedSchema {
|
||||
ControlRenderer(schema: schema, engine: engine)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadContent()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContent() async {
|
||||
guard let url = schema.options.url else {
|
||||
errorMessage = "缺少url"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await engine.rpc.get(url)
|
||||
if response.isSuccess {
|
||||
loadedSchema = try JSONDecoder().decode(ControlSchema.self, from: response.data)
|
||||
} else {
|
||||
errorMessage = "HTTP \(response.statusCode)"
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
192
Sources/SwiftBricks/Controls/TabularControl.swift
Normal file
192
Sources/SwiftBricks/Controls/TabularControl.swift
Normal file
@ -0,0 +1,192 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Tabular Control (表格/数据列表)
|
||||
|
||||
struct TabularControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var rows: [[String: Any]] = []
|
||||
@State private var selectedRow: Int? = nil
|
||||
@State private var currentPage: Int = 0
|
||||
@State private var isLoading: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 工具栏
|
||||
if let tools = schema.options.toolbar?.tools, !tools.isEmpty {
|
||||
toolbarView(tools: tools)
|
||||
}
|
||||
|
||||
// 表头
|
||||
headerRow
|
||||
|
||||
// 数据行
|
||||
if isLoading {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if rows.isEmpty {
|
||||
Text("暂无数据")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(pagedRows.enumerated()), id: \.offset) { idx, row in
|
||||
dataRow(row, index: idx + currentPage * pageRows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
if totalPages > 1 {
|
||||
paginationBar
|
||||
}
|
||||
}
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
.task {
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 表头
|
||||
|
||||
private var headerRow: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(visibleFields, id: \.name) { field in
|
||||
Text(field.label ?? field.title ?? field.name)
|
||||
.font(.caption.bold())
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
}
|
||||
|
||||
// MARK: - 数据行
|
||||
|
||||
private func dataRow(_ row: [String: Any], index: Int) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(visibleFields, id: \.name) { field in
|
||||
cellView(row: row, field: field)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
.background(selectedRow == index ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||
.border(Color.secondary.opacity(0.1), width: 0.5)
|
||||
.onTapGesture {
|
||||
selectedRow = index
|
||||
engine.store.selectRow(tabularId: schema.effectiveId, index: index)
|
||||
Task {
|
||||
await engine.triggerEvent(widgetId: schema.effectiveId, event: "select",
|
||||
data: EventData(["row": row, "index": index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cellView(row: [String: Any], field: FieldSchema) -> some View {
|
||||
let value = row[field.name] as? String ?? "\(row[field.name] ?? "")"
|
||||
|
||||
// 检查alters(代码字段映射)
|
||||
if let alter = schema.options.row_options?.browserfields?.alters?[field.name],
|
||||
let codes = alter.codes ?? alter.data,
|
||||
let match = codes.first(where: { $0.value == value }) {
|
||||
return Text(engine.i18n.t(match.text))
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
return Text(value)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
// MARK: - 工具栏
|
||||
|
||||
private func toolbarView(tools: [ToolbarTool]) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(tools, id: \.name) { tool in
|
||||
Button(engine.i18n.t(tool.label ?? tool.name)) {
|
||||
handleToolbarAction(tool)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(tool.selected_row == true && selectedRow == nil)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
}
|
||||
|
||||
private func handleToolbarAction(_ tool: ToolbarTool) {
|
||||
if tool.selected_row == true && selectedRow == nil {
|
||||
engine.errorMessage = "请先选择一行"
|
||||
return
|
||||
}
|
||||
Task {
|
||||
var data = EventData()
|
||||
if selectedRow != nil, let rowData = engine.store.getSelectedRowData(tabularId: schema.effectiveId) {
|
||||
for (k, v) in rowData {
|
||||
if let s = v as? String { data[k] = s }
|
||||
}
|
||||
}
|
||||
await engine.triggerEvent(widgetId: schema.effectiveId, event: tool.name, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分页
|
||||
|
||||
private var paginationBar: some View {
|
||||
HStack {
|
||||
Button("上一页") { currentPage -= 1 }
|
||||
.disabled(currentPage <= 0)
|
||||
Text("\(currentPage + 1) / \(totalPages)")
|
||||
.font(.caption)
|
||||
Button("下一页") { currentPage += 1 }
|
||||
.disabled(currentPage >= totalPages - 1)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
// MARK: - 数据加载
|
||||
|
||||
private func loadData() async {
|
||||
guard let url = schema.options.data_url else { return }
|
||||
isLoading = true
|
||||
let resolvedURL = engine.resolveTemplate(url, params: [:])
|
||||
do {
|
||||
let response = try await engine.rpc.get(resolvedURL)
|
||||
if response.isSuccess, let dict = response.dictionary, let r = dict["rows"] as? [[String: Any]] {
|
||||
rows = r
|
||||
engine.store.setTableData(id: schema.effectiveId, rows: r)
|
||||
}
|
||||
} catch {
|
||||
engine.errorMessage = "加载失败: \(error.localizedDescription)"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// MARK: - 计算属性
|
||||
|
||||
private var visibleFields: [FieldSchema] {
|
||||
guard let fields = schema.options.row_options?.fields else { return [] }
|
||||
let excluded = schema.options.row_options?.browserfields?.exclouded ?? []
|
||||
return fields.filter { !excluded.contains($0.name) }
|
||||
}
|
||||
|
||||
private var pageRows: Int { schema.options.page_rows ?? 20 }
|
||||
private var pagedRows: [[String: Any]] {
|
||||
let start = currentPage * pageRows
|
||||
let end = min(start + pageRows, rows.count)
|
||||
guard start < rows.count else { return [] }
|
||||
return Array(rows[start..<end])
|
||||
}
|
||||
private var totalPages: Int {
|
||||
max(1, Int(ceil(Double(rows.count) / Double(pageRows))))
|
||||
}
|
||||
}
|
||||
142
Sources/SwiftBricks/Controls/TextControl.swift
Normal file
142
Sources/SwiftBricks/Controls/TextControl.swift
Normal file
@ -0,0 +1,142 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Text Control
|
||||
|
||||
struct TextControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
let text = displayText
|
||||
Text(text)
|
||||
.font(resolveFont())
|
||||
.foregroundColor(resolveColor())
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
let raw = schema.options.otext ?? schema.options.text ?? ""
|
||||
if schema.options.i18n != false {
|
||||
return engine.i18n.t(raw)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private func resolveFont() -> Font {
|
||||
if let fs = schema.options.cfontsize {
|
||||
return .system(size: CGFloat(fs * 14))
|
||||
}
|
||||
if let fs = schema.options.fontsize, let size = Double(fs.replacingOccurrences(of: "px", with: "")) {
|
||||
return .system(size: CGFloat(size))
|
||||
}
|
||||
return .body
|
||||
}
|
||||
|
||||
private func resolveColor() -> Color {
|
||||
if let color = schema.options.color {
|
||||
return Color.fromCSS(color)
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Title Control
|
||||
|
||||
struct TitleControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
let level: Int
|
||||
|
||||
var body: some View {
|
||||
let text = schema.options.text ?? schema.options.otext ?? ""
|
||||
let translated = (schema.options.i18n != false) ? engine.i18n.t(text) : text
|
||||
|
||||
Group {
|
||||
switch level {
|
||||
case 1: Text(translated).font(.largeTitle).bold()
|
||||
case 2: Text(translated).font(.title).bold()
|
||||
case 3: Text(translated).font(.title2).bold()
|
||||
case 4: Text(translated).font(.title3).bold()
|
||||
case 5: Text(translated).font(.headline)
|
||||
case 6: Text(translated).font(.subheadline).bold()
|
||||
default: Text(translated).font(.body)
|
||||
}
|
||||
}
|
||||
.foregroundColor(resolveColor())
|
||||
}
|
||||
|
||||
private func resolveColor() -> Color {
|
||||
if let color = schema.options.color {
|
||||
return Color.fromCSS(color)
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Label Control
|
||||
|
||||
struct LabelControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
let text = schema.options.text ?? schema.options.otext ?? ""
|
||||
let translated = (schema.options.i18n != false) ? engine.i18n.t(text) : text
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if let icon = schema.options.icon {
|
||||
Text(icon)
|
||||
}
|
||||
Text(translated)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color Extension
|
||||
|
||||
extension Color {
|
||||
static func fromCSS(_ css: String) -> Color {
|
||||
let cleaned = css.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if cleaned.hasPrefix("#") {
|
||||
return Color(hex: cleaned)
|
||||
}
|
||||
|
||||
if cleaned.hasPrefix("var(") {
|
||||
// CSS变量 → 使用默认色
|
||||
return .primary
|
||||
}
|
||||
|
||||
switch cleaned.lowercased() {
|
||||
case "red": return .red
|
||||
case "blue": return .blue
|
||||
case "green": return .green
|
||||
case "yellow": return .yellow
|
||||
case "orange": return .orange
|
||||
case "purple": return .purple
|
||||
case "pink": return .pink
|
||||
case "white": return .white
|
||||
case "black": return .black
|
||||
case "gray", "grey": return .gray
|
||||
case "transparent": return .clear
|
||||
default: return .primary
|
||||
}
|
||||
}
|
||||
|
||||
init(hex: String) {
|
||||
var hexStr = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if hexStr.hasPrefix("#") { hexStr.removeFirst() }
|
||||
|
||||
var rgb: UInt64 = 0
|
||||
Scanner(string: hexStr).scanHexInt64(&rgb)
|
||||
|
||||
let r = Double((rgb >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgb >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgb & 0xFF) / 255.0
|
||||
|
||||
self.init(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
167
Sources/SwiftBricks/Controls/VBoxControl.swift
Normal file
167
Sources/SwiftBricks/Controls/VBoxControl.swift
Normal file
@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - VBox Control
|
||||
|
||||
struct VBoxControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
if let subwidgets = schema.subwidgets {
|
||||
SubWidgetsView(subwidgets: subwidgets, engine: engine)
|
||||
}
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(backgroundColor)
|
||||
.cornerRadius(cornerRadius)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(borderColor, lineWidth: borderWidth)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task {
|
||||
await engine.triggerEvent(widgetId: schema.effectiveId, event: "click")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var spacing: CGFloat { CGFloat(schema.options.spacing ?? 8) }
|
||||
private var padding: CGFloat { parsePadding(schema.options.padding) }
|
||||
private var cornerRadius: CGFloat {
|
||||
(schema.options.css?.contains("card") ?? false) ? 8 : 0
|
||||
}
|
||||
private var borderWidth: CGFloat {
|
||||
(schema.options.css?.contains("card") ?? false) ? 1 : 0
|
||||
}
|
||||
private var borderColor: Color {
|
||||
(schema.options.css?.contains("card") ?? false) ? Color.secondary.opacity(0.2) : .clear
|
||||
}
|
||||
private var backgroundColor: Color {
|
||||
if let bg = schema.options.bgcolor {
|
||||
return Color.fromCSS(bg)
|
||||
}
|
||||
if schema.options.css?.contains("card") ?? false {
|
||||
return Color.secondary.opacity(0.05)
|
||||
}
|
||||
return .clear
|
||||
}
|
||||
|
||||
private func parsePadding(_ padding: String?) -> CGFloat {
|
||||
guard let p = padding else { return 0 }
|
||||
if let val = Double(p.replacingOccurrences(of: "px", with: "")) {
|
||||
return CGFloat(val)
|
||||
}
|
||||
return 8
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HBox Control
|
||||
|
||||
struct HBoxControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: verticalAlignment, spacing: spacing) {
|
||||
if let subwidgets = schema.subwidgets {
|
||||
SubWidgetsView(subwidgets: subwidgets, engine: engine)
|
||||
}
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(backgroundColor)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private var spacing: CGFloat { CGFloat(schema.options.spacing ?? 8) }
|
||||
private var padding: CGFloat { parsePadding(schema.options.padding) }
|
||||
private var verticalAlignment: VerticalAlignment {
|
||||
switch schema.options.alignItems {
|
||||
case "center": return .center
|
||||
case "flex-end", "end": return .bottom
|
||||
default: return .top
|
||||
}
|
||||
}
|
||||
private var backgroundColor: Color {
|
||||
if let bg = schema.options.bgcolor { return Color.fromCSS(bg) }
|
||||
return .clear
|
||||
}
|
||||
|
||||
private func parsePadding(_ padding: String?) -> CGFloat {
|
||||
guard let p = padding else { return 0 }
|
||||
if let val = Double(p.replacingOccurrences(of: "px", with: "")) { return CGFloat(val) }
|
||||
return 8
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ScrollPanel Control
|
||||
|
||||
struct ScrollPanelControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
if schema.widgettype == "HScrollPanel" {
|
||||
ScrollView(.horizontal, showsIndicators: true) {
|
||||
HStack(alignment: .top, spacing: spacing) {
|
||||
if let subwidgets = schema.subwidgets {
|
||||
SubWidgetsView(subwidgets: subwidgets, engine: engine)
|
||||
}
|
||||
}
|
||||
.padding(padding)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: true) {
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
if let subwidgets = schema.subwidgets {
|
||||
SubWidgetsView(subwidgets: subwidgets, engine: engine)
|
||||
}
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var spacing: CGFloat { CGFloat(schema.options.spacing ?? 8) }
|
||||
private var padding: CGFloat {
|
||||
if let p = schema.options.padding, let val = Double(p.replacingOccurrences(of: "px", with: "")) {
|
||||
return CGFloat(val)
|
||||
}
|
||||
return 8
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DynamicColumn Control
|
||||
|
||||
struct DynamicColumnControl: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 200), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: resolvedColumns, spacing: gap) {
|
||||
if let subwidgets = schema.subwidgets {
|
||||
ForEach(Array(subwidgets.enumerated()), id: \.offset) { _, sub in
|
||||
ControlRenderer(schema: sub, engine: engine)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private var gap: CGFloat { CGFloat(schema.options.col_cgap ?? 1) * 12 }
|
||||
private var resolvedColumns: [GridItem] {
|
||||
let minWidth = CGFloat(schema.options.col_cwidth ?? 20) * 8
|
||||
return [GridItem(.adaptive(minimum: minWidth), spacing: gap)]
|
||||
}
|
||||
}
|
||||
408
Sources/SwiftBricks/Core/Engine.swift
Normal file
408
Sources/SwiftBricks/Core/Engine.swift
Normal file
@ -0,0 +1,408 @@
|
||||
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
|
||||
}
|
||||
113
Sources/SwiftBricks/Core/EventBus.swift
Normal file
113
Sources/SwiftBricks/Core/EventBus.swift
Normal file
@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// 事件总线 — Bricks事件发布/订阅系统
|
||||
/// 对应Bricks的dispatch/subscribe机制
|
||||
@MainActor
|
||||
public final class BricksEventBus: ObservableObject {
|
||||
|
||||
/// 事件定义: eventName → [callback]
|
||||
private var handlers: [String: [(EventData) async -> Void]] = [:]
|
||||
|
||||
/// 已发布的事件历史(调试用)
|
||||
@Published public var eventLog: [(name: String, data: EventData, timestamp: Date)] = []
|
||||
|
||||
private let maxLogSize = 100
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - 订阅
|
||||
|
||||
/// 订阅事件
|
||||
public func on(_ eventName: String, handler: @escaping (EventData) async -> Void) {
|
||||
if handlers[eventName] == nil {
|
||||
handlers[eventName] = []
|
||||
}
|
||||
handlers[eventName]?.append(handler)
|
||||
}
|
||||
|
||||
/// 取消订阅
|
||||
public func off(_ eventName: String) {
|
||||
handlers.removeValue(forKey: eventName)
|
||||
}
|
||||
|
||||
// MARK: - 发布
|
||||
|
||||
/// 派发事件
|
||||
public func dispatch(_ eventName: String, data: EventData = [:]) async {
|
||||
// 记录日志
|
||||
eventLog.append((name: eventName, data: data, timestamp: Date()))
|
||||
if eventLog.count > maxLogSize {
|
||||
eventLog.removeFirst()
|
||||
}
|
||||
|
||||
// 调用所有handler
|
||||
if let callbacks = handlers[eventName] {
|
||||
for callback in callbacks {
|
||||
await callback(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步派发(用于UI事件)
|
||||
public func dispatchSync(_ eventName: String, data: EventData = [:]) {
|
||||
Task { @MainActor in
|
||||
await dispatch(eventName, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 工具
|
||||
|
||||
public func hasSubscribers(_ eventName: String) -> Bool {
|
||||
!(handlers[eventName]?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
public func subscriberCount(_ eventName: String) -> Int {
|
||||
handlers[eventName]?.count ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 事件数据 — 类型安全的事件载荷
|
||||
public struct EventData: Sendable {
|
||||
private var storage: [String: Any]
|
||||
|
||||
public init(_ dict: [String: Any] = [:]) {
|
||||
self.storage = dict
|
||||
}
|
||||
|
||||
public subscript(key: String) -> Any? {
|
||||
get { storage[key] }
|
||||
set { storage[key] = newValue }
|
||||
}
|
||||
|
||||
public var asDictionary: [String: Any] { storage }
|
||||
|
||||
/// 合并两个EventData
|
||||
public func merged(with other: EventData) -> EventData {
|
||||
var result = EventData()
|
||||
for (k, v) in storage { result[k] = v }
|
||||
for (k, v) in other.storage { result[k] = v }
|
||||
return result
|
||||
}
|
||||
|
||||
/// 从字典创建
|
||||
public static func from(_ dict: [String: Any]) -> EventData {
|
||||
EventData(dict)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 常用事件名
|
||||
|
||||
public enum BricksEvent {
|
||||
public static let click = "click"
|
||||
public static let submit = "submit"
|
||||
public static let change = "change"
|
||||
public static let select = "select"
|
||||
public static let load = "load"
|
||||
public static let render = "render"
|
||||
public static let close = "close"
|
||||
public static let open = "open"
|
||||
public static let toggle = "toggle"
|
||||
public static let refresh = "refresh"
|
||||
public static let navigate = "navigate"
|
||||
}
|
||||
87
Sources/SwiftBricks/Core/I18n.swift
Normal file
87
Sources/SwiftBricks/Core/I18n.swift
Normal file
@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
/// 国际化 — 对应Bricks的i18n系统
|
||||
/// 从JSON文件加载翻译,支持语言切换
|
||||
@MainActor
|
||||
public final class BricksI18n: ObservableObject {
|
||||
|
||||
/// 当前语言(2位缩写: zh, en, ja, ko)
|
||||
@Published public var locale: String = "zh"
|
||||
|
||||
/// 翻译字典: key → translated text
|
||||
@Published public var messages: [String: String] = [:]
|
||||
|
||||
/// 是否启用i18n
|
||||
public var enabled: Bool = true
|
||||
|
||||
/// i18n文件基础路径
|
||||
public var basePath: String = ""
|
||||
|
||||
public init(locale: String = "zh") {
|
||||
self.locale = locale
|
||||
}
|
||||
|
||||
// MARK: - 加载翻译
|
||||
|
||||
/// 从字典加载
|
||||
public func loadMessages(_ dict: [String: String]) {
|
||||
messages = dict
|
||||
}
|
||||
|
||||
/// 从JSON字符串加载
|
||||
public func loadJSON(_ jsonString: String) {
|
||||
guard let data = jsonString.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return }
|
||||
messages = dict
|
||||
}
|
||||
|
||||
/// 从URL加载翻译文件
|
||||
public func loadFromURL(_ url: String) async {
|
||||
guard let urlObj = URL(string: url) else { return }
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: urlObj)
|
||||
if let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] {
|
||||
messages = dict
|
||||
}
|
||||
} catch {
|
||||
print("[SwiftBricks i18n] Failed to load: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载指定语言的i18n文件
|
||||
/// 路径格式: {basePath}/i18n/{locale}/i18n.json
|
||||
public func loadLocale(_ lang: String) async {
|
||||
locale = lang
|
||||
guard enabled else { return }
|
||||
|
||||
let url = "\(basePath)/i18n/\(lang)/i18n.json"
|
||||
await loadFromURL(url)
|
||||
}
|
||||
|
||||
// MARK: - 翻译
|
||||
|
||||
/// 翻译文本(i18n_getmsgs风格)
|
||||
public func t(_ key: String) -> String {
|
||||
guard enabled else { return key }
|
||||
return messages[key] ?? key
|
||||
}
|
||||
|
||||
/// 翻译,如果未找到则返回原文
|
||||
public func translate(_ text: String) -> String {
|
||||
t(text)
|
||||
}
|
||||
|
||||
// MARK: - 支持的语言
|
||||
|
||||
public static let supportedLocales = ["zh", "en", "ja", "ko"]
|
||||
|
||||
public static func localeName(_ code: String) -> String {
|
||||
switch code {
|
||||
case "zh": return "中文"
|
||||
case "en": return "English"
|
||||
case "ja": return "日本語"
|
||||
case "ko": return "한국어"
|
||||
default: return code
|
||||
}
|
||||
}
|
||||
}
|
||||
182
Sources/SwiftBricks/Core/RPC.swift
Normal file
182
Sources/SwiftBricks/Core/RPC.swift
Normal file
@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
|
||||
/// RPC网络层 — 对应Bricks的bricks.tget()和fetch
|
||||
/// 处理API调用、数据获取、文件上传
|
||||
@MainActor
|
||||
public final class BricksRPC: ObservableObject {
|
||||
|
||||
public struct Response: Sendable {
|
||||
public let data: Data
|
||||
public let statusCode: Int
|
||||
public let headers: [String: String]
|
||||
|
||||
public var json: Any? {
|
||||
try? JSONSerialization.jsonObject(with: data)
|
||||
}
|
||||
|
||||
public var dictionary: [String: Any]? {
|
||||
json as? [String: Any]
|
||||
}
|
||||
|
||||
public var array: [[String: Any]]? {
|
||||
if let dict = dictionary, let rows = dict["rows"] as? [[String: Any]] {
|
||||
return rows
|
||||
}
|
||||
return json as? [[String: Any]]
|
||||
}
|
||||
|
||||
public var text: String {
|
||||
String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
public var isSuccess: Bool {
|
||||
(200...299).contains(statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// 基础URL(可选,用于相对路径解析)
|
||||
public var baseURL: String = ""
|
||||
|
||||
/// 默认请求头
|
||||
public var defaultHeaders: [String: String] = [
|
||||
"Content-Type": "application/json"
|
||||
]
|
||||
|
||||
/// 认证token
|
||||
public var authToken: String?
|
||||
|
||||
/// 请求拦截器
|
||||
public var requestInterceptor: ((URLRequest) -> URLRequest)?
|
||||
|
||||
/// 活跃请求数
|
||||
@Published public var activeRequests: Int = 0
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: - GET
|
||||
|
||||
public func get(_ url: String, params: [String: String]? = nil) async throws -> Response {
|
||||
var urlString = resolveURL(url)
|
||||
if let params, !params.isEmpty {
|
||||
let query = params.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
|
||||
.joined(separator: "&")
|
||||
urlString += (urlString.contains("?") ? "&" : "?") + query
|
||||
}
|
||||
return try await request(urlString, method: "GET")
|
||||
}
|
||||
|
||||
// MARK: - POST
|
||||
|
||||
public func post(_ url: String, body: [String: Any]? = nil) async throws -> Response {
|
||||
var data: Data?
|
||||
if let body {
|
||||
data = try JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
return try await request(resolveURL(url), method: "POST", body: data)
|
||||
}
|
||||
|
||||
public func postForm(_ url: String, fields: [String: String]) async throws -> Response {
|
||||
let formString = fields.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
|
||||
.joined(separator: "&")
|
||||
let data = formString.data(using: .utf8)
|
||||
return try await request(resolveURL(url), method: "POST", body: data,
|
||||
headers: ["Content-Type": "application/x-www-form-urlencoded"])
|
||||
}
|
||||
|
||||
// MARK: - 通用请求
|
||||
|
||||
public func request(_ url: String, method: String = "GET", body: Data? = nil,
|
||||
headers: [String: String]? = nil) async throws -> Response {
|
||||
activeRequests += 1
|
||||
defer { activeRequests -= 1 }
|
||||
|
||||
guard let urlObj = URL(string: url) else {
|
||||
throw BricksRPCError.invalidURL(url)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: urlObj)
|
||||
request.httpMethod = method
|
||||
request.httpBody = body
|
||||
request.timeoutInterval = 30
|
||||
|
||||
// 设置默认头
|
||||
for (key, value) in defaultHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// 自定义头
|
||||
if let headers {
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
|
||||
// 认证
|
||||
if let token = authToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
// 拦截器
|
||||
if let interceptor = requestInterceptor {
|
||||
request = interceptor(request)
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw BricksRPCError.invalidResponse
|
||||
}
|
||||
|
||||
let responseHeaders = Dictionary(
|
||||
uniqueKeysWithValues: httpResponse.allHeaderFields.compactMap { key, value in
|
||||
guard let keyStr = key as? String, let valStr = value as? String else { return nil }
|
||||
return (keyStr, valStr)
|
||||
}
|
||||
)
|
||||
|
||||
return Response(
|
||||
data: data,
|
||||
statusCode: httpResponse.statusCode,
|
||||
headers: responseHeaders
|
||||
)
|
||||
} catch {
|
||||
throw BricksRPCError.networkError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL解析
|
||||
|
||||
private func resolveURL(_ url: String) -> String {
|
||||
if url.hasPrefix("http://") || url.hasPrefix("https://") {
|
||||
return url
|
||||
}
|
||||
if baseURL.isEmpty {
|
||||
return url
|
||||
}
|
||||
let base = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
|
||||
let path = url.hasPrefix("/") ? url : "/\(url)"
|
||||
return base + path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 错误类型
|
||||
|
||||
public enum BricksRPCError: Error, LocalizedError {
|
||||
case invalidURL(String)
|
||||
case invalidResponse
|
||||
case networkError(Error)
|
||||
case httpError(Int, String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL(let url): return "无效URL: \(url)"
|
||||
case .invalidResponse: return "无效响应"
|
||||
case .networkError(let error): return "网络错误: \(error.localizedDescription)"
|
||||
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
327
Sources/SwiftBricks/Core/Schema.swift
Normal file
327
Sources/SwiftBricks/Core/Schema.swift
Normal file
@ -0,0 +1,327 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - JSON Schema Models (Bricks JSON → Codable)
|
||||
|
||||
/// 根控件定义,对应Bricks JSON顶层结构
|
||||
public struct ControlSchema: Codable, Identifiable, Sendable {
|
||||
public var id: String?
|
||||
public let widgettype: String
|
||||
public var options: ControlOptions
|
||||
public var binds: [BindSchema]?
|
||||
public var subwidgets: [ControlSchema]?
|
||||
|
||||
/// 运行时生成的唯一ID(当JSON未提供id时)
|
||||
public var runtimeId: String = UUID().uuidString.prefix(8).lowercased()
|
||||
|
||||
public var effectiveId: String {
|
||||
id ?? runtimeId
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String? = nil,
|
||||
widgettype: String,
|
||||
options: ControlOptions = .init(),
|
||||
binds: [BindSchema]? = nil,
|
||||
subwidgets: [ControlSchema]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.widgettype = widgettype
|
||||
self.options = options
|
||||
self.binds = binds
|
||||
self.subwidgets = subwidgets
|
||||
}
|
||||
}
|
||||
|
||||
/// 控件选项 — 使用字典+Codable混合方式
|
||||
public struct ControlOptions: Codable, Sendable {
|
||||
// 通用属性
|
||||
public var width: String?
|
||||
public var height: String?
|
||||
public var cwidth: Double?
|
||||
public var cheight: Double?
|
||||
public var bgcolor: String?
|
||||
public var color: String?
|
||||
public var css: String?
|
||||
public var padding: String?
|
||||
public var cursor: String?
|
||||
public var spacing: Double?
|
||||
public var alignItems: String?
|
||||
|
||||
// Text/Title
|
||||
public var text: String?
|
||||
public var otext: String?
|
||||
public var i18n: Bool?
|
||||
public var level: Int?
|
||||
|
||||
// Button
|
||||
public var label: String?
|
||||
public var actiontype: String?
|
||||
public var url: String?
|
||||
public var icon: String?
|
||||
|
||||
// Input/Textarea
|
||||
public var name: String?
|
||||
public var placeholder: String?
|
||||
public var value: String?
|
||||
public var defaultvalue: String?
|
||||
public var required: Bool?
|
||||
public var uitype: String?
|
||||
public var title: String?
|
||||
public var rules: [ValidationRule]?
|
||||
public var codes: [CodeItem]?
|
||||
|
||||
// Select/Code
|
||||
public var data: [CodeItem]?
|
||||
public var dataurl: String?
|
||||
public var valueField: String?
|
||||
public var textField: String?
|
||||
|
||||
// Form
|
||||
public var fields: [FieldSchema]?
|
||||
public var submit_url: String?
|
||||
public var submit_label: String?
|
||||
public var show_label: Bool?
|
||||
|
||||
// Tabular
|
||||
public var data_url: String?
|
||||
public var data_method: String?
|
||||
public var page_rows: Int?
|
||||
public var row_options: RowOptions?
|
||||
public var toolbar: ToolbarSchema?
|
||||
|
||||
// TabView
|
||||
public var tabs: [TabItem]?
|
||||
public var activeTab: Int?
|
||||
|
||||
// DynamicColumn
|
||||
public var col_cwidth: Double?
|
||||
public var col_cgap: Double?
|
||||
public var col_width: Double?
|
||||
|
||||
// PopupWindow
|
||||
public var popup_options: PopupOptions?
|
||||
public var auto_open: Bool?
|
||||
public var archor: String?
|
||||
public var dismiss_events: [String]?
|
||||
|
||||
// Menu
|
||||
public var items: [MenuItemSchema]?
|
||||
public var target: String?
|
||||
|
||||
// HTML
|
||||
public var html: String?
|
||||
|
||||
// Image
|
||||
public var src: String?
|
||||
public var alt: String?
|
||||
|
||||
// Misc
|
||||
public var fontsize: String?
|
||||
public var cfontsize: Double?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// 绑定定义 — event→action映射
|
||||
public struct BindSchema: Codable, Sendable {
|
||||
public let wid: String // 事件源widget id
|
||||
public let event: String // 事件名
|
||||
public let actiontype: String // 动作类型(9种)
|
||||
public var target: String? // 目标widget id
|
||||
public var options: BindOptions?
|
||||
public var mode: String? // replace/append
|
||||
public var method: String? // method名(actiontype=method时)
|
||||
public var script: String? // 内联脚本(actiontype=script时)
|
||||
public var popup_options: PopupOptions?
|
||||
public var datawidget: String? // 数据源widget
|
||||
public var event_params: String?
|
||||
}
|
||||
|
||||
/// 绑定选项
|
||||
public struct BindOptions: Codable, Sendable {
|
||||
public var url: String?
|
||||
public var params: [String: String]?
|
||||
|
||||
public init(url: String? = nil, params: [String: String]? = nil) {
|
||||
self.url = url
|
||||
self.params = params
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹出窗口选项
|
||||
public struct PopupOptions: Codable, Sendable {
|
||||
public var title: String?
|
||||
public var cwidth: Double?
|
||||
public var cheight: Double?
|
||||
public var width: String?
|
||||
public var height: String?
|
||||
public var archor: String?
|
||||
public var eventpos: Bool?
|
||||
public var dismiss_events: [String]?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// 表单字段定义
|
||||
public struct FieldSchema: Codable, Sendable {
|
||||
public var name: String
|
||||
public var label: String?
|
||||
public var title: String?
|
||||
public var type: String?
|
||||
public var uitype: String?
|
||||
public var required: Bool?
|
||||
public var cwidth: Double?
|
||||
public var placeholder: String?
|
||||
public var value: String?
|
||||
public var defaultvalue: String?
|
||||
public var codes: [CodeItem]?
|
||||
public var data: [CodeItem]?
|
||||
public var dataurl: String?
|
||||
public var valueField: String?
|
||||
public var textField: String?
|
||||
public var rules: [ValidationRule]?
|
||||
|
||||
public var effectiveLabel: String { label ?? title ?? name }
|
||||
public var effectiveUitype: String { uitype ?? type ?? "str" }
|
||||
}
|
||||
|
||||
/// 验证规则
|
||||
public struct ValidationRule: Codable, Sendable {
|
||||
public let type: String
|
||||
public var value: String?
|
||||
public var message: String?
|
||||
}
|
||||
|
||||
/// 代码项(下拉选项)
|
||||
public struct CodeItem: Codable, Sendable, Identifiable {
|
||||
public var id: String { value }
|
||||
public let value: String
|
||||
public let text: String
|
||||
}
|
||||
|
||||
/// Tabular行选项
|
||||
public struct RowOptions: Codable, Sendable {
|
||||
public var fields: [FieldSchema]?
|
||||
public var browserfields: BrowserFields?
|
||||
}
|
||||
|
||||
/// 浏览器字段配置
|
||||
public struct BrowserFields: Codable, Sendable {
|
||||
public var exclouded: [String]?
|
||||
public var cwidths: [String: Double]?
|
||||
public var alters: [String: FieldSchema]?
|
||||
}
|
||||
|
||||
/// 工具栏定义
|
||||
public struct ToolbarSchema: Codable, Sendable {
|
||||
public var tools: [ToolbarTool]?
|
||||
}
|
||||
|
||||
/// 工具栏按钮
|
||||
public struct ToolbarTool: Codable, Sendable {
|
||||
public var name: String
|
||||
public var label: String?
|
||||
public var icon: String?
|
||||
public var selected_row: Bool?
|
||||
public var css: String?
|
||||
}
|
||||
|
||||
/// TabView项
|
||||
public struct TabItem: Codable, Sendable {
|
||||
public var label: String
|
||||
public var icon: String?
|
||||
public var content: ControlSchema?
|
||||
}
|
||||
|
||||
/// 菜单项
|
||||
public struct MenuItemSchema: Codable, Sendable {
|
||||
public var name: String
|
||||
public var label: String
|
||||
public var icon: String?
|
||||
public var url: String?
|
||||
public var submenu: [MenuItemSchema]?
|
||||
public var binds: [BindSchema]?
|
||||
}
|
||||
|
||||
// MARK: - 有效WidgetType枚举
|
||||
|
||||
public enum WidgetType: String, CaseIterable, Sendable {
|
||||
// 文本
|
||||
case text = "Text"
|
||||
case title1 = "Title1", title2 = "Title2", title3 = "Title3"
|
||||
case title4 = "Title4", title5 = "Title5", title6 = "Title6"
|
||||
case label = "Label"
|
||||
|
||||
// 输入
|
||||
case input = "Input"
|
||||
case textarea = "Textarea"
|
||||
case select = "Select"
|
||||
case uiCode = "UiCode"
|
||||
case uiStr = "UiStr"
|
||||
case uiNumber = "UiNumber"
|
||||
case uiDate = "UiDate"
|
||||
case uiText = "UiText"
|
||||
|
||||
// 按钮
|
||||
case button = "Button"
|
||||
|
||||
// 布局
|
||||
case vbox = "VBox"
|
||||
case hbox = "HBox"
|
||||
case vScrollPanel = "VScrollPanel"
|
||||
case hScrollPanel = "HScrollPanel"
|
||||
case filler = "Filler"
|
||||
case dynamicColumn = "DynamicColumn"
|
||||
|
||||
// 数据
|
||||
case tabular = "Tabular"
|
||||
case dataViewer = "DataViewer"
|
||||
case form = "Form"
|
||||
case inlineForm = "InlineForm"
|
||||
|
||||
// 导航
|
||||
case tabView = "TabView"
|
||||
case menu = "Menu"
|
||||
|
||||
// 弹窗
|
||||
case popupWindow = "PopupWindow"
|
||||
|
||||
// 其他
|
||||
case card = "Card"
|
||||
case html = "Html"
|
||||
case image = "Image"
|
||||
case urlwidget = "urlwidget"
|
||||
|
||||
// 特殊
|
||||
case popup = "Popup"
|
||||
|
||||
public var isLayout: Bool {
|
||||
[.vbox, .hbox, .vScrollPanel, .hScrollPanel, .dynamicColumn, .card].contains(self)
|
||||
}
|
||||
|
||||
public var isText: Bool {
|
||||
[.text, .title1, .title2, .title3, .title4, .title5, .title6, .label].contains(self)
|
||||
}
|
||||
|
||||
public var isInput: Bool {
|
||||
[.input, .textarea, .select, .uiCode, .uiStr, .uiNumber, .uiDate, .uiText].contains(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 有效ActionType枚举
|
||||
|
||||
public enum ActionType: String, CaseIterable, Sendable {
|
||||
case newwindow // 新窗口打开
|
||||
case iframe // iframe加载
|
||||
case urlwidget // 加载UI内容到目标(最常用)
|
||||
case urldata // 获取数据更新目标
|
||||
case bricks // 从配置实例化widget
|
||||
case registerfunction // 注册函数
|
||||
case method // 调用目标widget方法
|
||||
case script // 执行内联脚本
|
||||
case event // 派发自定义事件
|
||||
|
||||
public static var validNames: [String] {
|
||||
allCases.map { $0.rawValue }
|
||||
}
|
||||
}
|
||||
117
Sources/SwiftBricks/Core/Store.swift
Normal file
117
Sources/SwiftBricks/Core/Store.swift
Normal file
@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// 数据存储 — Bricks binds系统的数据层
|
||||
/// 存储widget的值,提供双向绑定
|
||||
@MainActor
|
||||
public final class BricksStore: ObservableObject {
|
||||
/// widget值存储: widgetId → value
|
||||
@Published public var values: [String: Any] = [:]
|
||||
|
||||
/// 表单字段值: formId → {fieldName: value}
|
||||
@Published public var formValues: [String: [String: String]] = [:]
|
||||
|
||||
/// Tabular数据: tabularId → rows
|
||||
@Published public var tableData: [String: [[String: Any]]] = [:]
|
||||
|
||||
/// Tabular选中行: tabularId → selectedIndex
|
||||
@Published public var selectedRows: [String: Int] = [:]
|
||||
|
||||
/// 弹出窗口状态
|
||||
@Published public var popups: [String: PopupState] = [:]
|
||||
|
||||
/// 侧边栏折叠状态
|
||||
@Published public var collapsedMenus: Set<String> = []
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - 通用值存取
|
||||
|
||||
public func getValue(id: String) -> Any? {
|
||||
values[id]
|
||||
}
|
||||
|
||||
public func setValue(id: String, value: Any?) {
|
||||
if let value {
|
||||
values[id] = value
|
||||
} else {
|
||||
values.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 表单字段
|
||||
|
||||
public func getFormField(formId: String, field: String) -> String {
|
||||
formValues[formId]?[field] ?? ""
|
||||
}
|
||||
|
||||
public func setFormField(formId: String, field: String, value: String) {
|
||||
if formValues[formId] == nil {
|
||||
formValues[formId] = [:]
|
||||
}
|
||||
formValues[formId]?[field] = value
|
||||
}
|
||||
|
||||
public func getFormValues(formId: String) -> [String: String] {
|
||||
formValues[formId] ?? [:]
|
||||
}
|
||||
|
||||
public func setFormValues(formId: String, data: [String: String]) {
|
||||
formValues[formId] = data
|
||||
}
|
||||
|
||||
public func clearForm(formId: String) {
|
||||
formValues[formId]?.keys.forEach { key in
|
||||
formValues[formId]?[key] = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 表格数据
|
||||
|
||||
public func setTableData(id: String, rows: [[String: Any]]) {
|
||||
tableData[id] = rows
|
||||
}
|
||||
|
||||
public func getTableData(id: String) -> [[String: Any]] {
|
||||
tableData[id] ?? []
|
||||
}
|
||||
|
||||
public func selectRow(tabularId: String, index: Int) {
|
||||
selectedRows[tabularId] = index
|
||||
}
|
||||
|
||||
public func getSelectedRow(tabularId: String) -> Int? {
|
||||
selectedRows[tabularId]
|
||||
}
|
||||
|
||||
public func getSelectedRowData(tabularId: String) -> [String: Any]? {
|
||||
guard let idx = selectedRows[tabularId],
|
||||
let rows = tableData[tabularId],
|
||||
idx >= 0, idx < rows.count else { return nil }
|
||||
return rows[idx]
|
||||
}
|
||||
|
||||
// MARK: - 弹窗
|
||||
|
||||
public func openPopup(id: String, schema: ControlSchema, options: PopupOptions) {
|
||||
popups[id] = PopupState(schema: schema, options: options, isOpen: true)
|
||||
}
|
||||
|
||||
public func closePopup(id: String) {
|
||||
popups.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
public func isPopupOpen(id: String) -> Bool {
|
||||
popups[id]?.isOpen ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹窗状态
|
||||
public struct PopupState {
|
||||
public let schema: ControlSchema
|
||||
public let options: PopupOptions
|
||||
public var isOpen: Bool
|
||||
}
|
||||
158
Sources/SwiftBricks/Renderer/BricksView.swift
Normal file
158
Sources/SwiftBricks/Renderer/BricksView.swift
Normal file
@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
|
||||
/// BricksView — SwiftBricks的主入口视图
|
||||
/// 从JSON schema渲染完整的UI页面
|
||||
///
|
||||
/// 用法:
|
||||
/// ```swift
|
||||
/// BricksView(json: jsonString, engine: engine)
|
||||
/// BricksView(url: "/api/page.dspy", engine: engine)
|
||||
/// ```
|
||||
public struct BricksView: View {
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var isLoaded: Bool = false
|
||||
@State private var loadError: String?
|
||||
|
||||
private var jsonString: String?
|
||||
private var loadURL: String?
|
||||
|
||||
/// 从JSON字符串创建
|
||||
public init(json: String, engine: BricksEngine) {
|
||||
self.jsonString = json
|
||||
self.loadURL = nil
|
||||
self._engine = ObservedObject(wrappedValue: engine)
|
||||
}
|
||||
|
||||
/// 从URL加载
|
||||
public init(url: String, engine: BricksEngine) {
|
||||
self.jsonString = nil
|
||||
self.loadURL = url
|
||||
self._engine = ObservedObject(wrappedValue: engine)
|
||||
}
|
||||
|
||||
/// 从已加载的schema创建
|
||||
public init(schema: ControlSchema, engine: BricksEngine) {
|
||||
self.jsonString = nil
|
||||
self.loadURL = nil
|
||||
self._engine = ObservedObject(wrappedValue: engine)
|
||||
// 立即加载
|
||||
engine.loadSchema(schema)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
if let schema = engine.rootSchema {
|
||||
// 主内容
|
||||
ControlRenderer(schema: schema, engine: engine)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// 弹窗层
|
||||
if let popup = engine.activePopup {
|
||||
popupOverlay(popup)
|
||||
}
|
||||
|
||||
// 加载指示器
|
||||
if engine.isLoading {
|
||||
Color.black.opacity(0.01)
|
||||
.overlay(ProgressView())
|
||||
}
|
||||
} else if let error = loadError {
|
||||
errorView(error)
|
||||
} else {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await performLoad()
|
||||
}
|
||||
.alert("错误", isPresented: .init(
|
||||
get: { engine.errorMessage != nil },
|
||||
set: { if !$0 { engine.errorMessage = nil } }
|
||||
)) {
|
||||
Button("确定") { engine.errorMessage = nil }
|
||||
} message: {
|
||||
Text(engine.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 加载
|
||||
|
||||
private func performLoad() async {
|
||||
if let json = jsonString {
|
||||
do {
|
||||
try engine.loadJSON(json)
|
||||
isLoaded = true
|
||||
} catch {
|
||||
loadError = "JSON解析失败: \(error.localizedDescription)"
|
||||
}
|
||||
} else if let url = loadURL {
|
||||
do {
|
||||
try await engine.loadFromURL(url)
|
||||
isLoaded = true
|
||||
} catch {
|
||||
loadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 弹窗层
|
||||
|
||||
private func popupOverlay(_ popup: PopupInfo) -> some View {
|
||||
ZStack {
|
||||
// 遮罩
|
||||
Color.black.opacity(0.3)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
engine.activePopup = nil
|
||||
}
|
||||
|
||||
// 弹窗内容
|
||||
PopupWindowControl(schema: popup.schema, engine: engine)
|
||||
.frame(
|
||||
maxWidth: popupWidth(popup.options),
|
||||
maxHeight: popupHeight(popup.options)
|
||||
)
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
private func popupWidth(_ opts: PopupOptions) -> CGFloat? {
|
||||
if let cw = opts.cwidth { return CGFloat(cw * 8) }
|
||||
if let w = opts.width, let px = Double(w.replacingOccurrences(of: "px", with: "")) {
|
||||
return CGFloat(px)
|
||||
}
|
||||
return 400
|
||||
}
|
||||
|
||||
private func popupHeight(_ opts: PopupOptions) -> CGFloat? {
|
||||
if let ch = opts.cheight { return CGFloat(ch * 16) }
|
||||
if let h = opts.height {
|
||||
if h.hasSuffix("%") { return nil } // 百分比用maxHeight
|
||||
if let px = Double(h.replacingOccurrences(of: "px", with: "")) {
|
||||
return CGFloat(px)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 错误视图
|
||||
|
||||
private func errorView(_ error: String) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.orange)
|
||||
Text("加载失败")
|
||||
.font(.headline)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("重试") {
|
||||
Task { await performLoad() }
|
||||
}
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
160
Sources/SwiftBricks/Renderer/ControlRenderer.swift
Normal file
160
Sources/SwiftBricks/Renderer/ControlRenderer.swift
Normal file
@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 控件渲染器 — 递归将JSON schema渲染为SwiftUI视图
|
||||
/// 这是SwiftBricks的核心,对应Bricks的widget实例化链
|
||||
public struct ControlRenderer: View {
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
public init(schema: ControlSchema, engine: BricksEngine) {
|
||||
self.schema = schema
|
||||
self.engine = engine
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
renderWidget(schema)
|
||||
.frame(
|
||||
maxWidth: resolveWidth(schema.options),
|
||||
maxHeight: resolveHeight(schema.options),
|
||||
alignment: resolveAlignment(schema.options)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Widget分发
|
||||
|
||||
@ViewBuilder
|
||||
func renderWidget(_ schema: ControlSchema) -> some View {
|
||||
let type = schema.widgettype
|
||||
|
||||
switch type {
|
||||
// 文本类
|
||||
case "Text":
|
||||
TextControl(schema: schema, engine: engine)
|
||||
case "Title1":
|
||||
TitleControl(schema: schema, engine: engine, level: 1)
|
||||
case "Title2":
|
||||
TitleControl(schema: schema, engine: engine, level: 2)
|
||||
case "Title3":
|
||||
TitleControl(schema: schema, engine: engine, level: 3)
|
||||
case "Title4":
|
||||
TitleControl(schema: schema, engine: engine, level: 4)
|
||||
case "Title5":
|
||||
TitleControl(schema: schema, engine: engine, level: 5)
|
||||
case "Title6":
|
||||
TitleControl(schema: schema, engine: engine, level: 6)
|
||||
case "Label":
|
||||
LabelControl(schema: schema, engine: engine)
|
||||
|
||||
// 输入类
|
||||
case "Input", "UiStr":
|
||||
InputControl(schema: schema, engine: engine)
|
||||
case "Textarea", "UiText":
|
||||
TextareaControl(schema: schema, engine: engine)
|
||||
case "UiNumber":
|
||||
NumberControl(schema: schema, engine: engine)
|
||||
case "UiDate":
|
||||
DateControl(schema: schema, engine: engine)
|
||||
case "Select", "UiCode":
|
||||
SelectControl(schema: schema, engine: engine)
|
||||
|
||||
// 按钮
|
||||
case "Button":
|
||||
ButtonControl(schema: schema, engine: engine)
|
||||
|
||||
// 布局
|
||||
case "VBox", "Card":
|
||||
VBoxControl(schema: schema, engine: engine)
|
||||
case "HBox":
|
||||
HBoxControl(schema: schema, engine: engine)
|
||||
case "VScrollPanel", "HScrollPanel":
|
||||
ScrollPanelControl(schema: schema, engine: engine)
|
||||
case "Filler":
|
||||
Spacer().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case "DynamicColumn":
|
||||
DynamicColumnControl(schema: schema, engine: engine)
|
||||
|
||||
// 数据展示
|
||||
case "Tabular", "DataViewer":
|
||||
TabularControl(schema: schema, engine: engine)
|
||||
case "Form":
|
||||
FormControl(schema: schema, engine: engine)
|
||||
case "InlineForm":
|
||||
InlineFormControl(schema: schema, engine: engine)
|
||||
|
||||
// 导航
|
||||
case "TabView":
|
||||
TabViewControl(schema: schema, engine: engine)
|
||||
case "Menu":
|
||||
MenuControl(schema: schema, engine: engine)
|
||||
|
||||
// 弹窗
|
||||
case "PopupWindow":
|
||||
PopupWindowControl(schema: schema, engine: engine)
|
||||
|
||||
// HTML
|
||||
case "Html":
|
||||
HtmlControl(schema: schema, engine: engine)
|
||||
|
||||
// Image
|
||||
case "Image":
|
||||
ImageControl(schema: schema, engine: engine)
|
||||
|
||||
// urlwidget(直接加载远程内容)
|
||||
case "urlwidget":
|
||||
UrlWidgetControl(schema: schema, engine: engine)
|
||||
|
||||
default:
|
||||
// 未知widgettype — 渲染占位符
|
||||
VStack {
|
||||
Text("⚠️ Unknown: \(type)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 尺寸解析
|
||||
|
||||
private func resolveWidth(_ opts: ControlOptions) -> CGFloat? {
|
||||
if let w = opts.width {
|
||||
if w == "100%" { return nil } // 使用frame的maxWidth
|
||||
if let px = parsePixels(w) { return px }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveHeight(_ opts: ControlOptions) -> CGFloat? {
|
||||
if let h = opts.height {
|
||||
if h == "100%" { return nil }
|
||||
if let px = parsePixels(h) { return px }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveAlignment(_ opts: ControlOptions) -> Alignment {
|
||||
switch opts.alignItems {
|
||||
case "center": return .center
|
||||
case "flex-end", "end": return .trailing
|
||||
case "flex-start", "start": return .leading
|
||||
default: return .topLeading
|
||||
}
|
||||
}
|
||||
|
||||
private func parsePixels(_ str: String) -> CGFloat? {
|
||||
let cleaned = str.replacingOccurrences(of: "px", with: "")
|
||||
return CGFloat(Double(cleaned) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染子控件列表
|
||||
|
||||
struct SubWidgetsView: View {
|
||||
let subwidgets: [ControlSchema]
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
ForEach(Array(subwidgets.enumerated()), id: \.offset) { _, sub in
|
||||
ControlRenderer(schema: sub, engine: engine)
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Sources/SwiftBricks/SwiftBricks.swift
Normal file
50
Sources/SwiftBricks/SwiftBricks.swift
Normal file
@ -0,0 +1,50 @@
|
||||
/// SwiftBricks — Bricks框架的Swift/SwiftUI原生实现
|
||||
///
|
||||
/// 核心架构:
|
||||
/// - BricksEngine: 主引擎,管理schema加载、binds事件、状态
|
||||
/// - BricksStore: 数据存储,widget值/表单/表格/弹窗状态
|
||||
/// - BricksEventBus: 事件发布/订阅
|
||||
/// - BricksRPC: 网络层(GET/POST/Form)
|
||||
/// - BricksI18n: 国际化
|
||||
/// - BricksView: 主入口视图
|
||||
/// - ControlRenderer: 递归JSON→SwiftUI渲染器
|
||||
///
|
||||
/// 支持的Widget类型:
|
||||
/// - 文本: Text, Title1-6, Label
|
||||
/// - 输入: Input, Textarea, Select, UiCode, UiStr, UiNumber, UiDate, UiText
|
||||
/// - 按钮: Button
|
||||
/// - 布局: VBox, HBox, VScrollPanel, HScrollPanel, Filler, DynamicColumn, Card
|
||||
/// - 数据: Tabular, DataViewer, Form, InlineForm
|
||||
/// - 导航: TabView, Menu
|
||||
/// - 弹窗: PopupWindow
|
||||
/// - 其他: Html, Image, urlwidget
|
||||
///
|
||||
/// 支持的Bind ActionType (9种):
|
||||
/// newwindow, iframe, urlwidget, urldata, bricks, registerfunction, method, script, event
|
||||
///
|
||||
/// 用法:
|
||||
/// ```swift
|
||||
/// // 1. 创建引擎
|
||||
/// let engine = BricksEngine()
|
||||
/// engine.rpc.baseURL = "https://api.example.com"
|
||||
///
|
||||
/// // 2. 渲染视图
|
||||
/// BricksView(json: jsonString, engine: engine)
|
||||
/// BricksView(url: "/api/page.ui", engine: engine)
|
||||
///
|
||||
/// // 3. 设置i18n
|
||||
/// await engine.i18n.loadLocale("en")
|
||||
/// ```
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// 导出所有公开类型
|
||||
public typealias Schema = ControlSchema
|
||||
public typealias Options = ControlOptions
|
||||
public typealias Bind = BindSchema
|
||||
public typealias Field = FieldSchema
|
||||
public typealias Store = BricksStore
|
||||
public typealias EventBus = BricksEventBus
|
||||
public typealias RPC = BricksRPC
|
||||
public typealias I18n = BricksI18n
|
||||
public typealias Engine = BricksEngine
|
||||
195
Tests/SwiftBricksTests/SwiftBricksTests.swift
Normal file
195
Tests/SwiftBricksTests/SwiftBricksTests.swift
Normal file
@ -0,0 +1,195 @@
|
||||
import XCTest
|
||||
@testable import SwiftBricks
|
||||
|
||||
final class SwiftBricksTests: XCTestCase {
|
||||
|
||||
// MARK: - Schema解析
|
||||
|
||||
func testParseSimpleSchema() throws {
|
||||
let json = """
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"text": "Hello World",
|
||||
"i18n": false
|
||||
}
|
||||
}
|
||||
"""
|
||||
let data = json.data(using: .utf8)!
|
||||
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
|
||||
XCTAssertEqual(schema.widgettype, "Text")
|
||||
XCTAssertEqual(schema.options.text, "Hello World")
|
||||
XCTAssertEqual(schema.options.i18n, false)
|
||||
}
|
||||
|
||||
func testParseVBoxWithSubwidgets() throws {
|
||||
let json = """
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": { "spacing": 10, "css": "card" },
|
||||
"subwidgets": [
|
||||
{ "widgettype": "Title2", "options": { "text": "标题" } },
|
||||
{ "widgettype": "Text", "options": { "otext": "描述文本" } },
|
||||
{ "widgettype": "Button", "id": "btn1", "options": { "label": "点击" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "urlwidget",
|
||||
"target": "content", "options": { "url": "/api/page.ui" } }] }
|
||||
]
|
||||
}
|
||||
"""
|
||||
let data = json.data(using: .utf8)!
|
||||
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
|
||||
XCTAssertEqual(schema.widgettype, "VBox")
|
||||
XCTAssertEqual(schema.options.spacing, 10)
|
||||
XCTAssertEqual(schema.subwidgets?.count, 3)
|
||||
XCTAssertEqual(schema.subwidgets?[0].widgettype, "Title2")
|
||||
XCTAssertEqual(schema.subwidgets?[2].binds?.count, 1)
|
||||
XCTAssertEqual(schema.subwidgets?[2].binds?[0].actiontype, "urlwidget")
|
||||
}
|
||||
|
||||
func testParseFormSchema() throws {
|
||||
let json = """
|
||||
{
|
||||
"widgettype": "Form",
|
||||
"options": {
|
||||
"title": "用户信息",
|
||||
"submit_url": "/api/submit.dspy",
|
||||
"fields": [
|
||||
{ "name": "username", "label": "用户名", "uitype": "str", "required": true,
|
||||
"rules": [{ "type": "required", "message": "不能为空" }] },
|
||||
{ "name": "role", "label": "角色", "uitype": "code",
|
||||
"codes": [{"value": "admin", "text": "管理员"}, {"value": "user", "text": "普通用户"}] }
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
let data = json.data(using: .utf8)!
|
||||
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
|
||||
XCTAssertEqual(schema.widgettype, "Form")
|
||||
XCTAssertEqual(schema.options.fields?.count, 2)
|
||||
XCTAssertEqual(schema.options.fields?[0].rules?.count, 1)
|
||||
XCTAssertEqual(schema.options.fields?[1].codes?.count, 2)
|
||||
}
|
||||
|
||||
func testParseTabularSchema() throws {
|
||||
let json = """
|
||||
{
|
||||
"widgettype": "Tabular",
|
||||
"options": {
|
||||
"data_url": "/api/list.dspy",
|
||||
"page_rows": 10,
|
||||
"row_options": {
|
||||
"fields": [
|
||||
{ "name": "id", "label": "ID", "uitype": "str" },
|
||||
{ "name": "name", "label": "名称", "uitype": "str" }
|
||||
],
|
||||
"browserfields": {
|
||||
"exclouded": ["id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
let data = json.data(using: .utf8)!
|
||||
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
|
||||
XCTAssertEqual(schema.widgettype, "Tabular")
|
||||
XCTAssertEqual(schema.options.page_rows, 10)
|
||||
XCTAssertEqual(schema.options.row_options?.fields?.count, 2)
|
||||
XCTAssertEqual(schema.options.row_options?.browserfields?.exclouded, ["id"])
|
||||
}
|
||||
|
||||
// MARK: - ActionType验证
|
||||
|
||||
func testValidActionTypes() {
|
||||
XCTAssertEqual(ActionType.allCases.count, 9)
|
||||
XCTAssertNotNil(ActionType(rawValue: "urlwidget"))
|
||||
XCTAssertNotNil(ActionType(rawValue: "urldata"))
|
||||
XCTAssertNotNil(ActionType(rawValue: "method"))
|
||||
XCTAssertNotNil(ActionType(rawValue: "event"))
|
||||
XCTAssertNil(ActionType(rawValue: "fetch")) // 无效
|
||||
XCTAssertNil(ActionType(rawValue: "ajax")) // 无效
|
||||
}
|
||||
|
||||
// MARK: - WidgetType验证
|
||||
|
||||
func testWidgetTypeCategories() {
|
||||
XCTAssertTrue(WidgetType.vbox.isLayout)
|
||||
XCTAssertTrue(WidgetType.hbox.isLayout)
|
||||
XCTAssertTrue(WidgetType.text.isText)
|
||||
XCTAssertTrue(WidgetType.title2.isText)
|
||||
XCTAssertTrue(WidgetType.input.isInput)
|
||||
XCTAssertFalse(WidgetType.button.isLayout)
|
||||
}
|
||||
|
||||
// MARK: - Store
|
||||
|
||||
@MainActor
|
||||
func testStoreFormValues() {
|
||||
let store = BricksStore()
|
||||
store.setFormField(formId: "form1", field: "name", value: "Alice")
|
||||
store.setFormField(formId: "form1", field: "email", value: "alice@test.com")
|
||||
|
||||
XCTAssertEqual(store.getFormField(formId: "form1", field: "name"), "Alice")
|
||||
XCTAssertEqual(store.getFormField(formId: "form1", field: "email"), "alice@test.com")
|
||||
|
||||
let all = store.getFormValues(formId: "form1")
|
||||
XCTAssertEqual(all.count, 2)
|
||||
|
||||
store.clearForm(formId: "form1")
|
||||
XCTAssertEqual(store.getFormField(formId: "form1", field: "name"), "")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testStoreTableData() {
|
||||
let store = BricksStore()
|
||||
let rows: [[String: Any]] = [
|
||||
["id": "1", "name": "Alice"],
|
||||
["id": "2", "name": "Bob"]
|
||||
]
|
||||
store.setTableData(id: "tbl1", rows: rows)
|
||||
store.selectRow(tabularId: "tbl1", index: 1)
|
||||
|
||||
XCTAssertEqual(store.getTableData(id: "tbl1").count, 2)
|
||||
XCTAssertEqual(store.getSelectedRow(tabularId: "tbl1"), 1)
|
||||
let selected = store.getSelectedRowData(tabularId: "tbl1")
|
||||
XCTAssertEqual(selected?["name"] as? String, "Bob")
|
||||
}
|
||||
|
||||
// MARK: - I18n
|
||||
|
||||
@MainActor
|
||||
func testI18nTranslation() {
|
||||
let i18n = BricksI18n(locale: "en")
|
||||
i18n.loadMessages([
|
||||
"你好": "Hello",
|
||||
"提交": "Submit"
|
||||
])
|
||||
|
||||
XCTAssertEqual(i18n.t("你好"), "Hello")
|
||||
XCTAssertEqual(i18n.t("提交"), "Submit")
|
||||
XCTAssertEqual(i18n.t("未知"), "未知") // 未翻译返回原文
|
||||
}
|
||||
|
||||
// MARK: - EventBus
|
||||
|
||||
@MainActor
|
||||
func testEventBus() async {
|
||||
let bus = BricksEventBus()
|
||||
var received = false
|
||||
|
||||
bus.on("test.click") { _ in
|
||||
received = true
|
||||
}
|
||||
|
||||
await bus.dispatch("test.click")
|
||||
XCTAssertTrue(received)
|
||||
}
|
||||
|
||||
// MARK: - Engine模板替换
|
||||
|
||||
@MainActor
|
||||
func testTemplateResolve() {
|
||||
let engine = BricksEngine()
|
||||
let result = engine.resolveTemplate("/api/detail.dspy?id=${id}$", params: ["id": "12345"])
|
||||
XCTAssertEqual(result, "/api/detail.dspy?id=12345")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user