Hermes Agent 36967370fd 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
2026-06-21 12:12:03 +08:00

283 lines
9.5 KiB
Swift

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
}
}