- 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
283 lines
9.5 KiB
Swift
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
|
|
}
|
|
}
|