SwiftUI 2026-04-10

  • ObservableObject 是类型能力,@StateObject 是存储方式
    @StateObject 保证只初始化一次,普通 var 或 @ObservedObject 可能被反复重建
    只有 class 才能用 ObservableObject,struct 不行
    视图内部自己管理的数据源 → 必须用 @StateObject

view 里面可以用@ObservedObject 、@StateObject。
@StateObject 用来创建管理 对象,比如ObservableObject 类型对象;@ObservedObject 是用来外部传递的

  • 总结
    ObservableObject 是一个协议,用来让一个类能被 SwiftUI 监听数据变化。
    @StateObject 是一个属性包装器,用来创建并持有一个遵循 ObservableObject 的实例,保证它只初始化一次、不会被视图重建销毁。
    @StateObject:自己创建对象 → 管生命周期
    @ObservedObject:外部传入对象 → 不管生命周期

@Published —— 发布属性只要这个值改变,所有用到它的 UI 自动刷新,相当于「广播」:一变全变
@MainActor —— 主线程安全,强制所有 UI 操作在主线程执行,防止崩溃,SwiftUI 现代项目必备:可以用在vm类,观察类,某个方法,以及闭包/异步任务,

凡是跟 UI 有关的:View、ViewModel、ObservableObject,都加上 @MainActor。用来替代 DispatchQueue.main.async;

@EnvironmentObject - 全局变量,可以跨层级传递数据,通常在 App 结构或 ContentView 最外层 注入(.environmentObject(xxxx)),在任意子视图使用 @EnvironmentObject 获取

@main
struct MyApp: App {
    // 创建实例
    let userData = UserData()
    var body: some Scene {
        WindowGroup {
            ContentView()
                // 注入环境
                .environmentObject(userData)
        }
    }
}

struct ContentView: View {
    // 直接获取,不需要传参
    @EnvironmentObject var userData: UserData
    var body: some View {
        VStack {
            Text("Name: \(userData.name)")
            Text("Age: \(userData.age)")

            Button("Change Name") {
                userData.name = "Jerry"
            }
        }
    }
}
  • @Environment(.dismiss) private var dismiss

你用 @Environment(.xxx) 就是去环境里取东西。从系统环境里,拿一个 “关闭当前页面” 的功能,存到 dismiss 这个变量里

  • 异步任务:.task 页面显示时自动加载数据,支持 async/await,页面消失自动取消任务
import SwiftUI

@MainActor
final class SelectExamIntentViewModel: ObservableObject {
    @Published var selectedExamID: String?
    @Published var selectedSubjectID: String?
    @Published var selectedSubjectIDs: [String] = []
    @Published var sections: [ExamIntentSection] = []
    @Published var showSubjectEditor = false
    @Published var title = ""
    @Published var subtitle = ""
    @Published var categoryId = ""
    @Published var secondCategoryId = ""
    @Published var boxId = ""
    @Published var techId = ""
    @Published var isLoadingOptions = false
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var infoMessage: String?
    @Published var showAdvancedEditor = false

    private let store: ExamContextStore
    private let service: any ExamContextServiceProtocol
    private var hasLoadedOptions = false
    private var persistedContexts: [ExerciseContext] = []

    init(store: ExamContextStore, service: any ExamContextServiceProtocol) {
        self.store = store
        self.service = service
        applyExistingContexts(store.contexts)
    }

    var selectedExam: ExamIntentOption? {
        sections.flatMap(\.items).first(where: { $0.id == selectedExamID })
    }

    var examOptions: [ExamIntentOption] {
        sections.flatMap(\.items)
    }

    var allSubjects: [ExamSubjectOption] {
        selectedExam?.subjects ?? []
    }

    var selectedSubject: ExamSubjectOption? {
        allSubjects.first(where: { $0.id == selectedSubjectID })
    }

    var managedSubjects: [ExamSubjectOption] {
        selectedSubjectIDs.compactMap { subjectID in
            allSubjects.first(where: { $0.id == subjectID })
        }
    }

    var selectedContextPreview: ExerciseContext? {
        guard
            let selectedExam,
            let selectedSubject
        else {
            return nil
        }

        return selectedSubject.context(exam: selectedExam)
    }

    func loadOptionsIfNeeded() async {
        guard !hasLoadedOptions else { return }
        hasLoadedOptions = true
        isLoadingOptions = true

        defer {
            isLoadingOptions = false
        }

        do {
            let loadedSections = try await service.fetchExamIntentSections()
            sections = loadedSections.isEmpty ? ExamIntentSection.mockSections : loadedSections
            syncSelectionFromCurrentFields()
        } catch {
            sections = ExamIntentSection.mockSections
            errorMessage = "考试分类加载失败,已切换到内置列表:\(error.localizedDescription)"
            syncSelectionFromCurrentFields()
        }
    }

    func selectExam(_ option: ExamIntentOption) {
        selectedExamID = option.id
        title = option.title
        secondCategoryId = String(option.secondCategoryId)
        syncSelectionFromCurrentFields()
    }

    func chooseExamAndEditSubjects(_ option: ExamIntentOption) {
        selectExam(option)
        showSubjectEditor = true
    }

    func toggleManagedSubject(_ subject: ExamSubjectOption) {
        if let index = selectedSubjectIDs.firstIndex(of: subject.id) {
            selectedSubjectIDs.remove(at: index)

            if selectedSubjectID == subject.id {
                selectedSubjectID = selectedSubjectIDs.last
            }
        } else {
            selectedSubjectIDs.append(subject.id)
            selectedSubjectID = subject.id
        }

        if let activeSubject = selectedSubject {
            applySubject(activeSubject)
        } else {
            clearSubjectSelection()
        }
    }

    func saveSelection() async {
        guard let selectedExam else {
            errorMessage = "请先选择考试"
            return
        }

        guard !managedSubjects.isEmpty else {
            errorMessage = "请至少选择一个科目"
            return
        }

        let contexts = managedSubjects.map { $0.context(exam: selectedExam) }
        let primaryContext = selectedSubject?.context(exam: selectedExam) ?? contexts.first

        isLoading = true
        errorMessage = nil
        infoMessage = nil

        store.save(contexts, primary: primaryContext)

        do {
            try await service.saveSelectedCategory(secondCategoryId: selectedExam.secondCategoryId)
            infoMessage = "考试意向已同步,已保存 \(managedSubjects.count) 个科目"
        } catch {
            infoMessage = "已保存本地考试意向,服务端同步失败:\(error.localizedDescription)"
        }

        isLoading = false
        store.dismissSelection()
    }

    private func applyExistingContexts(_ contexts: [ExerciseContext]) {
        persistedContexts = contexts

        guard let primaryContext = contexts.first else { return }
        title = primaryContext.groupName ?? primaryContext.categoryName ?? ""
        subtitle = primaryContext.categoryName ?? ""
        categoryId = primaryContext.categoryId.map(String.init) ?? ""
        secondCategoryId = String(primaryContext.secondCategoryId)
        boxId = primaryContext.boxId.map(String.init) ?? ""
        techId = primaryContext.techId.map(String.init) ?? ""
    }

    private func syncSelectionFromCurrentFields() {
        if let matchedExam = examOptions.first(where: { String($0.secondCategoryId) == secondCategoryId }) {
            selectedExamID = matchedExam.id

            let matchedSubjectIDs = matchedExam.subjects.compactMap { subject in
                persistedContexts.contains(where: {
                    $0.secondCategoryId == matchedExam.secondCategoryId && $0.categoryId == subject.categoryId
                }) ? subject.id : nil
            }

            if matchedSubjectIDs.isEmpty,
               let matchedSubject = matchedExam.subjects.first(where: { String($0.categoryId) == categoryId }) {
                selectedSubjectIDs = [matchedSubject.id]
                selectedSubjectID = matchedSubject.id
            } else {
                selectedSubjectIDs = matchedSubjectIDs
                selectedSubjectID = selectedSubjectIDs.last
            }
        } else {
            selectedSubjectIDs = []
            selectedSubjectID = nil
        }
    }

    private func applySubject(_ subject: ExamSubjectOption) {
        selectedSubjectID = subject.id
        subtitle = subject.displayName
        categoryId = String(subject.categoryId)
        boxId = subject.boxId.map(String.init) ?? ""
        techId = subject.techId.map(String.init) ?? ""
    }

    private func clearSubjectSelection() {
        selectedSubjectIDs = []
        selectedSubjectID = nil
        subtitle = ""
        categoryId = ""
        boxId = ""
        techId = ""
    }
}

struct SelectExamIntentView: View {
    let container: AppContainer
    @EnvironmentObject private var examContextStore: ExamContextStore
    @Environment(\.dismiss) private var dismiss
    @StateObject private var viewModel: SelectExamIntentViewModel
    @State private var expandedSectionIDs: Set<Int> = []
    
    init(container: AppContainer, store: ExamContextStore) {
        self.container = container
        _viewModel = StateObject(
            wrappedValue: SelectExamIntentViewModel(
                store: store,
                service: container.examContextService
            )
        )
    }

    var body: some View {
        NavigationView {
            ZStack(alignment: .bottom) {
                Color(.systemGroupedBackground)
                    .ignoresSafeArea()

                Group {
                    if viewModel.showSubjectEditor {
                        subjectEditorContent
                    } else {
                        examSelectionContent
                    }
                }

                activeBottomBar
            }
            .navigationTitle(viewModel.showSubjectEditor ? "编辑科目" : "选择考试")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button {
                        if viewModel.showSubjectEditor {
                            withAnimation(.easeInOut(duration: 0.2)) {
                                viewModel.showSubjectEditor = false
                            }
                        } else if examContextStore.hasSelection {
                            examContextStore.dismissSelection()
                            dismiss()
                        }
                    } label: {
                        Image(systemName: "chevron.left")
                            .foregroundColor(.primary)
                    }
                    .disabled(!viewModel.showSubjectEditor && !examContextStore.hasSelection)
                    .opacity((viewModel.showSubjectEditor || examContextStore.hasSelection) ? 1 : 0)
                }

                ToolbarItem(placement: .topBarTrailing) {
                    if !viewModel.showSubjectEditor {
                        Button("手动填写") {
                            viewModel.showAdvancedEditor = true
                        }
                        .font(.subheadline.weight(.medium))
                    }
                }
            }
        }
        .navigationViewStyle(StackNavigationViewStyle())
        .interactiveDismissDisabled(!examContextStore.hasSelection)
        .task {
            await viewModel.loadOptionsIfNeeded()
            if expandedSectionIDs.isEmpty {
                expandedSectionIDs = Set(viewModel.sections.prefix(2).map(\.id))
            }
        }
        .sheet(isPresented: $viewModel.showAdvancedEditor) {
            advancedEditor
        }
    }

    private var examSelectionContent: some View {
        ScrollView(showsIndicators: false) {
            VStack(spacing: 14) {
                selectedCard

                if viewModel.isLoadingOptions && viewModel.sections.isEmpty {
                    loadingCard
                }

                ForEach(viewModel.sections) { section in
                    groupCard(section)
                }

                footerMessages
                    .padding(.bottom, 100)
            }
            .padding(.horizontal, 14)
            .padding(.top, 14)
        }
    }

    private var subjectEditorContent: some View {
        ScrollView(showsIndicators: false) {
            VStack(spacing: 16) {
                subjectEditorHeader
                subjectManageSection
                subjectAllSection
                footerMessages
                    .padding(.bottom, 112)
            }
            .padding(.horizontal, 16)
            .padding(.top, 16)
        }
    }

    private var selectedCard: some View {
        VStack(alignment: .leading, spacing: 14) {
            Text("当前考试意向")
                .font(.system(size: 12, weight: .semibold))
                .foregroundColor(.secondary)

            HStack(alignment: .top) {
                VStack(alignment: .leading, spacing: 6) {
                    if let persistedContext = examContextStore.context {
                        Text(persistedContext.displayName)
                            .font(.system(size: 22, weight: .bold))
                            .foregroundColor(.primary)
                        Text(summaryDescription(for: persistedContext))
                            .font(.system(size: 13))
                            .foregroundColor(.secondary)
                    } else {
                        Text("请选择你的考试方向")
                            .font(.system(size: 22, weight: .bold))
                            .foregroundColor(.primary)
                        Text("考试意向是题库、课程和报告等页面的统一上下文")
                            .font(.system(size: 13))
                            .foregroundColor(.secondary)
                    }
                }

                Spacer()

                if examContextStore.context != nil {
                    Text("已生效")
                        .font(.system(size: 12, weight: .bold))
                        .foregroundColor(.blue)
                        .padding(.horizontal, 12)
                        .padding(.vertical, 8)
                        .background(Color.blue.opacity(0.08))
                        .clipShape(Capsule())
                }
            }
        }
        .padding(18)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            LinearGradient(
                colors: [Color.blue.opacity(0.08), Color.white],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        )
        .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
        .overlay(
            RoundedRectangle(cornerRadius: 24, style: .continuous)
                .stroke(Color.blue.opacity(0.12), lineWidth: 1)
        )
    }

    private var loadingCard: some View {
        HStack(spacing: 12) {
            ProgressView()
            Text("正在加载考试分类和科目...")
                .font(.system(size: 14, weight: .medium))
                .foregroundColor(.secondary)
            Spacer()
        }
        .padding(18)
        .background(Color(.systemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
    }

    private func groupCard(_ section: ExamIntentSection) -> some View {
        let isExpanded = expandedSectionIDs.contains(section.id)
        let items = section.items

        return VStack(spacing: 0) {
            Button {
                toggle(section)
            } label: {
                HStack(alignment: .top) {
                    VStack(alignment: .leading, spacing: 6) {
                        Text(section.title)
                            .font(.system(size: 18, weight: .bold))
                            .foregroundColor(.primary)
                        Text(section.subtitle.isEmpty ? section.headerMeta : "\(section.subtitle) · \(section.headerMeta)")
                            .font(.system(size: 12))
                            .foregroundColor(.secondary)
                    }

                    Spacer()

                    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
                        .font(.system(size: 15, weight: .semibold))
                        .foregroundColor(.secondary)
                        .padding(.top, 4)
                }
                .padding(18)
            }
            .buttonStyle(.plain)

            if isExpanded {
                VStack(alignment: .leading, spacing: 0) {
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 112), spacing: 10)], alignment: .leading, spacing: 12) {
                        ForEach(items) { option in
                            examChip(option)
                        }
                    }
                    .padding(.horizontal, 18)
                    .padding(.bottom, 18)
                }
            } else {
                Text(section.collapsedHint)
                    .font(.system(size: 13))
                    .foregroundColor(Color(.tertiaryLabel))
                    .padding(.horizontal, 18)
                    .padding(.bottom, 18)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Color(.systemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
        .overlay(
            RoundedRectangle(cornerRadius: 24, style: .continuous)
                .stroke(Color.black.opacity(0.04), lineWidth: 1)
        )
    }

    private func examChip(_ option: ExamIntentOption) -> some View {
        let isSelected = viewModel.selectedExamID == option.id

        return Button {
            viewModel.chooseExamAndEditSubjects(option)
        } label: {
            HStack(spacing: 8) {
                if option.isHot {
                    Circle()
                        .fill(Color.orange)
                        .frame(width: 6, height: 6)
                }

                Text(option.title)
                    .lineLimit(1)
            }
            .font(.system(size: 14, weight: isSelected ? .semibold : .medium))
            .foregroundColor(isSelected ? .blue : Color(.secondaryLabel))
            .padding(.horizontal, 16)
            .frame(height: 40)
            .frame(maxWidth: .infinity)
            .background(isSelected ? Color.blue.opacity(0.08) : Color.white)
            .overlay(
                Capsule()
                    .stroke(isSelected ? Color.blue.opacity(0.35) : Color(.separator), lineWidth: 1)
            )
            .clipShape(Capsule())
        }
        .buttonStyle(.plain)
    }

    private var footerMessages: some View {
        VStack(alignment: .leading, spacing: 8) {
            if let errorMessage = viewModel.errorMessage {
                Text(errorMessage)
                    .font(.footnote)
                    .foregroundStyle(.red)
            }

            if let infoMessage = viewModel.infoMessage {
                Text(infoMessage)
                    .font(.footnote)
                    .foregroundStyle(.secondary)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    private var bottomBar: some View {
        VStack(spacing: 0) {
            Rectangle()
                .fill(Color.clear)
                .frame(height: 16)
                .background(
                    LinearGradient(
                        colors: [Color(.systemGroupedBackground).opacity(0), Color(.systemGroupedBackground)],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                )

            HStack {
                Spacer()
                HStack {
                    Image(systemName: "hand.tap.fill")
                    Text("先选择考试,再进入科目编辑页完成设置")
                        .font(.system(size: 14, weight: .semibold))
                }
                .foregroundColor(Color(red: 0.28, green: 0.39, blue: 0.58))
                Spacer()
            }
            .frame(height: 54)
            .background(Color.white)
            .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
            .padding(.horizontal, 14)
            .padding(.top, 6)
            .padding(.bottom, 18)
            .background(Color(.systemGroupedBackground))
        }
    }

    private var activeBottomBar: some View {
        Group {
            if viewModel.showSubjectEditor {
                subjectEditorBottomBar
            } else {
                bottomBar
            }
        }
    }

    private var subjectEditorHeader: some View {
        VStack(alignment: .leading, spacing: 14) {
            Text("当前考试")
                .font(.system(size: 12, weight: .semibold))
                .foregroundColor(.secondary)

            HStack(alignment: .top, spacing: 12) {
                VStack(alignment: .leading, spacing: 8) {
                    Text(viewModel.selectedExam?.title ?? "请选择考试")
                        .font(.system(size: 24, weight: .bold))
                        .foregroundColor(.primary)

                    Text("请继续确认你的科目,保存后将联动题库、课程和报告等页面。")
                        .font(.system(size: 13))
                        .foregroundColor(.secondary)
                }

                Spacer()

                Image(systemName: "square.grid.2x2.fill")
                    .font(.system(size: 20, weight: .semibold))
                    .foregroundColor(Color(red: 0.16, green: 0.39, blue: 0.78))
                    .padding(12)
                    .background(Color.white.opacity(0.75))
                    .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
            }
        }
        .padding(20)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            LinearGradient(
                colors: [
                    Color(red: 0.91, green: 0.96, blue: 1.0),
                    Color.white
                ],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        )
        .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
        .overlay(
            RoundedRectangle(cornerRadius: 24, style: .continuous)
                .stroke(Color.blue.opacity(0.12), lineWidth: 1)
        )
    }

    private var subjectEditorBottomBar: some View {
        VStack(spacing: 0) {
            Rectangle()
                .fill(Color.clear)
                .frame(height: 20)
                .background(
                    LinearGradient(
                        colors: [Color(.systemGroupedBackground).opacity(0), Color(.systemGroupedBackground)],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                )

            Button {
                Task { await viewModel.saveSelection() }
            } label: {
                HStack(spacing: 8) {
                    if viewModel.isLoading {
                        ProgressView()
                            .tint(.white)
                    }
                    Text(viewModel.managedSubjects.isEmpty ? "请选择科目" : "完成")
                        .font(.system(size: 16, weight: .bold))
                }
                .foregroundColor(.white)
                .frame(maxWidth: .infinity)
                .frame(height: 54)
                .background(
                    LinearGradient(
                        colors: [Color(red: 0.16, green: 0.43, blue: 0.97), Color(red: 0.15, green: 0.33, blue: 0.82)],
                        startPoint: .leading,
                        endPoint: .trailing
                    )
                )
                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
            }
            .buttonStyle(.plain)
            .disabled(viewModel.isLoading || viewModel.managedSubjects.isEmpty)
            .opacity(viewModel.isLoading || viewModel.managedSubjects.isEmpty ? 0.55 : 1)
            .padding(.horizontal, 16)
            .padding(.top, 8)
            .padding(.bottom, 18)
            .background(Color(.systemGroupedBackground))
        }
    }

    private var subjectManageSection: some View {
        VStack(alignment: .leading, spacing: 18) {
            VStack(spacing: 4) {
                Text("管理我的科目")
                    .font(.system(size: 18, weight: .bold))
                    .frame(maxWidth: .infinity, alignment: .center)

                Text("已添加 \(viewModel.managedSubjects.count) 个科目,可继续增减")
                    .font(.system(size: 12, weight: .medium))
                    .foregroundColor(.secondary)
                    .frame(maxWidth: .infinity, alignment: .center)
            }

            if viewModel.managedSubjects.isEmpty {
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
                    .foregroundColor(Color(.systemGray4))
                    .frame(height: 92)
                    .overlay(
                        Text("点击添加科目,我们才会更了解你的需求哦~")
                            .font(.system(size: 14, weight: .medium))
                            .foregroundColor(Color(.systemGray))
                    )
            } else {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 136), spacing: 12)], spacing: 12) {
                    ForEach(viewModel.managedSubjects) { subject in
                        managedSubjectChip(subject)
                    }
                }
            }
        }
        .padding(20)
        .background(Color.white)
        .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
    }

    private var subjectAllSection: some View {
        VStack(alignment: .leading, spacing: 18) {
            VStack(spacing: 4) {
                Text("全部科目")
                    .font(.system(size: 18, weight: .bold))
                    .frame(maxWidth: .infinity, alignment: .center)

                Text("点击右侧加号添加,当前阶段仅开放注册安全工程师相关方向")
                    .font(.system(size: 12, weight: .medium))
                    .foregroundColor(.secondary)
                    .frame(maxWidth: .infinity, alignment: .center)
            }

            if viewModel.allSubjects.isEmpty {
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .fill(Color(.systemGray6))
                    .frame(height: 88)
                    .overlay(
                        Text("当前考试暂未查询到可选科目")
                            .font(.system(size: 14, weight: .medium))
                            .foregroundColor(.secondary)
                    )
            } else {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 132), spacing: 12)], spacing: 12) {
                    ForEach(viewModel.allSubjects) { subject in
                        allSubjectChip(subject)
                    }
                }
            }
        }
        .padding(20)
        .background(Color.white)
        .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
    }

    private func managedSubjectChip(_ option: ExamSubjectOption) -> some View {
        Button {
            viewModel.toggleManagedSubject(option)
        } label: {
            HStack(spacing: 8) {
                Text(option.displayName)
                    .lineLimit(1)
                Spacer(minLength: 0)
                Image(systemName: "minus")
                    .font(.system(size: 12, weight: .bold))
            }
            .padding(.horizontal, 14)
            .frame(height: 56)
            .frame(maxWidth: .infinity)
            .background(Color(red: 0.95, green: 0.98, blue: 1.0))
            .foregroundColor(Color(red: 0.14, green: 0.36, blue: 0.74))
            .overlay(
                RoundedRectangle(cornerRadius: 14, style: .continuous)
                    .stroke(Color.blue.opacity(0.18), lineWidth: 1)
            )
            .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
        }
        .buttonStyle(.plain)
    }

    private func allSubjectChip(_ option: ExamSubjectOption) -> some View {
        let isSelected = viewModel.selectedSubjectIDs.contains(option.id)

        return Button {
            viewModel.toggleManagedSubject(option)
        } label: {
            HStack(spacing: 8) {
                Text(option.displayName)
                    .lineLimit(1)
                Spacer(minLength: 0)
                Image(systemName: isSelected ? "checkmark" : "plus")
                    .font(.system(size: 12, weight: .bold))
            }
            .padding(.horizontal, 14)
            .frame(height: 56)
            .frame(maxWidth: .infinity)
            .background(isSelected ? Color(red: 0.95, green: 0.98, blue: 1.0) : Color.white)
            .foregroundColor(isSelected ? Color(red: 0.14, green: 0.36, blue: 0.74) : Color.primary)
            .overlay(
                RoundedRectangle(cornerRadius: 14, style: .continuous)
                    .stroke(isSelected ? Color.blue.opacity(0.22) : Color(.systemGray5), lineWidth: 1)
            )
            .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
        }
        .buttonStyle(.plain)
    }

    private var advancedEditor: some View {
        NavigationView {
            ScrollView {
                VStack(spacing: 14) {
                    examField("考试名称", text: $viewModel.title)
                    examField("说明", text: $viewModel.subtitle)
                    examField("categoryId", text: $viewModel.categoryId)
                    examField("secondCategoryId", text: $viewModel.secondCategoryId)
                    examField("boxId", text: $viewModel.boxId)
                    examField("techId / bookId", text: $viewModel.techId)
                }
                .padding(20)
            }
            .navigationTitle("手动配置")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("关闭") {
                        viewModel.showAdvancedEditor = false
                    }
                }
                ToolbarItem(placement: .topBarTrailing) {
                    if #available(iOS 16.0, *) {
                        Button("完成") {
                            viewModel.showAdvancedEditor = false
                        }
                        .fontWeight(.semibold)
                    } else {
                        // Fallback on earlier versions
                    }
                }
            }
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }

    private func toggle(_ section: ExamIntentSection) {
        if expandedSectionIDs.contains(section.id) {
            expandedSectionIDs.remove(section.id)
        } else {
            expandedSectionIDs.insert(section.id)
        }
    }

    private func summaryDescription(for context: ExerciseContext) -> String {
        if let matchedExam = viewModel.examOptions.first(where: { $0.secondCategoryId == context.secondCategoryId }),
           let matchedSubject = matchedExam.subjects.first(where: { $0.categoryId == context.categoryId }) {
            return "\(matchedExam.sectionTitle) · \(matchedSubject.displayName)"
        }

        return context.detailText
    }
}

private func examField(_ title: String, text: Binding<String>) -> some View {
    VStack(alignment: .leading, spacing: 6) {
        Text(title)
            .font(.footnote)
            .foregroundStyle(.secondary)
        TextField(title, text: text)
            .textFieldStyle(.roundedBorder)
    }
}

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容