import SwiftUI public struct AICard: View { private let content: Content private let padding: CGFloat private let emphasis: Bool public init( padding: CGFloat = 16, emphasis: Bool = false, @ViewBuilder content: () -> Content ) { self.padding = padding self.emphasis = emphasis self.content = content() } public var body: some View { content .frame(maxWidth: .infinity, alignment: .leading) .padding(padding) .background( AICard.backgroundGradient( raisedTop: emphasis ) ) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)) .shadow(color: AITheme.cardShadow, radius: 14, x: 0, y: 6) .overlay { RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous) .strokeBorder(AITheme.line) } } } extension AICard { fileprivate static func backgroundGradient(raisedTop: Bool) -> LinearGradient { LinearGradient( colors: raisedTop ? [AITheme.surfaceRaised, AITheme.surfaceRaised.opacity(0.94)] : [AITheme.surfaceRaised, AITheme.surface], startPoint: .top, endPoint: .bottom ) } } public struct AITag: View { private let text: String private let tint: Color? private let systemImage: String? /// 不传 `tint` 时是中性归类标签(标签、方向这类不带健康含义的信息), /// 传了 `tint` 才是状态标签,用同色色雾与描边。颜色只留给后者。 public init(_ text: String, tint: Color? = nil, systemImage: String? = nil) { self.text = text self.tint = tint self.systemImage = systemImage } public var body: some View { let tint = self.tint HStack(spacing: 4) { if let systemImage { Image(systemName: systemImage) .font(.system(size: 10, weight: .semibold)) .accessibilityHidden(true) } Text(text) .font(.caption2.weight(.semibold)) .lineLimit(1) .truncationMode(.middle) } .padding(.horizontal, 10) // 与节点卡片上的标签胶囊同一形状,同一高度;深色底上 10% 色雾几乎看不见, // 所以描边权重必须够,不能让标签在暗色主题里消失。 .frame(height: 28) .foregroundStyle(tint == nil ? AITheme.ink : tint!) .background(tint.map { $0.opacity(0.14) } ?? AITheme.tile) .clipShape(Capsule(style: .continuous)) .overlay { Capsule(style: .continuous) .strokeBorder(tint.map { $0.opacity(0.30) } ?? AITheme.tileLine) } .accessibilityLabel(text) } } public struct AISectionTitle: View { private let title: String private let systemImage: String private let detail: String? private let footer: Footer public init( _ title: String, systemImage: String, detail: String? = nil, actionTitle: String? = nil, actionSystemImage: String? = nil, actionKind: AIButtonStyle.Kind = .secondary, action: (() -> Void)? = nil ) { self.title = title self.systemImage = systemImage self.detail = detail self.footer = Footer( title: actionTitle, systemImage: actionSystemImage, kind: actionKind, action: action ) } private struct Footer: View { let title: String? let systemImage: String? let kind: AIButtonStyle.Kind let action: (() -> Void)? var body: some View { if let title, let action { Button { action() } label: { if let systemImage { Label(title, systemImage: systemImage) } else { Text(title) } } .buttonStyle(AIButtonStyle(kind: kind, size: .compact)) } } } public var body: some View { HStack(spacing: 8) { Image(systemName: systemImage) .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.primary) .frame(width: 22, height: 22) .accessibilityHidden(true) Text(title) .font(.headline.weight(.semibold)) .foregroundStyle(AITheme.ink) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) if let detail { Text(detail) .font(.caption.weight(.medium)) .foregroundStyle(AITheme.muted) .lineLimit(1) } if Footer.self != EmptyView.self { footer } } } } public struct AIFieldLabel: View { private let title: String public init(_ title: String) { self.title = title } public var body: some View { Text(title) .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.ink) } } extension View { /// 卡片内的二级面板:极浅底色 + 更浅的描边,只做"分组",不抢主信息的对比度。 func aiInsetSurface(cornerRadius: CGFloat = 12) -> some View { self .background(AITheme.tile) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .strokeBorder(AITheme.tileLine) } } } /// 带语义色的内嵌指标块:底色/描边都由 tint 决定,浅色下是一层很淡的色洗。 struct AITintTile: View { private let tint: Color private let content: Content init(tint: Color, @ViewBuilder content: () -> Content) { self.tint = tint self.content = content() } var body: some View { content .frame(maxWidth: .infinity, alignment: .leading) .background(tint.opacity(0.09)) .clipShape(RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous) .strokeBorder(tint.opacity(0.20)) } } } public struct AIStatCard: View { private let title: String private let value: String private let detail: String? private let tint: Color private let systemImage: String? public init( title: String, value: String, detail: String? = nil, systemImage: String? = nil, tint: Color ) { self.title = title self.value = value self.detail = detail self.systemImage = systemImage self.tint = tint } public var body: some View { // 卡内不再叠一张卡:双层阴影 + 双层描边是浅色发灰的主因。 HStack(alignment: .center, spacing: 10) { if let systemImage { Image(systemName: systemImage) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(tint) .frame(width: 30, height: 30) .background(tint.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 2) { Text(title) .font(.caption2.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.85) .foregroundStyle(AITheme.muted) Text(value) .font(.headline.weight(.semibold)) .monospacedDigit() .foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint) .lineLimit(1) .minimumScaleFactor(0.75) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .aiInsetSurface(cornerRadius: AITheme.tileRadius) .accessibilityElement(children: .combine) } } public struct AIStatusBanner: View { private let message: String private let tint: Color private let systemImage: String public init(_ message: String, tint: Color, systemImage: String) { self.message = message self.tint = tint self.systemImage = systemImage } public var body: some View { HStack(alignment: .top, spacing: 10) { Image(systemName: systemImage) .font(.subheadline.weight(.semibold)) .foregroundStyle(tint) .accessibilityHidden(true) Text(message) .font(.subheadline.weight(.medium)) .foregroundStyle(tint) .frame(maxWidth: .infinity, alignment: .leading) } .padding(15) .background(tint.opacity(0.10)) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous) .strokeBorder(tint.opacity(0.22)) } .accessibilityElement(children: .combine) } } public struct AIEmptyState: View { private let systemImage: String private let title: String private let message: String private let actionTitle: String? private let actionSystemImage: String private let action: (() -> Void)? public init(systemImage: String, title: String, message: String) { self.systemImage = systemImage self.title = title self.message = message self.actionTitle = nil self.actionSystemImage = "arrow.clockwise" self.action = nil } /// 失败/空态带一个就地重试入口,避免"红色横幅 + 暂无数据"互相矛盾。 public init( systemImage: String, title: String, message: String, actionTitle: String, actionSystemImage: String = "arrow.clockwise", action: @escaping () -> Void ) { self.systemImage = systemImage self.title = title self.message = message self.actionTitle = actionTitle self.actionSystemImage = actionSystemImage self.action = action } public var body: some View { VStack(spacing: 10) { Image(systemName: systemImage) .font(.system(size: 27, weight: .medium)) .foregroundStyle(AITheme.primary) .frame(width: 54, height: 54) .background(AITheme.primary.opacity(0.10)) .clipShape(Circle()) .accessibilityHidden(true) Text(title) .font(.headline.weight(.semibold)) .foregroundStyle(AITheme.ink) Text(message) .font(.subheadline) .foregroundStyle(AITheme.muted) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) if let actionTitle, let action { Button(action: action) { Label(actionTitle, systemImage: actionSystemImage) } .buttonStyle(AIButtonStyle(kind: .secondary, size: .compact)) .padding(.top, 4) } } .frame(maxWidth: .infinity) .padding(.vertical, 16) .accessibilityElement(children: .combine) } } public struct AIListCard: View { private let title: String private let subtitle: String? private let status: String? private let statusTint: Color? private let details: [String] private let footer: Footer public init( title: String, subtitle: String? = nil, status: String? = nil, statusTint: Color? = nil, details: [String] = [], @ViewBuilder footer: () -> Footer = { EmptyView() } ) { self.title = title self.subtitle = subtitle self.status = status self.statusTint = statusTint self.details = details self.footer = footer() } public var body: some View { AICard { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 10) { Text(title) .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.ink) .lineLimit(2) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .leading) if let status { AITag(status, tint: statusTint ?? AITheme.muted) } } if let subtitle { Text(subtitle) .font(.caption) .foregroundStyle(AITheme.muted) .lineLimit(2) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .leading) } if !details.isEmpty { VStack(alignment: .leading, spacing: 4) { ForEach(details, id: \.self) { detail in Text(detail) .font(.caption) .foregroundStyle(AITheme.muted) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } } } if Footer.self != EmptyView.self { footer } } } } } public struct AILoadingCard: View { private let message: String public init(_ message: String) { self.message = message } public var body: some View { VStack(spacing: 10) { ProgressView() .controlSize(.regular) .frame(width: 44, height: 44) .background(AITheme.control) .clipShape(Circle()) Text(message) .font(.subheadline) .foregroundStyle(AITheme.muted) } .frame(maxWidth: .infinity) .padding(16) .background( LinearGradient( colors: [AITheme.surfaceRaised, AITheme.surface], startPoint: .top, endPoint: .bottom ) ) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)) .shadow(color: AITheme.brand.opacity(0.08), radius: 20, x: 0, y: 10) .overlay { RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous) .strokeBorder(AITheme.line) } .accessibilityElement(children: .combine) } } public struct AIActionRow: View { private let spacing: CGFloat private let content: Content public init( spacing: CGFloat = 10, @ViewBuilder content: () -> Content ) { self.spacing = spacing self.content = content() } public var body: some View { AIEqualWidthLayout(spacing: spacing) { content } } } private struct AIEqualWidthLayout: Layout { let spacing: CGFloat func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) -> CGSize { guard !subviews.isEmpty else { return .zero } let count = subviews.count let width: CGFloat if let proposedWidth = proposal.width { width = proposedWidth } else { let idealWidths = subviews.map { $0.sizeThatFits(.unspecified).width } width = idealWidths.reduce(0, +) + spacing * CGFloat(count - 1) } let equalWidth = max(0, (width - spacing * CGFloat(count - 1)) / CGFloat(count)) let height = subviews.map { $0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height }.max() ?? 0 return CGSize(width: width, height: height) } func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) { guard !subviews.isEmpty else { return } let equalWidth = max( 0, (bounds.width - spacing * CGFloat(subviews.count - 1)) / CGFloat(subviews.count) ) var x = bounds.minX for subview in subviews { let size = subview.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)) subview.place( at: CGPoint(x: x, y: bounds.midY - size.height / 2), proposal: ProposedViewSize(width: equalWidth, height: size.height) ) x += equalWidth + spacing } } } public struct AIKeyValueRow: View { private let title: String private let value: String private let valueTint: Color private let monospaced: Bool public init(_ title: String, value: String, tint: Color = AITheme.ink, monospaced: Bool = false) { self.title = title self.value = value self.valueTint = tint self.monospaced = monospaced } public var body: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(title) .font(.subheadline) .foregroundStyle(AITheme.muted) .lineLimit(2) .layoutPriority(1) Spacer(minLength: 12) Text(value) .font( monospaced ? Font.system(.subheadline, design: .monospaced, weight: .medium) : Font.subheadline.weight(.medium) ) .monospacedDigit() .foregroundStyle(valueTint) .multilineTextAlignment(.trailing) .lineLimit(2) .truncationMode(.middle) .minimumScaleFactor(0.85) .textSelection(.enabled) .layoutPriority(2) } .accessibilityElement(children: .combine) } } public struct AISegmentedControl: View { private let title: String private let options: [(value: Value, title: String)] @Binding private var selection: Value public init( _ title: String, selection: Binding, options: [(value: Value, title: String)] ) { self.title = title self._selection = selection self.options = options } public var body: some View { HStack(spacing: 3) { ForEach(options, id: \.value) { option in let isSelected = selection == option.value Button { selection = option.value } label: { Text(option.title) .font(.subheadline.weight(isSelected ? .semibold : .medium)) .lineLimit(1) .minimumScaleFactor(0.75) .foregroundStyle(isSelected ? AITheme.ink : AITheme.muted) .frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight) // 分段控件是"视图切换"而不是主操作:抬起的中性块,避免和标签筛选抢注意力。 .background { RoundedRectangle(cornerRadius: 11, style: .continuous) .fill(isSelected ? AITheme.segmentSelected : Color.clear) .shadow(color: isSelected ? AITheme.cardShadow : .clear, radius: 3, x: 0, y: 1) } } .buttonStyle(.plain) .contentShape(Rectangle()) .accessibilityLabel(option.title) .accessibilityAddTraits(isSelected ? .isSelected : []) } } .padding(3) .background(AITheme.segmentTrack) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(AITheme.tileLine) } .animation(.easeOut(duration: 0.16), value: selection) .accessibilityElement(children: .contain) .accessibilityLabel(title) } } public struct AIFieldStyle: TextFieldStyle { @FocusState private var isFocused: Bool public init() {} public func _body(configuration: TextField) -> some View { configuration .textFieldStyle(.plain) .focused($isFocused) .aiInputSurface() } } public struct AISearchField: View { private let placeholder: String @Binding private var text: String private var onSubmit: (() -> Void)? private let minHeight: CGFloat @FocusState private var isFocused: Bool public init( _ placeholder: String, text: Binding, minHeight: CGFloat = AITheme.buttonHeight, onSubmit: (() -> Void)? = nil ) { self.placeholder = placeholder self._text = text self.minHeight = minHeight self.onSubmit = onSubmit } public var body: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: 15, weight: .medium)) .foregroundStyle(AITheme.muted) .accessibilityHidden(true) TextField(placeholder, text: $text) .textFieldStyle(.plain) .focused($isFocused) .submitLabel(.search) .onSubmit { onSubmit?() } if !text.isEmpty { Button { text = "" onSubmit?() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(AITheme.muted) .font(.system(size: 16, weight: .medium)) } .buttonStyle(.plain) .frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize) .contentShape(Rectangle()) .accessibilityLabel("清除搜索") } } .aiInputSurface(minHeight: minHeight, isFocused: isFocused) } } extension View { func aiInputSurface( minHeight: CGFloat = AITheme.inputHeight, isFocused: Bool = false ) -> some View { self .padding(.horizontal, 12) .frame(minHeight: minHeight, alignment: .leading) .background(isFocused ? AITheme.surfaceRaised : AITheme.control) .clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous) .strokeBorder(isFocused ? AITheme.primary.opacity(0.55) : AITheme.line) } .animation(.easeOut(duration: 0.15), value: isFocused) } func aiBottomActionBar(@ViewBuilder content: () -> Content) -> some View { safeAreaInset(edge: .bottom, spacing: 0) { content() .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 10) .background { ZStack { Rectangle() .fill(.ultraThinMaterial) Rectangle() .fill(AITheme.surface.opacity(0.98)) } .ignoresSafeArea(edges: .bottom) } .overlay(alignment: .top) { Rectangle() .fill(AITheme.line) .frame(height: 1) } } } func aiSelectSurface(compact: Bool = false) -> some View { self .tint(AITheme.ink) .font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold)) .foregroundStyle(AITheme.ink) .labelsHidden() .frame(maxWidth: .infinity, alignment: .leading) .aiInputSurface(minHeight: compact ? AITheme.compactControlHeight : AITheme.inputHeight) } } public struct AIToolbarButton: View { private let title: String private let systemImage: String? private let kind: AIButtonStyle.Kind private let action: () -> Void public init( _ title: String, systemImage: String? = nil, kind: AIButtonStyle.Kind = .secondary, action: @escaping () -> Void ) { self.title = title self.systemImage = systemImage self.kind = kind self.action = action } public var body: some View { Button { action() } label: { if let systemImage { Label(title, systemImage: systemImage) .labelStyle(.titleAndIcon) .font(.system(size: 15, weight: .semibold)) } else { Text(title) .font(.system(size: 15, weight: .semibold)) } } .buttonStyle(AIButtonStyle(kind: kind, size: .toolbarInline)) .accessibilityLabel(title) } } public struct AIIconButton: View { private let title: String private let systemImage: String private let kind: AIButtonStyle.Kind private let action: () -> Void public init( _ title: String, systemImage: String, kind: AIButtonStyle.Kind = .secondary, action: @escaping () -> Void ) { self.title = title self.systemImage = systemImage self.kind = kind self.action = action } public var body: some View { Button { action() } label: { Image(systemName: systemImage) .font(.system(size: 16, weight: .semibold)) } .buttonStyle(AIButtonStyle(kind: kind, size: .icon)) .accessibilityLabel(title) } } /// 列表行内的轻量图标动作:同色淡底小方块,触区仍补足 44pt。 /// 行内操作用实心药丸会把一行读成"按钮墙",这里让颜色只表达危险程度。 public struct AISymbolAction: View { private let title: String private let systemImage: String private let tint: Color /// 安静样式:只留着色图标与点击区,不再铺色块。 /// 卡片列表里每行都放一块彩色方块会把整页染成"警告",删除这类低频动作更适合安静样式。 private let isQuiet: Bool private let action: () -> Void public init( _ title: String, systemImage: String, tint: Color = AITheme.muted, quiet: Bool = false, action: @escaping () -> Void ) { self.title = title self.systemImage = systemImage self.tint = tint self.isQuiet = quiet self.action = action } public var body: some View { Button { action() } label: { Image(systemName: systemImage) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(tint) .frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize) .background(isQuiet ? Color.clear : tint.opacity(0.10)) .clipShape(RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous)) .overlay { if isQuiet == false { RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous) .strokeBorder(tint.opacity(0.18)) } } .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(title) } } /// 按固有尺寸换行排布的流式布局:标签胶囊这类「宽度取决于文字」的控件用它。 /// 栅格会把每枚胶囊拉到同一列宽,短标签右侧就空出一片,读起来像没加载完。 public struct AIFlowLayout: Layout { public let spacing: CGFloat public init(spacing: CGFloat = 8) { self.spacing = spacing } public func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) -> CGSize { let maxWidth = proposal.width ?? .infinity var x: CGFloat = 0 var y: CGFloat = 0 var rowHeight: CGFloat = 0 var widestRow: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x > 0, x + size.width > maxWidth { widestRow = max(widestRow, x - spacing) y += rowHeight + spacing x = 0 rowHeight = 0 } x += size.width + spacing rowHeight = max(rowHeight, size.height) } widestRow = max(widestRow, x - spacing) return CGSize(width: max(0, widestRow), height: max(0, y + rowHeight)) } public func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) { var x = bounds.minX var y = bounds.minY var rowHeight: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x > bounds.minX, x + size.width > bounds.maxX { x = bounds.minX y += rowHeight + spacing rowHeight = 0 } subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified) x += size.width + spacing rowHeight = max(rowHeight, size.height) } } }