parent
d5fef06e76
commit
12764a2598
@ -0,0 +1,150 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 日历事件输入组件
|
||||||
|
struct CalendarInputView: View {
|
||||||
|
@Binding var eventTitle: String
|
||||||
|
@Binding var eventDescription: String
|
||||||
|
@Binding var startDate: Date
|
||||||
|
@Binding var endDate: Date
|
||||||
|
@Binding var location: String
|
||||||
|
@FocusState var focusedField: CalendarField?
|
||||||
|
|
||||||
|
// 日历字段枚举
|
||||||
|
enum CalendarField: Hashable {
|
||||||
|
case title, description, location
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// 事件标题
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("事件标题")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("会议标题", text: $eventTitle)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件描述
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("事件描述")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("事件详细描述", text: $eventDescription)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始时间
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("开始时间")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
DatePicker("开始时间", selection: $startDate, displayedComponents: [.date, .hourAndMinute])
|
||||||
|
.datePickerStyle(CompactDatePickerStyle())
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束时间
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("结束时间")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
DatePicker("结束时间", selection: $endDate, displayedComponents: [.date, .hourAndMinute])
|
||||||
|
.datePickerStyle(CompactDatePickerStyle())
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地点
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("地点")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("会议地点", text: $location)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间验证提示
|
||||||
|
if endDate <= startDate {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
|
Text("时间设置提示")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("结束时间必须晚于开始时间")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.orange.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
Spacer()
|
||||||
|
Button("完成") {
|
||||||
|
focusedField = nil
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 设置默认时间
|
||||||
|
if startDate == Date() {
|
||||||
|
startDate = Date()
|
||||||
|
endDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate) ?? startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CalendarInputView(
|
||||||
|
eventTitle: .constant(""),
|
||||||
|
eventDescription: .constant(""),
|
||||||
|
startDate: .constant(Date()),
|
||||||
|
endDate: .constant(Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()),
|
||||||
|
location: .constant("")
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,325 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 简化的卡片组件
|
||||||
|
struct SimpleCardView: View {
|
||||||
|
let content: AnyView
|
||||||
|
let padding: EdgeInsets
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
let shadowColor: Color
|
||||||
|
let shadowRadius: CGFloat
|
||||||
|
let shadowOffset: CGSize
|
||||||
|
let backgroundColor: Color
|
||||||
|
|
||||||
|
init(
|
||||||
|
padding: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16),
|
||||||
|
cornerRadius: CGFloat = 12,
|
||||||
|
shadowColor: Color = .black.opacity(0.1),
|
||||||
|
shadowRadius: CGFloat = 8,
|
||||||
|
shadowOffset: CGSize = CGSize(width: 0, height: 4),
|
||||||
|
backgroundColor: Color = Color(.systemBackground),
|
||||||
|
@ViewBuilder content: () -> some View
|
||||||
|
) {
|
||||||
|
self.padding = padding
|
||||||
|
self.cornerRadius = cornerRadius
|
||||||
|
self.shadowColor = shadowColor
|
||||||
|
self.shadowRadius = shadowRadius
|
||||||
|
self.shadowOffset = shadowOffset
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
|
self.content = AnyView(content())
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.padding(padding)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.cornerRadius(cornerRadius)
|
||||||
|
.shadow(
|
||||||
|
color: shadowColor,
|
||||||
|
radius: shadowRadius,
|
||||||
|
x: shadowOffset.width,
|
||||||
|
y: shadowOffset.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的卡片样式
|
||||||
|
extension SimpleCardView {
|
||||||
|
static func standard<Content: View>(
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> SimpleCardView {
|
||||||
|
SimpleCardView(content: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func elevated<Content: View>(
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> SimpleCardView {
|
||||||
|
SimpleCardView(
|
||||||
|
shadowColor: .black.opacity(0.15),
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: CGSize(width: 0, height: 6),
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func subtle<Content: View>(
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> SimpleCardView {
|
||||||
|
SimpleCardView(
|
||||||
|
shadowColor: .black.opacity(0.05),
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: CGSize(width: 0, height: 2),
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func compact<Content: View>(
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> SimpleCardView {
|
||||||
|
SimpleCardView(
|
||||||
|
padding: EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12),
|
||||||
|
cornerRadius: 8,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func large<Content: View>(
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> SimpleCardView {
|
||||||
|
SimpleCardView(
|
||||||
|
padding: EdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24),
|
||||||
|
cornerRadius: 16,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 信息卡片组件
|
||||||
|
struct InfoCard: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let content: String
|
||||||
|
let actionTitle: String?
|
||||||
|
let action: (() -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .blue,
|
||||||
|
content: String,
|
||||||
|
actionTitle: String? = nil,
|
||||||
|
action: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.content = content
|
||||||
|
self.actionTitle = actionTitle
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SimpleCardView.standard {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// 标题行
|
||||||
|
HStack {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(iconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
if let subtitle = subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容
|
||||||
|
Text(content)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(nil)
|
||||||
|
|
||||||
|
// 操作按钮
|
||||||
|
if let actionTitle = actionTitle, let action = action {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(actionTitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(iconColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 统计卡片组件
|
||||||
|
struct StatCard: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let subtitle: String?
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: Color
|
||||||
|
let trend: Trend?
|
||||||
|
|
||||||
|
enum Trend {
|
||||||
|
case up(String)
|
||||||
|
case down(String)
|
||||||
|
case neutral(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
icon: String? = nil,
|
||||||
|
iconColor: Color = .blue,
|
||||||
|
trend: Trend? = nil
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.value = value
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.icon = icon
|
||||||
|
self.iconColor = iconColor
|
||||||
|
self.trend = trend
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SimpleCardView.standard {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// 标题和图标
|
||||||
|
HStack {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(iconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数值
|
||||||
|
Text(value)
|
||||||
|
.font(.title)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
// 副标题和趋势
|
||||||
|
HStack {
|
||||||
|
if let subtitle = subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let trend = trend {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: trendIcon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(trendColor)
|
||||||
|
|
||||||
|
Text(trendValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(trendColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trendIcon: String {
|
||||||
|
switch trend {
|
||||||
|
case .up:
|
||||||
|
return "arrow.up"
|
||||||
|
case .down:
|
||||||
|
return "arrow.down"
|
||||||
|
case .neutral:
|
||||||
|
return "minus"
|
||||||
|
case .none:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trendColor: Color {
|
||||||
|
switch trend {
|
||||||
|
case .up:
|
||||||
|
return .green
|
||||||
|
case .down:
|
||||||
|
return .red
|
||||||
|
case .neutral:
|
||||||
|
return .orange
|
||||||
|
case .none:
|
||||||
|
return .clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trendValue: String {
|
||||||
|
switch trend {
|
||||||
|
case .up(let value), .down(let value), .neutral(let value):
|
||||||
|
return value
|
||||||
|
case .none:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// 标准卡片
|
||||||
|
SimpleCardView.standard {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("标准卡片")
|
||||||
|
.font(.headline)
|
||||||
|
Text("这是一个标准样式的卡片组件")
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 信息卡片
|
||||||
|
InfoCard(
|
||||||
|
title: "提示信息",
|
||||||
|
subtitle: "重要提醒",
|
||||||
|
icon: "info.circle",
|
||||||
|
content: "这是一个信息卡片,用于显示重要的提示信息。",
|
||||||
|
actionTitle: "了解更多",
|
||||||
|
action: {}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 统计卡片
|
||||||
|
StatCard(
|
||||||
|
title: "总用户数",
|
||||||
|
value: "1,234",
|
||||||
|
subtitle: "本月新增 123",
|
||||||
|
icon: "person.3",
|
||||||
|
trend: .up("+12%")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 紧凑卡片
|
||||||
|
SimpleCardView.compact {
|
||||||
|
Text("紧凑卡片")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,168 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 联系人信息输入组件
|
||||||
|
struct ContactInputView: View {
|
||||||
|
@Binding var firstName: String
|
||||||
|
@Binding var lastName: String
|
||||||
|
@Binding var phone: String
|
||||||
|
@Binding var email: String
|
||||||
|
@Binding var company: String
|
||||||
|
@Binding var title: String
|
||||||
|
@Binding var address: String
|
||||||
|
@Binding var website: String
|
||||||
|
@FocusState var focusedField: ContactField?
|
||||||
|
|
||||||
|
// 联系人字段枚举
|
||||||
|
enum ContactField: Hashable {
|
||||||
|
case firstName, lastName, phone, email, company, title, address, website
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// 姓名
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("名")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("名", text: $firstName)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .firstName)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("姓")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("姓", text: $lastName)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .lastName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 电话
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("电话")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("+86 138 0013 8000", text: $phone)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(.phonePad)
|
||||||
|
.focused($focusedField, equals: .phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邮箱
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("邮箱")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("user@example.com", text: $email)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.focused($focusedField, equals: .email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公司
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("公司")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("公司名称", text: $company)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .company)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 职位
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("职位")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("职位名称", text: $title)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地址
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("地址")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("详细地址", text: $address)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网站
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("网站")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("https://example.com", text: $website)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.focused($focusedField, equals: .website)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
Spacer()
|
||||||
|
Button("完成") {
|
||||||
|
focusedField = nil
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContactInputView(
|
||||||
|
firstName: .constant(""),
|
||||||
|
lastName: .constant(""),
|
||||||
|
phone: .constant(""),
|
||||||
|
email: .constant(""),
|
||||||
|
company: .constant(""),
|
||||||
|
title: .constant(""),
|
||||||
|
address: .constant(""),
|
||||||
|
website: .constant("")
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,141 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用日期选择器组件
|
||||||
|
struct DatePickerView: View {
|
||||||
|
let title: String
|
||||||
|
let isRequired: Bool
|
||||||
|
let date: Binding<Date>
|
||||||
|
let displayedComponents: DatePickerComponents
|
||||||
|
let icon: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
date: Binding<Date>,
|
||||||
|
displayedComponents: DatePickerComponents = [.date, .hourAndMinute],
|
||||||
|
icon: String? = nil
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.isRequired = isRequired
|
||||||
|
self.date = date
|
||||||
|
self.displayedComponents = displayedComponents
|
||||||
|
self.icon = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
InputTitleView.required(title, icon: icon)
|
||||||
|
|
||||||
|
DatePicker(title, selection: date, displayedComponents: displayedComponents)
|
||||||
|
.datePickerStyle(CompactDatePickerStyle())
|
||||||
|
.labelsHidden()
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(.systemGray6))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的日期选择器样式
|
||||||
|
extension DatePickerView {
|
||||||
|
static func dateOnly(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
date: Binding<Date>,
|
||||||
|
icon: String? = nil
|
||||||
|
) -> DatePickerView {
|
||||||
|
DatePickerView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
date: date,
|
||||||
|
displayedComponents: .date,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func timeOnly(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
date: Binding<Date>,
|
||||||
|
icon: String? = nil
|
||||||
|
) -> DatePickerView {
|
||||||
|
DatePickerView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
date: date,
|
||||||
|
displayedComponents: .hourAndMinute,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dateAndTime(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
date: Binding<Date>,
|
||||||
|
icon: String? = nil
|
||||||
|
) -> DatePickerView {
|
||||||
|
DatePickerView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
date: date,
|
||||||
|
displayedComponents: [.date, .hourAndMinute],
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func startDate(
|
||||||
|
title: String = "开始时间",
|
||||||
|
isRequired: Bool = true,
|
||||||
|
date: Binding<Date>,
|
||||||
|
icon: String? = "calendar"
|
||||||
|
) -> DatePickerView {
|
||||||
|
DatePickerView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
date: date,
|
||||||
|
displayedComponents: [.date, .hourAndMinute],
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func endDate(
|
||||||
|
title: String = "结束时间",
|
||||||
|
isRequired: Bool = true,
|
||||||
|
date: Binding<Date>,
|
||||||
|
icon: String? = "calendar"
|
||||||
|
) -> DatePickerView {
|
||||||
|
DatePickerView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
date: date,
|
||||||
|
displayedComponents: [.date, .hourAndMinute],
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
DatePickerView.dateOnly(
|
||||||
|
title: "选择日期",
|
||||||
|
date: .constant(Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
DatePickerView.timeOnly(
|
||||||
|
title: "选择时间",
|
||||||
|
date: .constant(Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
DatePickerView.dateAndTime(
|
||||||
|
title: "选择日期和时间",
|
||||||
|
date: .constant(Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
DatePickerView.startDate(date: .constant(Date()))
|
||||||
|
DatePickerView.endDate(date: .constant(Date().addingTimeInterval(3600)))
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,161 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Email输入组件
|
||||||
|
struct EmailInputView: View {
|
||||||
|
@Binding var emailAddress: String
|
||||||
|
@Binding var emailSubject: String
|
||||||
|
@Binding var emailBody: String
|
||||||
|
@Binding var emailCc: String
|
||||||
|
@Binding var emailBcc: String
|
||||||
|
@FocusState var focusedEmailField: EmailField?
|
||||||
|
|
||||||
|
// Email字段枚举
|
||||||
|
enum EmailField: Hashable {
|
||||||
|
case address, subject, body, cc, bcc
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Email地址 (必填)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("邮箱地址")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("user@example.com", text: $emailAddress)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.focused($focusedEmailField, equals: .address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题 (必填)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("主题")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("邮件主题", text: $emailSubject)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedEmailField, equals: .subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正文 (必填)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("正文")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
TextEditor(text: $emailBody)
|
||||||
|
.frame(minHeight: 120)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(focusedEmailField == .body ? Color.blue : Color(.systemGray4), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.focused($focusedEmailField, equals: .body)
|
||||||
|
.onChange(of: emailBody) { newValue in
|
||||||
|
// 限制最大字符数为1200
|
||||||
|
if newValue.count > 1200 {
|
||||||
|
emailBody = String(newValue.prefix(1200))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 占位符文本
|
||||||
|
if emailBody.isEmpty && focusedEmailField != .body {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Text("输入邮件正文内容...")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.body)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符计数
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("\(emailBody.count)/1200")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(emailBody.count >= 1200 ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CC地址 (可选)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("抄送地址")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("cc@example.com", text: $emailCc)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.focused($focusedEmailField, equals: .cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BCC地址 (可选)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("密送地址")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("bcc@example.com", text: $emailBcc)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.focused($focusedEmailField, equals: .bcc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
Spacer()
|
||||||
|
Button("完成") {
|
||||||
|
focusedEmailField = nil
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
EmailInputView(
|
||||||
|
emailAddress: .constant(""),
|
||||||
|
emailSubject: .constant(""),
|
||||||
|
emailBody: .constant(""),
|
||||||
|
emailCc: .constant(""),
|
||||||
|
emailBcc: .constant("")
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,285 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用表单组件
|
||||||
|
struct FormView<Content: View>: View {
|
||||||
|
let title: String?
|
||||||
|
let content: Content
|
||||||
|
let spacing: CGFloat
|
||||||
|
let padding: EdgeInsets
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String? = nil,
|
||||||
|
spacing: CGFloat = 16,
|
||||||
|
padding: EdgeInsets = EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20),
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.spacing = spacing
|
||||||
|
self.padding = padding
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: spacing) {
|
||||||
|
if let title = title {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.padding(padding)
|
||||||
|
}
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 表单分组组件
|
||||||
|
struct FormGroup<Content: View>: View {
|
||||||
|
let title: String?
|
||||||
|
let content: Content
|
||||||
|
let spacing: CGFloat
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String? = nil,
|
||||||
|
spacing: CGFloat = 12,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.spacing = spacing
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: spacing) {
|
||||||
|
if let title = title {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 表单行组件
|
||||||
|
struct FormRow<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
let isRequired: Bool
|
||||||
|
let content: Content
|
||||||
|
let icon: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
icon: String? = nil,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.isRequired = isRequired
|
||||||
|
self.icon = icon
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
if isRequired {
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 表单操作按钮组件
|
||||||
|
struct FormActionButton: View {
|
||||||
|
let title: String
|
||||||
|
let action: () -> Void
|
||||||
|
let isEnabled: Bool
|
||||||
|
let style: ButtonStyle
|
||||||
|
let icon: String?
|
||||||
|
|
||||||
|
enum ButtonStyle {
|
||||||
|
case primary
|
||||||
|
case secondary
|
||||||
|
case destructive
|
||||||
|
case success
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
action: @escaping () -> Void,
|
||||||
|
isEnabled: Bool = true,
|
||||||
|
style: ButtonStyle = .primary,
|
||||||
|
icon: String? = nil
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.action = action
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
self.style = style
|
||||||
|
self.icon = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(backgroundColor)
|
||||||
|
)
|
||||||
|
.foregroundColor(foregroundColor)
|
||||||
|
}
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.opacity(isEnabled ? 1.0 : 0.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
switch style {
|
||||||
|
case .primary:
|
||||||
|
return .blue
|
||||||
|
case .secondary:
|
||||||
|
return Color(.systemGray5)
|
||||||
|
case .destructive:
|
||||||
|
return .red
|
||||||
|
case .success:
|
||||||
|
return .green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foregroundColor: Color {
|
||||||
|
switch style {
|
||||||
|
case .primary, .destructive, .success:
|
||||||
|
return .white
|
||||||
|
case .secondary:
|
||||||
|
return .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的表单操作按钮
|
||||||
|
extension FormActionButton {
|
||||||
|
static func primary(
|
||||||
|
title: String,
|
||||||
|
action: @escaping () -> Void,
|
||||||
|
isEnabled: Bool = true,
|
||||||
|
icon: String? = nil
|
||||||
|
) -> FormActionButton {
|
||||||
|
FormActionButton(
|
||||||
|
title: title,
|
||||||
|
action: action,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
style: .primary,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func secondary(
|
||||||
|
title: String,
|
||||||
|
action: @escaping () -> Void,
|
||||||
|
isEnabled: Bool = true,
|
||||||
|
icon: String? = nil
|
||||||
|
) -> FormActionButton {
|
||||||
|
FormActionButton(
|
||||||
|
title: title,
|
||||||
|
action: action,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
style: .secondary,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func destructive(
|
||||||
|
title: String,
|
||||||
|
action: @escaping () -> Void,
|
||||||
|
isEnabled: Bool = true,
|
||||||
|
icon: String? = nil
|
||||||
|
) -> FormActionButton {
|
||||||
|
FormActionButton(
|
||||||
|
title: title,
|
||||||
|
action: action,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
style: .destructive,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func success(
|
||||||
|
title: String,
|
||||||
|
action: @escaping () -> Void,
|
||||||
|
isEnabled: Bool = true,
|
||||||
|
icon: String? = nil
|
||||||
|
) -> FormActionButton {
|
||||||
|
FormActionButton(
|
||||||
|
title: title,
|
||||||
|
action: action,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
style: .success,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
FormView(title: "示例表单") {
|
||||||
|
FormGroup(title: "基本信息") {
|
||||||
|
FormRow(title: "用户名", isRequired: true, icon: "person") {
|
||||||
|
TextField("请输入用户名", text: .constant(""))
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
FormRow(title: "邮箱", isRequired: true, icon: "envelope") {
|
||||||
|
TextField("请输入邮箱", text: .constant(""))
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FormGroup(title: "操作") {
|
||||||
|
FormActionButton.primary(title: "保存", action: {})
|
||||||
|
FormActionButton.secondary(title: "取消", action: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,217 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 输入组件工厂
|
||||||
|
struct InputComponentFactory {
|
||||||
|
|
||||||
|
// 根据QR码类型返回相应的输入组件
|
||||||
|
static func createInputComponent(
|
||||||
|
for qrCodeType: QRCodeType,
|
||||||
|
content: Binding<String>,
|
||||||
|
emailAddress: Binding<String>,
|
||||||
|
emailSubject: Binding<String>,
|
||||||
|
emailBody: Binding<String>,
|
||||||
|
emailCc: Binding<String>,
|
||||||
|
emailBcc: Binding<String>,
|
||||||
|
focusedEmailField: FocusState<EmailInputView.EmailField?>,
|
||||||
|
isContentFieldFocused: FocusState<Bool>,
|
||||||
|
ssid: Binding<String>,
|
||||||
|
password: Binding<String>,
|
||||||
|
encryptionType: Binding<WiFiInputView.WiFiEncryptionType>,
|
||||||
|
focusedWiFiField: FocusState<WiFiInputView.WiFiField?>,
|
||||||
|
firstName: Binding<String>,
|
||||||
|
lastName: Binding<String>,
|
||||||
|
phone: Binding<String>,
|
||||||
|
email: Binding<String>,
|
||||||
|
company: Binding<String>,
|
||||||
|
title: Binding<String>,
|
||||||
|
address: Binding<String>,
|
||||||
|
website: Binding<String>,
|
||||||
|
focusedContactField: FocusState<ContactInputView.ContactField?>,
|
||||||
|
latitude: Binding<String>,
|
||||||
|
longitude: Binding<String>,
|
||||||
|
locationName: Binding<String>,
|
||||||
|
focusedLocationField: FocusState<LocationInputView.LocationField?>,
|
||||||
|
eventTitle: Binding<String>,
|
||||||
|
eventDescription: Binding<String>,
|
||||||
|
startDate: Binding<Date>,
|
||||||
|
endDate: Binding<Date>,
|
||||||
|
location: Binding<String>,
|
||||||
|
focusedCalendarField: FocusState<CalendarInputView.CalendarField?>,
|
||||||
|
username: Binding<String>,
|
||||||
|
message: Binding<String>,
|
||||||
|
focusedSocialField: FocusState<SocialInputView.SocialField?>,
|
||||||
|
phoneNumber: Binding<String>,
|
||||||
|
phoneMessage: Binding<String>,
|
||||||
|
focusedPhoneField: FocusState<PhoneInputView.PhoneField?>,
|
||||||
|
url: Binding<String>,
|
||||||
|
isUrlFieldFocused: FocusState<Bool>
|
||||||
|
) -> AnyView {
|
||||||
|
|
||||||
|
switch qrCodeType {
|
||||||
|
case .mail:
|
||||||
|
return AnyView(
|
||||||
|
EmailInputView(
|
||||||
|
emailAddress: emailAddress,
|
||||||
|
emailSubject: emailSubject,
|
||||||
|
emailBody: emailBody,
|
||||||
|
emailCc: emailCc,
|
||||||
|
emailBcc: emailBcc,
|
||||||
|
focusedEmailField: focusedEmailField
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .wifi:
|
||||||
|
return AnyView(
|
||||||
|
WiFiInputView(
|
||||||
|
ssid: ssid,
|
||||||
|
password: password,
|
||||||
|
encryptionType: encryptionType,
|
||||||
|
focusedField: focusedWiFiField
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .vcard, .mecard:
|
||||||
|
return AnyView(
|
||||||
|
ContactInputView(
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
phone: phone,
|
||||||
|
email: email,
|
||||||
|
company: company,
|
||||||
|
title: title,
|
||||||
|
address: address,
|
||||||
|
website: website,
|
||||||
|
focusedField: focusedContactField
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .location:
|
||||||
|
return AnyView(
|
||||||
|
LocationInputView(
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
locationName: locationName,
|
||||||
|
focusedField: focusedLocationField
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .calendar:
|
||||||
|
return AnyView(
|
||||||
|
CalendarInputView(
|
||||||
|
eventTitle: eventTitle,
|
||||||
|
eventDescription: eventDescription,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
location: location
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .instagram, .facebook, .twitter, .tiktok, .snapchat, .whatsapp, .viber, .spotify:
|
||||||
|
let platform = SocialInputView.SocialPlatform(rawValue: qrCodeType.rawValue.capitalized) ?? .instagram
|
||||||
|
return AnyView(
|
||||||
|
SocialInputView(
|
||||||
|
username: username,
|
||||||
|
message: message,
|
||||||
|
platform: platform,
|
||||||
|
focusedField: focusedSocialField
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .phone, .sms:
|
||||||
|
let inputType: PhoneInputView.PhoneInputType = qrCodeType == .phone ? .phone : .sms
|
||||||
|
return AnyView(
|
||||||
|
PhoneInputView(
|
||||||
|
phoneNumber: phoneNumber,
|
||||||
|
message: phoneMessage,
|
||||||
|
inputType: inputType,
|
||||||
|
focusedField: focusedPhoneField
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .url:
|
||||||
|
return AnyView(
|
||||||
|
URLInputView(
|
||||||
|
url: url,
|
||||||
|
isUrlFieldFocused: isUrlFieldFocused
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 默认使用通用文本输入组件
|
||||||
|
return AnyView(
|
||||||
|
TextInputView(
|
||||||
|
content: content,
|
||||||
|
isContentFieldFocused: isContentFieldFocused,
|
||||||
|
placeholder: getPlaceholderText(for: qrCodeType),
|
||||||
|
maxCharacters: getMaxCharacters(for: qrCodeType)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取占位符文本
|
||||||
|
private static func getPlaceholderText(for qrCodeType: QRCodeType) -> String {
|
||||||
|
switch qrCodeType {
|
||||||
|
case .text:
|
||||||
|
return "输入任意文本内容..."
|
||||||
|
case .phone:
|
||||||
|
return "输入电话号码..."
|
||||||
|
case .sms:
|
||||||
|
return "输入短信内容..."
|
||||||
|
case .wifi:
|
||||||
|
return "输入WiFi信息..."
|
||||||
|
case .vcard:
|
||||||
|
return "输入联系人信息..."
|
||||||
|
case .mecard:
|
||||||
|
return "输入联系人信息..."
|
||||||
|
case .location:
|
||||||
|
return "输入地理位置..."
|
||||||
|
case .calendar:
|
||||||
|
return "输入日历事件信息..."
|
||||||
|
case .instagram:
|
||||||
|
return "输入Instagram信息..."
|
||||||
|
case .facebook:
|
||||||
|
return "输入Facebook信息..."
|
||||||
|
case .spotify:
|
||||||
|
return "输入Spotify信息..."
|
||||||
|
case .twitter:
|
||||||
|
return "输入Twitter信息..."
|
||||||
|
case .whatsapp:
|
||||||
|
return "输入WhatsApp信息..."
|
||||||
|
case .viber:
|
||||||
|
return "输入Viber信息..."
|
||||||
|
case .snapchat:
|
||||||
|
return "输入Snapchat信息..."
|
||||||
|
case .tiktok:
|
||||||
|
return "输入TikTok信息..."
|
||||||
|
case .mail:
|
||||||
|
return "输入邮件内容..."
|
||||||
|
case .url:
|
||||||
|
return "输入网址..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最大字符数
|
||||||
|
private static func getMaxCharacters(for qrCodeType: QRCodeType) -> Int {
|
||||||
|
switch qrCodeType {
|
||||||
|
case .text:
|
||||||
|
return 150
|
||||||
|
case .phone, .sms:
|
||||||
|
return 100
|
||||||
|
case .wifi:
|
||||||
|
return 200
|
||||||
|
case .vcard, .mecard:
|
||||||
|
return 500
|
||||||
|
case .location:
|
||||||
|
return 200
|
||||||
|
case .calendar:
|
||||||
|
return 300
|
||||||
|
case .instagram, .facebook, .twitter, .tiktok, .snapchat, .whatsapp, .viber, .spotify:
|
||||||
|
return 200
|
||||||
|
case .mail:
|
||||||
|
return 1200
|
||||||
|
case .url:
|
||||||
|
return 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,208 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用输入字段组件
|
||||||
|
struct InputFieldView: View {
|
||||||
|
let title: String
|
||||||
|
let isRequired: Bool
|
||||||
|
let placeholder: String
|
||||||
|
let text: Binding<String>
|
||||||
|
let keyboardType: UIKeyboardType
|
||||||
|
let autocapitalization: UITextAutocapitalizationType
|
||||||
|
let icon: String?
|
||||||
|
let isSecure: Bool
|
||||||
|
let isFocused: Bool
|
||||||
|
let onFocusChange: (Bool) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
keyboardType: UIKeyboardType = .default,
|
||||||
|
autocapitalization: UITextAutocapitalizationType = .sentences,
|
||||||
|
icon: String? = nil,
|
||||||
|
isSecure: Bool = false,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.isRequired = isRequired
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.text = text
|
||||||
|
self.keyboardType = keyboardType
|
||||||
|
self.autocapitalization = autocapitalization
|
||||||
|
self.icon = icon
|
||||||
|
self.isSecure = isSecure
|
||||||
|
self.isFocused = isFocused
|
||||||
|
self.onFocusChange = onFocusChange
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
InputTitleView.required(title, icon: icon)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if isSecure {
|
||||||
|
SecureField(placeholder, text: text)
|
||||||
|
} else {
|
||||||
|
TextField(placeholder, text: text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.keyboardType(keyboardType)
|
||||||
|
.autocapitalization(autocapitalization)
|
||||||
|
.onTapGesture {
|
||||||
|
onFocusChange(true)
|
||||||
|
}
|
||||||
|
.onChange(of: isFocused) { newValue in
|
||||||
|
onFocusChange(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的输入字段样式
|
||||||
|
extension InputFieldView {
|
||||||
|
static func text(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) -> InputFieldView {
|
||||||
|
InputFieldView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
placeholder: placeholder,
|
||||||
|
text: text,
|
||||||
|
icon: icon,
|
||||||
|
isFocused: isFocused,
|
||||||
|
onFocusChange: onFocusChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func email(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) -> InputFieldView {
|
||||||
|
InputFieldView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
placeholder: placeholder,
|
||||||
|
text: text,
|
||||||
|
keyboardType: .emailAddress,
|
||||||
|
autocapitalization: .sentences,
|
||||||
|
icon: icon,
|
||||||
|
isFocused: isFocused,
|
||||||
|
onFocusChange: onFocusChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func phone(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) -> InputFieldView {
|
||||||
|
InputFieldView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
placeholder: placeholder,
|
||||||
|
text: text,
|
||||||
|
keyboardType: .phonePad,
|
||||||
|
icon: icon,
|
||||||
|
isFocused: isFocused,
|
||||||
|
onFocusChange: onFocusChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func url(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) -> InputFieldView {
|
||||||
|
InputFieldView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
placeholder: placeholder,
|
||||||
|
text: text,
|
||||||
|
keyboardType: .URL,
|
||||||
|
autocapitalization: .sentences,
|
||||||
|
icon: icon,
|
||||||
|
isFocused: isFocused,
|
||||||
|
onFocusChange: onFocusChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func password(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) -> InputFieldView {
|
||||||
|
InputFieldView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
placeholder: placeholder,
|
||||||
|
text: text,
|
||||||
|
icon: icon,
|
||||||
|
isSecure: true,
|
||||||
|
isFocused: isFocused,
|
||||||
|
onFocusChange: onFocusChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
InputFieldView.text(
|
||||||
|
title: "用户名",
|
||||||
|
isRequired: true,
|
||||||
|
placeholder: "请输入用户名",
|
||||||
|
text: .constant(""),
|
||||||
|
icon: "person"
|
||||||
|
)
|
||||||
|
|
||||||
|
InputFieldView.email(
|
||||||
|
title: "邮箱地址",
|
||||||
|
isRequired: true,
|
||||||
|
placeholder: "user@example.com",
|
||||||
|
text: .constant(""),
|
||||||
|
icon: "envelope"
|
||||||
|
)
|
||||||
|
|
||||||
|
InputFieldView.phone(
|
||||||
|
title: "电话号码",
|
||||||
|
isRequired: true,
|
||||||
|
placeholder: "+86 138 0013 8000",
|
||||||
|
text: .constant(""),
|
||||||
|
icon: "phone"
|
||||||
|
)
|
||||||
|
|
||||||
|
InputFieldView.password(
|
||||||
|
title: "密码",
|
||||||
|
isRequired: true,
|
||||||
|
placeholder: "请输入密码",
|
||||||
|
text: .constant(""),
|
||||||
|
icon: "lock"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 输入提示组件
|
||||||
|
struct InputHintView: View {
|
||||||
|
let hint: String
|
||||||
|
let icon: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
init(hint: String, icon: String = "info.circle", color: Color = .blue) {
|
||||||
|
self.hint = hint
|
||||||
|
self.icon = icon
|
||||||
|
self.color = color
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(color)
|
||||||
|
|
||||||
|
Text("输入提示")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(hint)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(nil)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(color.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的提示样式
|
||||||
|
extension InputHintView {
|
||||||
|
static func info(hint: String) -> InputHintView {
|
||||||
|
InputHintView(hint: hint, icon: "info.circle", color: .blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func warning(hint: String) -> InputHintView {
|
||||||
|
InputHintView(hint: hint, icon: "exclamationmark.triangle", color: .orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func success(hint: String) -> InputHintView {
|
||||||
|
InputHintView(hint: hint, icon: "checkmark.circle", color: .green)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func error(hint: String) -> InputHintView {
|
||||||
|
InputHintView(hint: hint, icon: "xmark.circle", color: .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
InputHintView.info(hint: "这是一个信息提示")
|
||||||
|
InputHintView.warning(hint: "这是一个警告提示")
|
||||||
|
InputHintView.success(hint: "这是一个成功提示")
|
||||||
|
InputHintView.error(hint: "这是一个错误提示")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 输入标题组件
|
||||||
|
struct InputTitleView: View {
|
||||||
|
let title: String
|
||||||
|
let isRequired: Bool
|
||||||
|
let icon: String?
|
||||||
|
|
||||||
|
init(_ title: String, isRequired: Bool = false, icon: String? = nil) {
|
||||||
|
self.title = title
|
||||||
|
self.isRequired = isRequired
|
||||||
|
self.icon = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
if isRequired {
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的标题样式
|
||||||
|
extension InputTitleView {
|
||||||
|
static func required(_ title: String, icon: String? = nil) -> InputTitleView {
|
||||||
|
InputTitleView(title, isRequired: true, icon: icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func optional(_ title: String, icon: String? = nil) -> InputTitleView {
|
||||||
|
InputTitleView(title, isRequired: false, icon: icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
InputTitleView.required("必填字段", icon: "star.fill")
|
||||||
|
InputTitleView.optional("可选字段", icon: "circle")
|
||||||
|
InputTitleView("普通标题")
|
||||||
|
InputTitleView.required("邮箱地址", icon: "envelope")
|
||||||
|
InputTitleView.required("密码", icon: "lock")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,273 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用键盘工具栏组件
|
||||||
|
struct KeyboardToolbarView: View {
|
||||||
|
let onDone: () -> Void
|
||||||
|
let showDoneButton: Bool
|
||||||
|
let additionalButtons: [KeyboardToolbarButton]
|
||||||
|
|
||||||
|
init(
|
||||||
|
onDone: @escaping () -> Void = {},
|
||||||
|
showDoneButton: Bool = true,
|
||||||
|
additionalButtons: [KeyboardToolbarButton] = []
|
||||||
|
) {
|
||||||
|
self.onDone = onDone
|
||||||
|
self.showDoneButton = showDoneButton
|
||||||
|
self.additionalButtons = additionalButtons
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
// 左侧按钮
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(additionalButtons.filter { $0.position == .left }, id: \.id) { button in
|
||||||
|
Button(action: button.action) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let icon = button.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
Text(button.title)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(button.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 右侧按钮
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(additionalButtons.filter { $0.position == .right }, id: \.id) { button in
|
||||||
|
Button(action: button.action) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let icon = button.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
Text(button.title)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(button.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showDoneButton {
|
||||||
|
Button("完成", action: onDone)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
// 左侧按钮
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(additionalButtons.filter { $0.position == .left }, id: \.id) { button in
|
||||||
|
Button(action: button.action) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let icon = button.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
Text(button.title)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(button.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 右侧按钮
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(additionalButtons.filter { $0.position == .right }, id: \.id) { button in
|
||||||
|
Button(action: button.action) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let icon = button.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
Text(button.title)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(button.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showDoneButton {
|
||||||
|
Button("完成", action: onDone)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 键盘工具栏按钮
|
||||||
|
struct KeyboardToolbarButton {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let icon: String?
|
||||||
|
let color: Color
|
||||||
|
let position: ButtonPosition
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
enum ButtonPosition {
|
||||||
|
case left, right
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
icon: String? = nil,
|
||||||
|
color: Color = .blue,
|
||||||
|
position: ButtonPosition = .right,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.icon = icon
|
||||||
|
self.color = color
|
||||||
|
self.position = position
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的工具栏按钮
|
||||||
|
extension KeyboardToolbarButton {
|
||||||
|
static func clear(
|
||||||
|
text: Binding<String>,
|
||||||
|
position: ButtonPosition = .left
|
||||||
|
) -> KeyboardToolbarButton {
|
||||||
|
KeyboardToolbarButton(
|
||||||
|
title: "清空",
|
||||||
|
icon: "trash",
|
||||||
|
color: .red,
|
||||||
|
position: position
|
||||||
|
) {
|
||||||
|
text.wrappedValue = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func copy(
|
||||||
|
text: String,
|
||||||
|
position: ButtonPosition = .left
|
||||||
|
) -> KeyboardToolbarButton {
|
||||||
|
KeyboardToolbarButton(
|
||||||
|
title: "复制",
|
||||||
|
icon: "doc.on.doc",
|
||||||
|
color: .blue,
|
||||||
|
position: position
|
||||||
|
) {
|
||||||
|
UIPasteboard.general.string = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func paste(
|
||||||
|
text: Binding<String>,
|
||||||
|
position: ButtonPosition = .left
|
||||||
|
) -> KeyboardToolbarButton {
|
||||||
|
KeyboardToolbarButton(
|
||||||
|
title: "粘贴",
|
||||||
|
icon: "doc.on.clipboard",
|
||||||
|
color: .green,
|
||||||
|
position: position
|
||||||
|
) {
|
||||||
|
if let pastedText = UIPasteboard.general.string {
|
||||||
|
text.wrappedValue = pastedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func next(
|
||||||
|
action: @escaping () -> Void,
|
||||||
|
position: ButtonPosition = .right
|
||||||
|
) -> KeyboardToolbarButton {
|
||||||
|
KeyboardToolbarButton(
|
||||||
|
title: "下一个",
|
||||||
|
icon: "arrow.right",
|
||||||
|
color: .blue,
|
||||||
|
position: position,
|
||||||
|
action: action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func previous(
|
||||||
|
action: @escaping () -> Void,
|
||||||
|
position: ButtonPosition = .left
|
||||||
|
) -> KeyboardToolbarButton {
|
||||||
|
KeyboardToolbarButton(
|
||||||
|
title: "上一个",
|
||||||
|
icon: "arrow.left",
|
||||||
|
color: .blue,
|
||||||
|
position: position,
|
||||||
|
action: action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的工具栏样式
|
||||||
|
extension KeyboardToolbarView {
|
||||||
|
static func simple(onDone: @escaping () -> Void = {}) -> KeyboardToolbarView {
|
||||||
|
KeyboardToolbarView(onDone: onDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func withClear(
|
||||||
|
text: Binding<String>,
|
||||||
|
onDone: @escaping () -> Void = {}
|
||||||
|
) -> KeyboardToolbarView {
|
||||||
|
KeyboardToolbarView(
|
||||||
|
onDone: onDone,
|
||||||
|
additionalButtons: [
|
||||||
|
.clear(text: text)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func withCopyPaste(
|
||||||
|
text: Binding<String>,
|
||||||
|
onDone: @escaping () -> Void = {}
|
||||||
|
) -> KeyboardToolbarView {
|
||||||
|
KeyboardToolbarView(
|
||||||
|
onDone: onDone,
|
||||||
|
additionalButtons: [
|
||||||
|
.copy(text: text.wrappedValue),
|
||||||
|
.paste(text: text)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func withNavigation(
|
||||||
|
onPrevious: @escaping () -> Void,
|
||||||
|
onNext: @escaping () -> Void,
|
||||||
|
onDone: @escaping () -> Void = {}
|
||||||
|
) -> KeyboardToolbarView {
|
||||||
|
KeyboardToolbarView(
|
||||||
|
onDone: onDone,
|
||||||
|
additionalButtons: [
|
||||||
|
.previous(action: onPrevious),
|
||||||
|
.next(action: onNext)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("简单工具栏")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("带清空按钮的工具栏")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("带复制粘贴的工具栏")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("带导航的工具栏")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,318 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用列表组件
|
||||||
|
struct ListView<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
|
||||||
|
let data: Data
|
||||||
|
let content: (Data.Element) -> Content
|
||||||
|
let spacing: CGFloat
|
||||||
|
let padding: EdgeInsets
|
||||||
|
let backgroundColor: Color
|
||||||
|
|
||||||
|
init(
|
||||||
|
data: Data,
|
||||||
|
spacing: CGFloat = 12,
|
||||||
|
padding: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0),
|
||||||
|
backgroundColor: Color = Color(.systemGroupedBackground),
|
||||||
|
@ViewBuilder content: @escaping (Data.Element) -> Content
|
||||||
|
) {
|
||||||
|
self.data = data
|
||||||
|
self.spacing = spacing
|
||||||
|
self.padding = padding
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: spacing) {
|
||||||
|
ForEach(data) { item in
|
||||||
|
content(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(padding)
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 列表项组件
|
||||||
|
struct ListItem<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
let padding: EdgeInsets
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
let backgroundColor: Color
|
||||||
|
let shadowColor: Color
|
||||||
|
let shadowRadius: CGFloat
|
||||||
|
let shadowOffset: CGSize
|
||||||
|
let onTap: (() -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
padding: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16),
|
||||||
|
cornerRadius: CGFloat = 12,
|
||||||
|
backgroundColor: Color = Color(.systemBackground),
|
||||||
|
shadowColor: Color = .black.opacity(0.05),
|
||||||
|
shadowRadius: CGFloat = 4,
|
||||||
|
shadowOffset: CGSize = CGSize(width: 0, height: 2),
|
||||||
|
onTap: (() -> Void)? = nil,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.padding = padding
|
||||||
|
self.cornerRadius = cornerRadius
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
|
self.shadowColor = shadowColor
|
||||||
|
self.shadowRadius = shadowRadius
|
||||||
|
self.shadowOffset = shadowOffset
|
||||||
|
self.onTap = onTap
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let onTap = onTap {
|
||||||
|
Button(action: onTap) {
|
||||||
|
content
|
||||||
|
.padding(padding)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.cornerRadius(cornerRadius)
|
||||||
|
.shadow(
|
||||||
|
color: shadowColor,
|
||||||
|
radius: shadowRadius,
|
||||||
|
x: shadowOffset.width,
|
||||||
|
y: shadowOffset.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.padding(padding)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.cornerRadius(cornerRadius)
|
||||||
|
.shadow(
|
||||||
|
color: shadowColor,
|
||||||
|
radius: shadowRadius,
|
||||||
|
x: shadowOffset.width,
|
||||||
|
y: shadowOffset.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的列表项样式
|
||||||
|
extension ListItem {
|
||||||
|
static func standard<U: View>(
|
||||||
|
onTap: (() -> Void)? = nil,
|
||||||
|
@ViewBuilder content: () -> U
|
||||||
|
) -> ListItem<U> {
|
||||||
|
ListItem<U>(onTap: onTap, content: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func compact<U: View>(
|
||||||
|
onTap: (() -> Void)? = nil,
|
||||||
|
@ViewBuilder content: () -> U
|
||||||
|
) -> ListItem<U> {
|
||||||
|
ListItem<U>(
|
||||||
|
padding: EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12),
|
||||||
|
cornerRadius: 8,
|
||||||
|
onTap: onTap,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func elevated<U: View>(
|
||||||
|
onTap: (() -> Void)? = nil,
|
||||||
|
@ViewBuilder content: () -> U
|
||||||
|
) -> ListItem<U> {
|
||||||
|
ListItem<U>(
|
||||||
|
shadowColor: .black.opacity(0.1),
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: CGSize(width: 0, height: 4),
|
||||||
|
onTap: onTap,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 空状态组件
|
||||||
|
struct EmptyStateView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let actionTitle: String?
|
||||||
|
let action: (() -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String,
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
actionTitle: String? = nil,
|
||||||
|
action: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.actionTitle = actionTitle
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
if let subtitle = subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let actionTitle = actionTitle, let action = action {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(actionTitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.blue.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 加载状态组件
|
||||||
|
struct LoadingStateView: View {
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
init(message: String = "加载中...") {
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 错误状态组件
|
||||||
|
struct ErrorStateView: View {
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
let retryAction: (() -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String = "出错了",
|
||||||
|
message: String = "加载失败,请重试",
|
||||||
|
retryAction: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.message = message
|
||||||
|
self.retryAction = retryAction
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let retryAction = retryAction {
|
||||||
|
Button(action: retryAction) {
|
||||||
|
Text("重试")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.blue.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// 列表视图
|
||||||
|
ListView(data: Array(1...5).map { ListItemData(id: $0, title: "项目 \($0)") }) { item in
|
||||||
|
ListItem<AnyView>.standard {
|
||||||
|
AnyView(
|
||||||
|
HStack {
|
||||||
|
Text(item.title)
|
||||||
|
.font(.body)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 300)
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "tray",
|
||||||
|
title: "暂无数据",
|
||||||
|
subtitle: "这里还没有任何内容",
|
||||||
|
actionTitle: "添加内容",
|
||||||
|
action: {}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
LoadingStateView(message: "正在加载数据...")
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
ErrorStateView(
|
||||||
|
title: "网络错误",
|
||||||
|
message: "无法连接到服务器,请检查网络连接",
|
||||||
|
retryAction: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 示例数据
|
||||||
|
struct ListItemData: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let title: String
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 简化的选择器组件
|
||||||
|
struct SimplePickerView<T: Hashable>: View {
|
||||||
|
let title: String
|
||||||
|
let isRequired: Bool
|
||||||
|
let selection: Binding<T>
|
||||||
|
let options: [T]
|
||||||
|
let optionTitle: (T) -> String
|
||||||
|
let icon: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
selection: Binding<T>,
|
||||||
|
options: [T],
|
||||||
|
optionTitle: @escaping (T) -> String,
|
||||||
|
icon: String? = nil
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.isRequired = isRequired
|
||||||
|
self.selection = selection
|
||||||
|
self.options = options
|
||||||
|
self.optionTitle = optionTitle
|
||||||
|
self.icon = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
InputTitleView.required(title, icon: icon)
|
||||||
|
|
||||||
|
Picker(title, selection: selection) {
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
Text(optionTitle(option)).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(.systemGray6))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 常用的选择器类型
|
||||||
|
extension SimplePickerView {
|
||||||
|
static func wifiEncryption(
|
||||||
|
selection: Binding<WiFiInputView.WiFiEncryptionType>,
|
||||||
|
icon: String? = "lock"
|
||||||
|
) -> SimplePickerView<WiFiInputView.WiFiEncryptionType> {
|
||||||
|
SimplePickerView<WiFiInputView.WiFiEncryptionType>(
|
||||||
|
title: "加密类型",
|
||||||
|
selection: selection,
|
||||||
|
options: WiFiInputView.WiFiEncryptionType.allCases,
|
||||||
|
optionTitle: { $0.displayName },
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func socialPlatform(
|
||||||
|
selection: Binding<SocialInputView.SocialPlatform>,
|
||||||
|
icon: String? = "globe"
|
||||||
|
) -> SimplePickerView<SocialInputView.SocialPlatform> {
|
||||||
|
SimplePickerView<SocialInputView.SocialPlatform>(
|
||||||
|
title: "社交平台",
|
||||||
|
selection: selection,
|
||||||
|
options: SocialInputView.SocialPlatform.allCases,
|
||||||
|
optionTitle: { $0.displayName },
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func phoneType(
|
||||||
|
selection: Binding<PhoneInputView.PhoneInputType>,
|
||||||
|
icon: String? = "phone"
|
||||||
|
) -> SimplePickerView<PhoneInputView.PhoneInputType> {
|
||||||
|
SimplePickerView<PhoneInputView.PhoneInputType>(
|
||||||
|
title: "电话类型",
|
||||||
|
selection: selection,
|
||||||
|
options: PhoneInputView.PhoneInputType.allCases,
|
||||||
|
optionTitle: { $0.displayName },
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// WiFi加密类型选择器
|
||||||
|
SimplePickerView<WiFiInputView.WiFiEncryptionType>.wifiEncryption(
|
||||||
|
selection: .constant(.wpa2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 社交平台选择器
|
||||||
|
SimplePickerView<SocialInputView.SocialPlatform>.socialPlatform(
|
||||||
|
selection: .constant(.instagram)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 电话类型选择器
|
||||||
|
SimplePickerView<PhoneInputView.PhoneInputType>.phoneType(
|
||||||
|
selection: .constant(.phone)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - QR码预览组件
|
||||||
|
struct QRCodePreviewView: View {
|
||||||
|
let qrCodeImage: UIImage?
|
||||||
|
let formattedContent: String
|
||||||
|
let qrCodeType: QRCodeType
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Text("预览")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二维码预览卡片
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// 二维码图片
|
||||||
|
if let qrImage = qrCodeImage {
|
||||||
|
Image(uiImage: qrImage)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.background(Color.white)
|
||||||
|
.cornerRadius(12)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
|
||||||
|
} else {
|
||||||
|
// 占位符
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.overlay(
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("无法生成二维码")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容预览卡片
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("内容")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(qrCodeType.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(formattedContent)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(nil)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
QRCodePreviewView(
|
||||||
|
qrCodeImage: nil,
|
||||||
|
formattedContent: "示例内容",
|
||||||
|
qrCodeType: .text
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,200 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用文本编辑器组件
|
||||||
|
struct TextEditorView: View {
|
||||||
|
let title: String
|
||||||
|
let isRequired: Bool
|
||||||
|
let placeholder: String
|
||||||
|
let text: Binding<String>
|
||||||
|
let maxCharacters: Int
|
||||||
|
let minHeight: CGFloat
|
||||||
|
let icon: String?
|
||||||
|
let isFocused: Bool
|
||||||
|
let onFocusChange: (Bool) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
maxCharacters: Int = 1000,
|
||||||
|
minHeight: CGFloat = 120,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.isRequired = isRequired
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.text = text
|
||||||
|
self.maxCharacters = maxCharacters
|
||||||
|
self.minHeight = minHeight
|
||||||
|
self.icon = icon
|
||||||
|
self.isFocused = isFocused
|
||||||
|
self.onFocusChange = onFocusChange
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
InputTitleView.required(title, icon: icon)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// 文本编辑器主体
|
||||||
|
TextEditor(text: text)
|
||||||
|
.frame(minHeight: minHeight)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(isFocused ? Color.blue : Color(.systemGray4), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.onChange(of: text.wrappedValue) { newValue in
|
||||||
|
// 限制最大字符数
|
||||||
|
if newValue.count > maxCharacters {
|
||||||
|
text.wrappedValue = String(newValue.prefix(maxCharacters))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
onFocusChange(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 占位符文本 - 左上角对齐
|
||||||
|
if text.wrappedValue.isEmpty && !isFocused {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Text(placeholder)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.body)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符计数
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("\(text.wrappedValue.count)/\(maxCharacters)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(getCharacterCountColor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCharacterCountColor() -> Color {
|
||||||
|
let count = text.wrappedValue.count
|
||||||
|
if count >= maxCharacters {
|
||||||
|
return .orange
|
||||||
|
} else if count >= Int(Double(maxCharacters) * 0.9) {
|
||||||
|
return .blue
|
||||||
|
} else {
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的文本编辑器样式
|
||||||
|
extension TextEditorView {
|
||||||
|
static func description(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
maxCharacters: Int = 500,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) -> TextEditorView {
|
||||||
|
TextEditorView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
placeholder: placeholder,
|
||||||
|
text: text,
|
||||||
|
maxCharacters: maxCharacters,
|
||||||
|
minHeight: 100,
|
||||||
|
icon: icon,
|
||||||
|
isFocused: isFocused,
|
||||||
|
onFocusChange: onFocusChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func longText(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
maxCharacters: Int = 1000,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) -> TextEditorView {
|
||||||
|
TextEditorView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
placeholder: placeholder,
|
||||||
|
text: text,
|
||||||
|
maxCharacters: maxCharacters,
|
||||||
|
minHeight: 150,
|
||||||
|
icon: icon,
|
||||||
|
isFocused: isFocused,
|
||||||
|
onFocusChange: onFocusChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func emailBody(
|
||||||
|
title: String,
|
||||||
|
isRequired: Bool = false,
|
||||||
|
placeholder: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
maxCharacters: Int = 1200,
|
||||||
|
icon: String? = nil,
|
||||||
|
isFocused: Bool = false,
|
||||||
|
onFocusChange: @escaping (Bool) -> Void = { _ in }
|
||||||
|
) -> TextEditorView {
|
||||||
|
TextEditorView(
|
||||||
|
title: title,
|
||||||
|
isRequired: isRequired,
|
||||||
|
placeholder: placeholder,
|
||||||
|
text: text,
|
||||||
|
maxCharacters: maxCharacters,
|
||||||
|
minHeight: 120,
|
||||||
|
icon: icon,
|
||||||
|
isFocused: isFocused,
|
||||||
|
onFocusChange: onFocusChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
TextEditorView.description(
|
||||||
|
title: "描述",
|
||||||
|
isRequired: true,
|
||||||
|
placeholder: "请输入描述内容...",
|
||||||
|
text: .constant(""),
|
||||||
|
icon: "text.quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
TextEditorView.longText(
|
||||||
|
title: "长文本",
|
||||||
|
isRequired: false,
|
||||||
|
placeholder: "请输入长文本内容...",
|
||||||
|
text: .constant(""),
|
||||||
|
icon: "doc.text"
|
||||||
|
)
|
||||||
|
|
||||||
|
TextEditorView.emailBody(
|
||||||
|
title: "邮件正文",
|
||||||
|
isRequired: true,
|
||||||
|
placeholder: "输入邮件正文内容...",
|
||||||
|
text: .constant(""),
|
||||||
|
icon: "envelope"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,110 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用文本输入组件
|
||||||
|
struct TextInputView: View {
|
||||||
|
@Binding var content: String
|
||||||
|
@FocusState var isContentFieldFocused: Bool
|
||||||
|
let placeholder: String
|
||||||
|
let maxCharacters: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ZStack {
|
||||||
|
// 输入框主体
|
||||||
|
TextEditor(text: $content)
|
||||||
|
.frame(minHeight: 120)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(isContentFieldFocused ? Color.blue : Color(.systemGray4), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.focused($isContentFieldFocused)
|
||||||
|
.onChange(of: content) { newValue in
|
||||||
|
// 限制最大字符数
|
||||||
|
if newValue.count > maxCharacters {
|
||||||
|
content = String(newValue.prefix(maxCharacters))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
Spacer()
|
||||||
|
Button("完成") {
|
||||||
|
isContentFieldFocused = false
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 占位符文本 - 左上角对齐
|
||||||
|
if content.isEmpty && !isContentFieldFocused {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Text(placeholder)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.body)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符计数和限制提示 - 输入框底下
|
||||||
|
HStack {
|
||||||
|
Spacer() // Pushes content to the right
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
// 字符限制提示
|
||||||
|
if content.count >= maxCharacters {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text("已达到最大字符数")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
} else if content.count >= Int(Double(maxCharacters) * 0.93) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text("接近字符限制")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符计数
|
||||||
|
Text("\(content.count)/\(maxCharacters)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(getCharacterCountColor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCharacterCountColor() -> Color {
|
||||||
|
if content.count >= maxCharacters {
|
||||||
|
return .orange
|
||||||
|
} else if content.count >= Int(Double(maxCharacters) * 0.93) {
|
||||||
|
return .blue
|
||||||
|
} else {
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TextInputView(
|
||||||
|
content: .constant(""),
|
||||||
|
placeholder: "输入任意文本内容...",
|
||||||
|
maxCharacters: 150
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,237 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用验证组件
|
||||||
|
struct ValidationView: View {
|
||||||
|
let validationState: ValidationState
|
||||||
|
let message: String?
|
||||||
|
let showIcon: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
validationState: ValidationState,
|
||||||
|
message: String? = nil,
|
||||||
|
showIcon: Bool = true
|
||||||
|
) {
|
||||||
|
self.validationState = validationState
|
||||||
|
self.message = message
|
||||||
|
self.showIcon = showIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if validationState != .none {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if showIcon {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(iconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = message {
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(backgroundColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
switch validationState {
|
||||||
|
case .none:
|
||||||
|
return ""
|
||||||
|
case .valid:
|
||||||
|
return "checkmark.circle"
|
||||||
|
case .warning:
|
||||||
|
return "exclamationmark.triangle"
|
||||||
|
case .error:
|
||||||
|
return "xmark.circle"
|
||||||
|
case .info:
|
||||||
|
return "info.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconColor: Color {
|
||||||
|
switch validationState {
|
||||||
|
case .none:
|
||||||
|
return .clear
|
||||||
|
case .valid:
|
||||||
|
return .green
|
||||||
|
case .warning:
|
||||||
|
return .orange
|
||||||
|
case .error:
|
||||||
|
return .red
|
||||||
|
case .info:
|
||||||
|
return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textColor: Color {
|
||||||
|
switch validationState {
|
||||||
|
case .none:
|
||||||
|
return .clear
|
||||||
|
case .valid:
|
||||||
|
return .green
|
||||||
|
case .warning:
|
||||||
|
return .orange
|
||||||
|
case .error:
|
||||||
|
return .red
|
||||||
|
case .info:
|
||||||
|
return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
switch validationState {
|
||||||
|
case .none:
|
||||||
|
return .clear
|
||||||
|
case .valid:
|
||||||
|
return .green.opacity(0.1)
|
||||||
|
case .warning:
|
||||||
|
return .orange.opacity(0.1)
|
||||||
|
case .error:
|
||||||
|
return .red.opacity(0.1)
|
||||||
|
case .info:
|
||||||
|
return .blue.opacity(0.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 验证状态枚举
|
||||||
|
enum ValidationState {
|
||||||
|
case none
|
||||||
|
case valid
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
case info
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预定义的验证样式
|
||||||
|
extension ValidationView {
|
||||||
|
static func success(message: String, showIcon: Bool = true) -> ValidationView {
|
||||||
|
ValidationView(
|
||||||
|
validationState: .valid,
|
||||||
|
message: message,
|
||||||
|
showIcon: showIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func warning(message: String, showIcon: Bool = true) -> ValidationView {
|
||||||
|
ValidationView(
|
||||||
|
validationState: .warning,
|
||||||
|
message: message,
|
||||||
|
showIcon: showIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func error(message: String, showIcon: Bool = true) -> ValidationView {
|
||||||
|
ValidationView(
|
||||||
|
validationState: .error,
|
||||||
|
message: message,
|
||||||
|
showIcon: showIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func info(message: String, showIcon: Bool = true) -> ValidationView {
|
||||||
|
ValidationView(
|
||||||
|
validationState: .info,
|
||||||
|
message: message,
|
||||||
|
showIcon: showIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 字符计数验证
|
||||||
|
struct CharacterCountValidation: View {
|
||||||
|
let currentCount: Int
|
||||||
|
let maxCount: Int
|
||||||
|
let warningThreshold: Double
|
||||||
|
|
||||||
|
init(
|
||||||
|
currentCount: Int,
|
||||||
|
maxCount: Int,
|
||||||
|
warningThreshold: Double = 0.9
|
||||||
|
) {
|
||||||
|
self.currentCount = currentCount
|
||||||
|
self.maxCount = maxCount
|
||||||
|
self.warningThreshold = warningThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if currentCount >= maxCount {
|
||||||
|
ValidationView.error(message: "已达到最大字符数")
|
||||||
|
} else if currentCount >= Int(Double(maxCount) * warningThreshold) {
|
||||||
|
ValidationView.warning(message: "接近字符限制")
|
||||||
|
} else {
|
||||||
|
Text("\(currentCount)/\(maxCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 必填字段验证
|
||||||
|
struct RequiredFieldValidation: View {
|
||||||
|
let fieldName: String
|
||||||
|
let isEmpty: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if isEmpty {
|
||||||
|
ValidationView.error(message: "\(fieldName)为必填项")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 格式验证
|
||||||
|
struct FormatValidation: View {
|
||||||
|
let fieldName: String
|
||||||
|
let isValid: Bool
|
||||||
|
let errorMessage: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
fieldName: String,
|
||||||
|
isValid: Bool,
|
||||||
|
errorMessage: String? = nil
|
||||||
|
) {
|
||||||
|
self.fieldName = fieldName
|
||||||
|
self.isValid = isValid
|
||||||
|
self.errorMessage = errorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !isValid {
|
||||||
|
ValidationView.error(
|
||||||
|
message: errorMessage ?? "\(fieldName)格式不正确"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ValidationView.success(message: "验证成功!")
|
||||||
|
ValidationView.warning(message: "这是一个警告")
|
||||||
|
ValidationView.error(message: "这是一个错误")
|
||||||
|
ValidationView.info(message: "这是一个提示")
|
||||||
|
|
||||||
|
CharacterCountValidation(currentCount: 95, maxCount: 100)
|
||||||
|
CharacterCountValidation(currentCount: 100, maxCount: 100)
|
||||||
|
|
||||||
|
RequiredFieldValidation(fieldName: "用户名", isEmpty: true)
|
||||||
|
RequiredFieldValidation(fieldName: "邮箱", isEmpty: false)
|
||||||
|
|
||||||
|
FormatValidation(fieldName: "邮箱地址", isValid: false)
|
||||||
|
FormatValidation(fieldName: "电话号码", isValid: true)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
@ -0,0 +1,128 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - WiFi输入组件
|
||||||
|
struct WiFiInputView: View {
|
||||||
|
@Binding var ssid: String
|
||||||
|
@Binding var password: String
|
||||||
|
@Binding var encryptionType: WiFiEncryptionType
|
||||||
|
@FocusState var focusedField: WiFiField?
|
||||||
|
|
||||||
|
// WiFi字段枚举
|
||||||
|
enum WiFiField: Hashable {
|
||||||
|
case ssid, password
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFi加密类型
|
||||||
|
enum WiFiEncryptionType: String, CaseIterable {
|
||||||
|
case none = "None"
|
||||||
|
case wep = "WEP"
|
||||||
|
case wpa = "WPA"
|
||||||
|
case wpa2 = "WPA2"
|
||||||
|
case wpa3 = "WPA3"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "无加密"
|
||||||
|
case .wep: return "WEP"
|
||||||
|
case .wpa: return "WPA"
|
||||||
|
case .wpa2: return "WPA2"
|
||||||
|
case .wpa3: return "WPA3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// SSID (必填)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("网络名称 (SSID)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("*")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("MyWiFi", text: $ssid)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .ssid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("密码")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureField("WiFi密码", text: $password)
|
||||||
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密类型
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("加密类型")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("加密类型", selection: $encryptionType) {
|
||||||
|
ForEach(WiFiEncryptionType.allCases, id: \.self) { type in
|
||||||
|
Text(type.displayName).tag(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式说明
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Text("格式说明")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("• 网络名称(SSID)为必填项\n• 密码为可选项,无加密时可留空\n• 将生成标准WiFi连接格式")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(nil)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.blue.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
Spacer()
|
||||||
|
Button("完成") {
|
||||||
|
focusedField = nil
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
WiFiInputView(
|
||||||
|
ssid: .constant(""),
|
||||||
|
password: .constant(""),
|
||||||
|
encryptionType: .constant(.wpa2)
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in new issue