← API | 列表 | swift_信号模块实现
提示信息
# 同伴 App — Swift 信号模块实现

> 业务规格 + 完整 SwiftUI 实现。对应 Flutter `features/signal/` 目录。

---

## 一、数据模型

```swift
// SignalModels.swift

// ── 发布配置 ────────────────────────────────────────────────────────────
struct SignalPublishConfig: Decodable, Sendable {
    let category: Int
    let categoryName: String
    let placeholder: String
    let hasPublished: Int          // >0 表示该分类已发布(值为信号 ID)
}

// ── 信号分类颜色(静态) ──────────────────────────────────────────────────
extension SignalPublishConfig {
    var categoryColor: Color {
        switch category {
        case 1: return Color(red: 0.9, green: 0.3, blue: 0.3)   // 恋爱/感情 — 红
        case 2: return Color(red: 0.2, green: 0.6, blue: 0.9)   // 同城/附近 — 蓝
        case 3: return Color(red: 0.4, green: 0.8, blue: 0.4)   // 游戏/娱乐 — 绿
        case 4: return Color(red: 0.9, green: 0.7, blue: 0.2)   // 互换/交易 — 黄
        default: return Color(red: 0.6, green: 0.6, blue: 0.6)  // 其他 — 灰
        }
    }

    var categoryIcon: String {
        switch category {
        case 1: return "heart.fill"
        case 2: return "mappin.and.ellipse"
        case 3: return "gamecontroller.fill"
        case 4: return "arrow.left.arrow.right"
        default: return "questionmark.circle.fill"
        }
    }
}

// ── 信号附件 ────────────────────────────────────────────────────────────
struct SignalAttachment: Codable, Sendable {
    let objectKey: String
    let mime: String
    let duration: Int?      // 语音时长(秒)
    let width: Int?
    let height: Int?
    let size: Int?
}

// ── 信号内容(嵌套在收件箱条目中) ─────────────────────────────────────
struct SignalContent: Decodable, Sendable {
    let id: Int
    let content: String
    let type: Int           // 1=文字 2=图片 3=语音
    let categoryName: String
    let attachment: [String: AnyCodable]?
    let uid: Int
    let nickname: String
    let avatar: String
    let createTimeFmt: String

    var imageURLs: [String] {
        guard type == 2,
              let images = attachment?["images"]?.value as? [String]
        else { return [] }
        return images
    }

    var voiceURL: String? {
        guard type == 3 else { return nil }
        return (attachment?["voice"]?.value as? String)
            ?? (attachment?["url"]?.value as? String)
    }

    var voiceDuration: Int {
        (attachment?["voice_duration"]?.value as? Int) ?? 0
    }
}

// ── 信号回复 ────────────────────────────────────────────────────────────
struct SignalReply: Decodable, Sendable {
    let id: Int
    let uid: Int
    let nickname: String
    let avatar: String
    let content: String
    let isImOpened: Int     // 0=未开IM 1=已开IM
    let createTimeFmt: String
}

// ── 收件箱条目 ──────────────────────────────────────────────────────────
struct SignalInboxItem: Decodable, Identifiable, Sendable {
    let receiveId: Int
    let type: Int           // 1=我收到信号 2=我的信号被回复
    var status: Int         // 0=待处理 1=已回复 2=已开启私聊
    var isRead: Int         // 0=未读 1=已读
    let nickname: String
    let avatar: String
    let createTimeFmt: String
    let signal: SignalContent
    let reply: SignalReply?
    let preview: String

    var id: Int { receiveId }
    var isUnread: Bool { isRead == 0 }
}

// ── 筛选条件 ────────────────────────────────────────────────────────────
struct SignalFilter: Codable, Sendable {
    var categories: [Int] = []
    var range: String = "unlimited"   // "unlimited" | "nearby"
    var ageMin: Int = 18
    var ageMax: Int = 40

    func toJSONString() -> String {
        let dict: [String: Any] = [
            "categories": categories,
            "range": range,
            "age_min": ageMin,
            "age_max": ageMax
        ]
        let data = try? JSONSerialization.data(withJSONObject: dict)
        return data.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
    }
}

// ── 发布请求 ────────────────────────────────────────────────────────────
struct SignalPublishRequest: Encodable, Sendable {
    let category: Int
    let content: String?
    let type: Int
    let attachment: SignalAttachment?
}

// ── 拉取响应 ────────────────────────────────────────────────────────────
struct SignalPullResponse: Decodable, Sendable {
    let list: [SignalInboxItem]
    let remainingCount: Int
    let dailyLimit: Int
}
```

---

## 二、SignalRepository(数据层)

```swift
// SignalRepository.swift
actor SignalRepository {
    static let shared = SignalRepository()

    // ── 获取发布配置 ───────────────────────────────────────────────────
    func fetchPublishConfig() async throws -> [SignalPublishConfig] {
        try await APIClient.shared.get("/signal/publish/config",
                                        as: [SignalPublishConfig].self)
    }

    // ── 发布信号 ───────────────────────────────────────────────────────
    func publish(request: SignalPublishRequest) async throws -> Int {
        try await APIClient.shared.post("/signal/publish",
                                         body: request, as: Int.self)
    }

    // ── 删除信号 ───────────────────────────────────────────────────────
    func deleteSignal(signalId: Int) async throws {
        try await APIClient.shared.post("/signal/delete",
                                         body: ["signal_id": signalId] as [String: Any])
    }

    // ── 收件箱列表(游标分页) ──────────────────────────────────────────
    func fetchInbox(lastId: Int? = nil) async throws -> [SignalInboxItem] {
        var params: [String: Any] = [:]
        if let id = lastId { params["last_id"] = id }
        return try await APIClient.shared.get("/signal/inbox/list",
                                               params: params,
                                               as: [SignalInboxItem].self)
    }

    // ── 标记已读 ───────────────────────────────────────────────────────
    func markRead(receiveId: Int) async throws {
        try await APIClient.shared.post("/signal/inbox/read",
                                         body: ["receive_id": receiveId] as [String: Any])
    }

    // ── 删除收件 ───────────────────────────────────────────────────────
    func deleteInboxItem(receiveId: Int) async throws {
        try await APIClient.shared.post("/signal/inbox/delete",
                                         body: ["receive_id": receiveId] as [String: Any])
    }

    // ── 回复信号 ───────────────────────────────────────────────────────
    func reply(receiveId: Int, content: String) async throws {
        try await APIClient.shared.post("/signal/reply",
                                         body: ["receive_id": receiveId,
                                                "content": content] as [String: Any])
    }

    // ── 拉取信号 ───────────────────────────────────────────────────────
    func pull(filter: SignalFilter) async throws -> SignalPullResponse {
        try await APIClient.shared.post("/signal/pull",
                                         body: ["filter": filter.toJSONString()] as [String: Any],
                                         as: SignalPullResponse.self)
    }

    // ── 未读回复数 ─────────────────────────────────────────────────────
    func fetchUnreadCount() async throws -> Int {
        struct R: Decodable { let unreadCount: Int }
        let r = try await APIClient.shared.get("/signal/reply/unread", as: R.self)
        return r.unreadCount
    }

    // ── 开启 IM 聊天 ───────────────────────────────────────────────────
    func openIM(receiveId: Int) async throws {
        try await APIClient.shared.post("/signal/inbox/open-im",
                                         body: ["receive_id": receiveId] as [String: Any])
    }

    // ── 低质量反馈 ─────────────────────────────────────────────────────
    func feedbackLowQuality(targetType: String, targetId: Int) async throws {
        try await APIClient.shared.post("/signal/feedback/low-quality",
                                         body: ["target_type": targetType,
                                                "target_id": targetId] as [String: Any])
    }

    // ── 本地缓存(MMKV) ───────────────────────────────────────────────
    func cacheInbox(_ items: [SignalInboxItem]) {
        guard let data = try? JSONEncoder().encode(items),
              let str  = String(data: data, encoding: .utf8) else { return }
        AppStorage.setString(StorageKeys.signalInboxCache, str)
    }

    func loadCachedInbox() -> [SignalInboxItem] {
        guard let str  = AppStorage.getString(StorageKeys.signalInboxCache),
              let data = str.data(using: .utf8),
              let items = try? JSONDecoder().decode([SignalInboxItem].self, from: data)
        else { return [] }
        return items
    }

    func loadCachedFilter() -> SignalFilter {
        guard let str  = AppStorage.getString(StorageKeys.signalFilter),
              let data = str.data(using: .utf8),
              let filter = try? JSONDecoder().decode(SignalFilter.self, from: data)
        else { return SignalFilter() }
        return filter
    }

    func saveFilter(_ filter: SignalFilter) {
        guard let data = try? JSONEncoder().encode(filter),
              let str  = String(data: data, encoding: .utf8) else { return }
        AppStorage.setString(StorageKeys.signalFilter, str)
    }
}
```

---

## 三、信号收件箱(SignalInboxView)

### ViewModel

```swift
// SignalInboxViewModel.swift
@MainActor
@Observable
final class SignalInboxViewModel {

    var items: [SignalInboxItem] = []
    var isLoading = false
    var isLoadingMore = false
    var hasMore = true
    var unreadReplyCount = 0
    var remainingPullCount = 0
    var showFilterSheet = false
    var filter: SignalFilter = SignalFilter()

    private var lastId: Int? = nil

    // ── 初始加载 ──────────────────────────────────────────────────────
    func onAppear() async {
        // 先展示缓存
        let cached = await SignalRepository.shared.loadCachedInbox()
        if items.isEmpty && !cached.isEmpty { items = cached }
        filter = await SignalRepository.shared.loadCachedFilter()
        await refresh()
        await refreshUnreadCount()
    }

    func refresh() async {
        isLoading = true
        lastId    = nil
        hasMore   = true
        defer { isLoading = false }
        do {
            let fetched = try await SignalRepository.shared.fetchInbox()
            items = fetched
            hasMore = fetched.count >= 20
            if let first = fetched.first { lastId = first.receiveId }
            await SignalRepository.shared.cacheInbox(fetched)
        } catch { }
    }

    func loadMore() async {
        guard hasMore, !isLoadingMore else { return }
        isLoadingMore = true
        defer { isLoadingMore = false }
        do {
            let more = try await SignalRepository.shared.fetchInbox(lastId: lastId)
            items.append(contentsOf: more)
            hasMore = more.count >= 20
            if let last = more.last { lastId = last.receiveId }
        } catch { }
    }

    // ── 拉取信号 ──────────────────────────────────────────────────────
    func pullSignal() async {
        do {
            let resp = try await SignalRepository.shared.pull(filter: filter)
            // 新拉取的插入列表头部
            items.insert(contentsOf: resp.list, at: 0)
            remainingPullCount = resp.remainingCount
            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
        } catch let e as AppAPIError {
            if e.code == 3006 {
                // 没有更多信号 — Toast 提示,不报错
                AppToast.show("暂时没有新信号了")
            }
            // 3008 由 ResponseHandler 全局处理(弹今日上限弹窗)
        }
    }

    // ── 标记已读(乐观更新) ───────────────────────────────────────────
    func markRead(receiveId: Int) async {
        guard let idx = items.firstIndex(where: { $0.receiveId == receiveId }) else { return }
        let snapshot = items[idx]
        items[idx].isRead = 1
        do {
            try await SignalRepository.shared.markRead(receiveId: receiveId)
        } catch {
            items[idx] = snapshot   // 回滚
        }
    }

    // ── 删除条目(乐观更新) ───────────────────────────────────────────
    func deleteItem(receiveId: Int) async {
        guard let idx = items.firstIndex(where: { $0.receiveId == receiveId }) else { return }
        let snapshot = items.remove(at: idx)
        do {
            try await SignalRepository.shared.deleteInboxItem(receiveId: receiveId)
        } catch {
            items.insert(snapshot, at: idx)   // 回滚
        }
    }

    // ── 未读数 ────────────────────────────────────────────────────────
    func refreshUnreadCount() async {
        unreadReplyCount = (try? await SignalRepository.shared.fetchUnreadCount()) ?? 0
    }

    // ── 保存筛选 ──────────────────────────────────────────────────────
    func applyFilter(_ newFilter: SignalFilter) async {
        filter = newFilter
        await SignalRepository.shared.saveFilter(newFilter)
        await refresh()
    }
}
```

### View

```swift
// SignalInboxView.swift
struct SignalInboxView: View {
    @State private var vm = SignalInboxViewModel()
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            ZStack(alignment: .bottom) {
                // 列表
                Group {
                    if vm.isLoading && vm.items.isEmpty {
                        inboxSkeleton
                    } else if vm.items.isEmpty {
                        ContentUnavailableView(
                            "收件箱为空",
                            systemImage: "tray",
                            description: Text("点击「收一条」拉取新信号")
                        )
                    } else {
                        List {
                            ForEach(vm.items) { item in
                                SignalInboxRow(item: item)
                                    .listRowInsets(EdgeInsets())
                                    .listRowSeparator(.hidden)
                                    .contentShape(Rectangle())
                                    .onTapGesture {
                                        Task { await vm.markRead(receiveId: item.receiveId) }
                                        navigationPath.append(item)
                                    }
                                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                                        Button(role: .destructive) {
                                            Task { await vm.deleteItem(receiveId: item.receiveId) }
                                        } label: {
                                            Label("删除", systemImage: "trash")
                                        }

                                        if item.isUnread {
                                            Button {
                                                Task { await vm.markRead(receiveId: item.receiveId) }
                                            } label: {
                                                Label("已读", systemImage: "envelope.open")
                                            }
                                            .tint(.blue)
                                        }
                                    }
                                    .onAppear {
                                        if item.id == vm.items.last?.id {
                                            Task { await vm.loadMore() }
                                        }
                                    }
                            }

                            if vm.isLoadingMore {
                                ProgressView().frame(maxWidth: .infinity).padding()
                                    .listRowSeparator(.hidden)
                            }
                        }
                        .listStyle(.plain)
                        .refreshable { await vm.refresh() }
                    }
                }
                // 底部悬浮操作栏 — Liquid Glass 融合
                .padding(.bottom, 80)

                bottomBar
            }
            .navigationTitle("收件箱")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        vm.showFilterSheet = true
                    } label: {
                        Image(systemName: "line.3.horizontal.decrease.circle")
                    }
                }
            }
            .navigationDestination(for: SignalInboxItem.self) { item in
                SignalDetailView(item: item)
            }
            .sheet(isPresented: $vm.showFilterSheet) {
                SignalFilterSheet(filter: vm.filter) { newFilter in
                    Task { await vm.applyFilter(newFilter) }
                }
            }
        }
        .task { await vm.onAppear() }
    }

    // ── 底部操作栏 ────────────────────────────────────────────────────
    private var bottomBar: some View {
        GlassEffectContainer {
            HStack(spacing: 0) {
                // 新回复 / 收一条
                Button {
                    Task { await vm.pullSignal() }
                } label: {
                    VStack(spacing: 4) {
                        HStack(spacing: 4) {
                            Image(systemName: "arrow.down.circle")
                            Text(vm.unreadReplyCount > 0 ? "新回复" : "收一条")
                                .font(.subheadline.weight(.medium))
                        }
                        if vm.remainingPullCount > 0 {
                            Text("今日剩余 \(vm.remainingPullCount)").font(.caption2).foregroundStyle(.secondary)
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 14)
                }
                .overlay(alignment: .topTrailing) {
                    if vm.unreadReplyCount > 0 {
                        Text("\(vm.unreadReplyCount)")
                            .font(.caption2.bold())
                            .padding(.horizontal, 5).padding(.vertical, 2)
                            .background(.red, in: Capsule())
                            .foregroundStyle(.white)
                            .offset(x: -8, y: 8)
                    }
                }
                .glassEffect(.regular.interactive())

                Divider().frame(height: 32)

                // 发一条
                NavigationLink(destination: SignalPublishView()) {
                    HStack(spacing: 4) {
                        Image(systemName: "plus.circle.fill")
                        Text("发一条").font(.subheadline.weight(.medium))
                    }
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 14)
                }
                .glassEffect(.regular.interactive())
            }
        }
        .padding(.horizontal, 20)
        .padding(.bottom, 16)
    }

    // ── 骨架屏 ────────────────────────────────────────────────────────
    private var inboxSkeleton: some View {
        List(0..<8, id: \.self) { _ in
            HStack(spacing: 12) {
                Circle().frame(width: 48, height: 48)
                VStack(alignment: .leading, spacing: 8) {
                    RoundedRectangle(cornerRadius: 4).frame(width: 120, height: 14)
                    RoundedRectangle(cornerRadius: 4).frame(width: 200, height: 12)
                }
                Spacer()
            }
            .padding(.vertical, 8)
            .redacted(reason: .placeholder)
            .listRowSeparator(.hidden)
        }
        .listStyle(.plain)
        .allowsHitTesting(false)
    }
}

// ── 收件箱列表行 ──────────────────────────────────────────────────────────
struct SignalInboxRow: View {
    let item: SignalInboxItem

    var body: some View {
        HStack(alignment: .top, spacing: 12) {
            // 未读圆点
            Circle()
                .fill(item.isUnread ? Color.accentColor : Color.clear)
                .frame(width: 8, height: 8)
                .padding(.top, 6)

            // 头像
            AsyncImage(url: URL(string: item.avatar)) { img in
                img.resizable().scaledToFill()
            } placeholder: {
                Circle().fill(.secondary.opacity(0.2))
            }
            .frame(width: 48, height: 48)
            .clipShape(Circle())

            VStack(alignment: .leading, spacing: 4) {
                HStack {
                    Text(item.nickname).font(.subheadline.bold())
                    Spacer()
                    Text(item.createTimeFmt).font(.caption).foregroundStyle(.secondary)
                }

                Text(item.preview)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)

                // 状态标签
                statusTag
            }

            Spacer(minLength: 0)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
        .background(item.isUnread ? Color.accentColor.opacity(0.05) : Color.clear)
    }

    @ViewBuilder
    private var statusTag: some View {
        switch item.status {
        case 0:
            Text(item.type == 1 ? "待回复" : "等待回复")
                .font(.caption2)
                .padding(.horizontal, 6).padding(.vertical, 2)
                .background(Color.orange.opacity(0.15), in: Capsule())
                .foregroundStyle(.orange)
        case 1:
            Text("已回复").font(.caption2)
                .padding(.horizontal, 6).padding(.vertical, 2)
                .background(Color.green.opacity(0.15), in: Capsule())
                .foregroundStyle(.green)
        case 2:
            Text("已开启私聊").font(.caption2)
                .padding(.horizontal, 6).padding(.vertical, 2)
                .background(Color.blue.opacity(0.15), in: Capsule())
                .foregroundStyle(.blue)
        default:
            EmptyView()
        }
    }
}
```

---

## 四、发布信号(SignalPublishView)

### ViewModel

```swift
// SignalPublishViewModel.swift
@MainActor
@Observable
final class SignalPublishViewModel {
    // 分类
    var configs: [SignalPublishConfig] = []
    var selectedCategory: Int = 1

    // 内容模式
    enum InputMode { case text, voice }
    var inputMode: InputMode = .voice

    // 文字模式
    var textContent: String = ""
    var selectedImageURL: URL?

    // 语音模式
    var isRecording = false
    var recordedFileURL: URL?
    var recordedDuration: Int = 0
    var isPlayingPreview = false

    // 发布状态
    var isPublishing = false
    var uploadProgress: Double = 0

    var canPublish: Bool {
        switch inputMode {
        case .text:  return textContent.trimmingCharacters(in: .whitespaces).count >= 1
        case .voice: return recordedFileURL != nil
        }
    }

    var selectedConfig: SignalPublishConfig? {
        configs.first { $0.category == selectedCategory }
    }

    // ── 加载配置 ──────────────────────────────────────────────────────
    func loadConfig() async {
        configs = (try? await SignalRepository.shared.fetchPublishConfig()) ?? []
        if selectedCategory == 0, let first = configs.first {
            selectedCategory = first.category
        }
    }

    // ── 发布 ──────────────────────────────────────────────────────────
    func publish(onSuccess: @escaping () -> Void) async {
        guard canPublish else { return }
        isPublishing = true
        defer { isPublishing = false }

        do {
            let request: SignalPublishRequest

            switch inputMode {
            case .text:
                var attachment: SignalAttachment? = nil
                if let imgURL = selectedImageURL {
                    let result = try await UploadClient.shared.uploadFile(
                        at: imgURL, type: "signal_image"
                    ) { [weak self] p in
                        self?.uploadProgress = p
                    }
                    attachment = SignalAttachment(
                        objectKey: result.objectKey, mime: "image/jpeg",
                        duration: nil, width: nil, height: nil, size: nil
                    )
                }
                request = SignalPublishRequest(
                    category: selectedCategory,
                    content: textContent,
                    type: selectedImageURL != nil ? 2 : 1,
                    attachment: attachment
                )

            case .voice:
                guard let voiceURL = recordedFileURL else { return }
                let result = try await UploadClient.shared.uploadFile(
                    at: voiceURL, type: "signal_audio"
                ) { [weak self] p in
                    self?.uploadProgress = p
                }
                request = SignalPublishRequest(
                    category: selectedCategory,
                    content: nil,
                    type: 3,
                    attachment: SignalAttachment(
                        objectKey: result.objectKey, mime: "audio/mp4",
                        duration: recordedDuration, width: nil, height: nil, size: nil
                    )
                )
            }

            _ = try await SignalRepository.shared.publish(request: request)
            UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
            onSuccess()

        } catch let e as AppAPIError {
            AppToast.showError(e.message)
        }
    }
}
```

### View

```swift
// SignalPublishView.swift
struct SignalPublishView: View {
    @State private var vm = SignalPublishViewModel()
    @State private var showCategoryPicker = false
    @State private var showImagePicker = false
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // 分类选择器按钮
                categorySelector
                    .padding(.horizontal, 20)
                    .padding(.top, 16)

                // 输入模式切换
                modeSwitcher
                    .padding(.horizontal, 20)
                    .padding(.top, 16)

                Spacer()

                // 输入区
                inputArea

                Spacer()

                // 发射按钮
                publishButton
                    .padding(.horizontal, 20)
                    .padding(.bottom, 32)
            }
            .navigationTitle("发信号")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("取消") { dismiss() }
                }
            }
            .sheet(isPresented: $showCategoryPicker) {
                CategoryPickerSheet(configs: vm.configs, selected: vm.selectedCategory) { cat in
                    vm.selectedCategory = cat
                }
            }
        }
        .task { await vm.loadConfig() }
    }

    // ── 分类选择 ──────────────────────────────────────────────────────
    private var categorySelector: some View {
        Button {
            showCategoryPicker = true
        } label: {
            HStack(spacing: 8) {
                if let config = vm.selectedConfig {
                    Image(systemName: config.categoryIcon)
                        .foregroundStyle(config.categoryColor)
                    Text(config.categoryName).font(.subheadline.weight(.medium))
                    if config.hasPublished > 0 {
                        Text("已发布").font(.caption2)
                            .padding(.horizontal, 5).padding(.vertical, 2)
                            .background(.orange.opacity(0.15), in: Capsule())
                            .foregroundStyle(.orange)
                    }
                }
                Spacer()
                Image(systemName: "chevron.down").font(.caption).foregroundStyle(.secondary)
            }
            .padding()
            .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
        }
        .foregroundStyle(.primary)
    }

    // ── 模式切换 ──────────────────────────────────────────────────────
    private var modeSwitcher: some View {
        Picker("输入方式", selection: $vm.inputMode) {
            Label("语音", systemImage: "waveform").tag(SignalPublishViewModel.InputMode.voice)
            Label("文字", systemImage: "text.bubble").tag(SignalPublishViewModel.InputMode.text)
        }
        .pickerStyle(.segmented)
    }

    // ── 输入区 ────────────────────────────────────────────────────────
    @ViewBuilder
    private var inputArea: some View {
        switch vm.inputMode {
        case .voice:
            VoiceRecorderView(
                isRecording: $vm.isRecording,
                recordedFileURL: $vm.recordedFileURL,
                recordedDuration: $vm.recordedDuration,
                isPlayingPreview: $vm.isPlayingPreview
            )
        case .text:
            TextSignalInput(
                text: $vm.textContent,
                imageURL: $vm.selectedImageURL,
                placeholder: vm.selectedConfig?.placeholder ?? "分享你的想法...",
                onPickImage: { showImagePicker = true }
            )
        }
    }

    // ── 发射按钮 ──────────────────────────────────────────────────────
    private var publishButton: some View {
        Button {
            Task {
                await vm.publish { dismiss() }
            }
        } label: {
            Group {
                if vm.isPublishing {
                    HStack(spacing: 8) {
                        ProgressView().tint(.white)
                        if vm.uploadProgress > 0 && vm.uploadProgress < 1 {
                            Text("\(Int(vm.uploadProgress * 100))%")
                        } else {
                            Text("发射中...")
                        }
                    }
                } else {
                    HStack(spacing: 6) {
                        Image(systemName: "dot.radiowaves.right")
                        Text("发射信号")
                    }
                    .fontWeight(.semibold)
                }
            }
            .frame(maxWidth: .infinity)
            .frame(height: 52)
        }
        .disabled(!vm.canPublish || vm.isPublishing)
        .glassEffect(.regular.tint(.accent).interactive())
    }
}

// ── 文字输入组件 ──────────────────────────────────────────────────────────
struct TextSignalInput: View {
    @Binding var text: String
    @Binding var imageURL: URL?
    let placeholder: String
    let onPickImage: () -> Void

    private let maxLength = 300

    var body: some View {
        VStack(alignment: .trailing, spacing: 8) {
            ZStack(alignment: .topLeading) {
                if text.isEmpty {
                    Text(placeholder)
                        .foregroundStyle(.tertiary)
                        .padding(.horizontal, 4)
                        .padding(.vertical, 8)
                }
                TextEditor(text: $text)
                    .frame(minHeight: 120)
                    .onChange(of: text) { _, new in
                        if new.count > maxLength {
                            text = String(new.prefix(maxLength))
                        }
                    }
            }
            .padding(12)
            .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
            .padding(.horizontal, 20)

            Text("\(text.count)/\(maxLength)")
                .font(.caption).foregroundStyle(.secondary)
                .padding(.trailing, 24)

            // 图片附件
            if let url = imageURL {
                HStack {
                    AsyncImage(url: url) { img in
                        img.resizable().scaledToFill()
                    } placeholder: { Rectangle().fill(.secondary.opacity(0.2)) }
                    .frame(width: 80, height: 80)
                    .clipShape(RoundedRectangle(cornerRadius: 8))

                    Button {
                        imageURL = nil
                    } label: {
                        Image(systemName: "xmark.circle.fill")
                            .foregroundStyle(.secondary)
                    }
                }
                .padding(.horizontal, 20)
            } else {
                Button {
                    onPickImage()
                } label: {
                    Label("添加图片", systemImage: "photo")
                        .font(.subheadline)
                }
                .glassEffect(.regular.interactive())
                .padding(.horizontal, 20)
            }
        }
    }
}

// ── 语音录制组件 ──────────────────────────────────────────────────────────
struct VoiceRecorderView: View {
    @Binding var isRecording: Bool
    @Binding var recordedFileURL: URL?
    @Binding var recordedDuration: Int
    @Binding var isPlayingPreview: Bool

    @State private var recorder: AVAudioRecorder?
    @State private var player: AVAudioPlayer?
    @State private var elapsedSeconds = 0
    @State private var timer: Timer?

    private let maxDuration = 60

    var body: some View {
        VStack(spacing: 24) {
            if let fileURL = recordedFileURL {
                // 录制完成 — 预览
                VStack(spacing: 16) {
                    Image(systemName: "waveform.circle.fill")
                        .font(.system(size: 64))
                        .foregroundStyle(.accent)
                        .symbolEffect(.pulse, isActive: isPlayingPreview)

                    Text("\(recordedDuration)″").font(.title2.monospacedDigit())

                    HStack(spacing: 16) {
                        Button {
                            togglePlay(fileURL: fileURL)
                        } label: {
                            Label(isPlayingPreview ? "停止" : "预览",
                                  systemImage: isPlayingPreview ? "stop.fill" : "play.fill")
                        }
                        .glassEffect(.regular.interactive())

                        Button(role: .destructive) {
                            recordedFileURL = nil
                            recordedDuration = 0
                        } label: {
                            Label("重录", systemImage: "arrow.counterclockwise")
                        }
                        .glassEffect(.regular.interactive())
                    }
                }
            } else {
                // 录制中 / 待录制
                VStack(spacing: 16) {
                    Text(isRecording ? "\(elapsedSeconds)″" : "按住录音")
                        .font(.title2.monospacedDigit())
                        .foregroundStyle(isRecording ? .accent : .secondary)

                    if isRecording {
                        ProgressView(value: Double(elapsedSeconds), total: Double(maxDuration))
                            .tint(.accent)
                            .padding(.horizontal, 40)
                    }

                    // 大圆按钮
                    Circle()
                        .fill(isRecording ? Color.red : Color.accentColor)
                        .frame(width: 80, height: 80)
                        .overlay {
                            Image(systemName: isRecording ? "stop.fill" : "mic.fill")
                                .font(.title2)
                                .foregroundStyle(.white)
                        }
                        .scaleEffect(isRecording ? 1.1 : 1.0)
                        .animation(.spring(response: 0.3), value: isRecording)
                        .onTapGesture {
                            if isRecording { stopRecording() }
                            else { startRecording() }
                        }

                    Text(isRecording ? "点击停止" : "最长 60 秒,最短 1 秒")
                        .font(.caption).foregroundStyle(.secondary)
                }
            }
        }
    }

    private func startRecording() {
        let session = AVAudioSession.sharedInstance()
        try? session.setCategory(.record, mode: .default)
        try? session.setActive(true)

        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("\(UUID().uuidString).m4a")

        let settings: [String: Any] = [
            AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
            AVSampleRateKey: 44100,
            AVNumberOfChannelsKey: 1,
            AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
        ]

        recorder = try? AVAudioRecorder(url: url, settings: settings)
        recorder?.record()
        isRecording = true
        elapsedSeconds = 0
        UIImpactFeedbackGenerator(style: .medium).impactOccurred()

        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            elapsedSeconds += 1
            if elapsedSeconds >= maxDuration { stopRecording() }
        }
    }

    private func stopRecording() {
        timer?.invalidate()
        recorder?.stop()
        guard elapsedSeconds >= 1, let url = recorder?.url else {
            isRecording = false
            return
        }
        recordedFileURL = url
        recordedDuration = elapsedSeconds
        isRecording = false
        UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
    }

    private func togglePlay(fileURL: URL) {
        if isPlayingPreview {
            player?.stop()
            isPlayingPreview = false
        } else {
            player = try? AVAudioPlayer(contentsOf: fileURL)
            player?.play()
            isPlayingPreview = true
        }
    }
}
```

---

## 五、信号详情(SignalDetailView)

```swift
// SignalDetailView.swift
struct SignalDetailView: View {
    let item: SignalInboxItem

    @State private var vm: SignalDetailViewModel
    @State private var replyText = ""
    @Environment(\.dismiss) private var dismiss

    init(item: SignalInboxItem) {
        self.item = item
        _vm = State(initialValue: SignalDetailViewModel(item: item))
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                ScrollView {
                    VStack(spacing: 20) {
                        // 信号卡片
                        signalCard

                        // type=2(发送方视角)才显示对方回复
                        if item.type == 2, let reply = item.reply {
                            replySection(reply)
                        }
                    }
                    .padding(20)
                }

                Divider()

                // 底部操作区
                bottomActionArea
                    .padding(.horizontal, 20)
                    .padding(.vertical, 12)
                    .padding(.bottom, 8)
            }
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                if item.type == 1 {
                    ToolbarItem(placement: .principal) {
                        // 发送者头像+昵称(可点击)
                        Button {
                            vm.showSenderProfile = true
                        } label: {
                            HStack(spacing: 8) {
                                AsyncImage(url: URL(string: item.avatar)) { img in
                                    img.resizable().scaledToFill()
                                } placeholder: {
                                    Circle().fill(.secondary.opacity(0.2))
                                }
                                .frame(width: 28, height: 28).clipShape(Circle())

                                Text(item.nickname).font(.subheadline.weight(.semibold))
                            }
                        }
                        .foregroundStyle(.primary)
                    }
                }

                ToolbarItem(placement: .topBarTrailing) {
                    Menu {
                        Button {
                            Task { await vm.report() }
                        } label: { Label("举报", systemImage: "exclamationmark.triangle") }

                        Button {
                            Task { await vm.feedbackLowQuality() }
                        } label: { Label("不感兴趣", systemImage: "hand.thumbsdown") }
                    } label: {
                        Image(systemName: "ellipsis.circle")
                    }
                }
            }
            .navigationDestination(isPresented: $vm.showSenderProfile) {
                UserProfileView(uid: item.signal.uid)
            }
            .navigationDestination(isPresented: $vm.showChat) {
                if let conv = vm.conversation {
                    ConversationView(conversation: conv)
                }
            }
        }
        .task { await vm.onAppear() }
    }

    // ── 信号卡片 ──────────────────────────────────────────────────────
    private var signalCard: some View {
        VStack(alignment: .leading, spacing: 12) {
            // 分类标签
            HStack {
                Text(item.signal.categoryName)
                    .font(.caption.weight(.medium))
                    .padding(.horizontal, 10).padding(.vertical, 4)
                    .background(Color.accentColor.opacity(0.15), in: Capsule())
                    .foregroundStyle(.accent)
                Spacer()
                Text(item.signal.createTimeFmt)
                    .font(.caption).foregroundStyle(.secondary)
            }

            // 正文
            if !item.signal.content.isEmpty {
                Text(item.signal.content)
                    .font(.body)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }

            // 图片附件
            let images = item.signal.imageURLs
            if !images.isEmpty {
                LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
                    ForEach(images, id: \.self) { url in
                        AsyncImage(url: URL(string: url)) { img in
                            img.resizable().scaledToFill()
                        } placeholder: {
                            Rectangle().fill(.secondary.opacity(0.2))
                        }
                        .frame(height: 120)
                        .clipShape(RoundedRectangle(cornerRadius: 8))
                    }
                }
            }

            // 语音
            if item.signal.type == 3, let voiceURL = item.signal.voiceURL {
                VoiceMessageBubble(url: voiceURL, duration: item.signal.voiceDuration, isMe: false)
            }
        }
        .padding(16)
        .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
    }

    // ── 对方回复(type=2) ────────────────────────────────────────────
    private func replySection(_ reply: SignalReply) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(spacing: 8) {
                AsyncImage(url: URL(string: reply.avatar)) { img in
                    img.resizable().scaledToFill()
                } placeholder: {
                    Circle().fill(.secondary.opacity(0.2))
                }
                .frame(width: 32, height: 32).clipShape(Circle())

                VStack(alignment: .leading, spacing: 2) {
                    Text(reply.nickname).font(.caption.weight(.semibold))
                    Text(reply.createTimeFmt).font(.caption2).foregroundStyle(.secondary)
                }
            }

            Text(reply.content)
                .font(.body)
                .padding(12)
                .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
        }
    }

    // ── 底部操作区 ────────────────────────────────────────────────────
    @ViewBuilder
    private var bottomActionArea: some View {
        if item.type == 1 {
            // 接收方:回复输入框
            if item.status >= 1 {
                // 已回复
                HStack {
                    Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
                    Text("已回复").foregroundStyle(.secondary)
                }
                .frame(maxWidth: .infinity)
                .padding()
                .background(.green.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
            } else {
                // 待回复
                HStack(spacing: 8) {
                    TextField("回复 \(item.nickname)...", text: $replyText)
                        .textFieldStyle(.roundedBorder)

                    Button {
                        Task { await vm.sendReply(content: replyText) { replyText = "" } }
                    } label: {
                        Image(systemName: "paperplane.fill")
                    }
                    .disabled(replyText.trimmingCharacters(in: .whitespaces).isEmpty || vm.isSendingReply)
                    .glassEffect(.regular.tint(.accent).interactive())
                }
            }
        } else {
            // 发送方:进入聊天按钮
            Button {
                Task { await vm.enterChat() }
            } label: {
                Group {
                    if vm.isEnteringChat {
                        ProgressView().tint(.white)
                    } else {
                        Text("进入聊天").fontWeight(.semibold)
                    }
                }
                .frame(maxWidth: .infinity)
                .frame(height: 50)
            }
            .disabled(vm.isEnteringChat)
            .glassEffect(.regular.tint(.accent).interactive())
        }
    }
}

// ── DetailViewModel ───────────────────────────────────────────────────────
@MainActor
@Observable
final class SignalDetailViewModel {
    let item: SignalInboxItem

    var isSendingReply = false
    var isEnteringChat = false
    var showSenderProfile = false
    var showChat = false
    var conversation: Conversation?

    init(item: SignalInboxItem) { self.item = item }

    func onAppear() async {
        // 标记已读(如未读)
        if item.isRead == 0 {
            try? await SignalRepository.shared.markRead(receiveId: item.receiveId)
        }
    }

    // 发送回复(乐观 UI 由 InboxViewModel 处理,此处仅调接口)
    func sendReply(content: String, onSuccess: @escaping () -> Void) async {
        guard !content.trimmingCharacters(in: .whitespaces).isEmpty else { return }
        isSendingReply = true
        defer { isSendingReply = false }
        do {
            try await SignalRepository.shared.reply(receiveId: item.receiveId, content: content)
            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
            onSuccess()
        } catch let e as AppAPIError {
            AppToast.showError(e.message)
        }
    }

    // 进入聊天(生成 conversationId 后 push)
    func enterChat() async {
        guard let myUid = AppStorage.getString(StorageKeys.userId).flatMap(Int.init) else { return }
        isEnteringChat = true
        defer { isEnteringChat = false }

        let targetUid = item.signal.uid
        let myImUid   = "tb_\(myUid)"
        let targetImUid = "tb_\(targetUid)"
        let sorted    = [myImUid, targetImUid].sorted()
        let convId    = "si_\(sorted[0])_\(sorted[1])"

        // 构建 Conversation 上下文
        conversation = Conversation(
            id: convId,
            targetUid: targetUid,
            nickname: item.nickname,
            avatar: item.avatar,
            fromSignal: true,
            signalId: item.signal.id,
            receiveId: String(item.receiveId),
            signalReply: item.reply?.content,
            signalContent: item.signal.content,
            isImOpened: AppStorage.getBool(StorageKeys.imOpened(convId)) ?? false
        )
        showChat = true
    }

    func report() async {
        try? await SignalRepository.shared.feedbackLowQuality(
            targetType: "signal", targetId: item.signal.id
        )
        AppToast.showSuccess("已举报")
    }

    func feedbackLowQuality() async {
        try? await SignalRepository.shared.feedbackLowQuality(
            targetType: "signal", targetId: item.signal.id
        )
        AppToast.showInfo("已标记不感兴趣")
    }
}
```

---

## 六、筛选 Sheet(SignalFilterSheet)

```swift
// SignalFilterSheet.swift
struct SignalFilterSheet: View {
    @State private var localFilter: SignalFilter
    let onApply: (SignalFilter) -> Void
    @Environment(\.dismiss) private var dismiss

    private let allCategories: [(Int, String)] = [
        (1, "恋爱/感情"), (2, "同城/附近"),
        (3, "游戏/娱乐"), (4, "互换/交易"), (5, "其他")
    ]

    init(filter: SignalFilter, onApply: @escaping (SignalFilter) -> Void) {
        _localFilter = State(initialValue: filter)
        self.onApply = onApply
    }

    var body: some View {
        NavigationStack {
            List {
                // 分类多选
                Section("信号分类") {
                    ForEach(allCategories, id: \.0) { (id, name) in
                        Button {
                            toggleCategory(id)
                        } label: {
                            HStack {
                                Text(name).foregroundStyle(.primary)
                                Spacer()
                                if localFilter.categories.isEmpty || localFilter.categories.contains(id) {
                                    Image(systemName: "checkmark").foregroundStyle(.accent)
                                }
                            }
                        }
                    }
                }

                // 地理范围
                Section("地理范围") {
                    Picker("范围", selection: $localFilter.range) {
                        Text("不限").tag("unlimited")
                        Text("同城附近").tag("nearby")
                    }
                    .pickerStyle(.inline)
                    .labelsHidden()
                }

                // 年龄范围
                Section("年龄范围 \(localFilter.ageMin) - \(localFilter.ageMax) 岁") {
                    RangeSlider(
                        low: $localFilter.ageMin,
                        high: $localFilter.ageMax,
                        bounds: 18...50
                    )
                    .padding(.vertical, 8)
                }
            }
            .navigationTitle("筛选")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("重置") {
                        localFilter = SignalFilter()
                    }
                }
                ToolbarItem(placement: .topBarTrailing) {
                    Button("应用") {
                        onApply(localFilter)
                        dismiss()
                    }
                    .fontWeight(.semibold)
                }
            }
        }
        .presentationDetents([.medium, .large])
    }

    private func toggleCategory(_ id: Int) {
        if localFilter.categories.isEmpty {
            // 全选状态 → 选中其他所有,排除此 id
            localFilter.categories = allCategories.map(\.0).filter { $0 != id }
        } else if localFilter.categories.contains(id) {
            localFilter.categories.removeAll { $0 == id }
            if localFilter.categories.isEmpty {
                // 全部取消 → 恢复全选(空=全部)
                localFilter.categories = []
            }
        } else {
            localFilter.categories.append(id)
            if localFilter.categories.count == allCategories.count {
                localFilter.categories = []   // 全选用空表示
            }
        }
    }
}
```

---

## 七、Toast 辅助(项目已有,仅备查)

```swift
// 在此模块中使用的 Toast 调用方式
AppToast.showSuccess("发布成功")
AppToast.showError("发布失败:\(msg)")
AppToast.showInfo("暂时没有新信号了")
```

---

## 八、路由注册

```swift
// AppCoordinator 或 NavigationDestination 注册:
// 信号收件箱 → 由 MainTabView 的第一个 Tab 挂载
// 信号详情   → NavigationDestination(for: SignalInboxItem.self)
// 发布信号   → NavigationLink(destination: SignalPublishView())
// 他人资料   → NavigationDestination(for: Int.self) { uid in UserProfileView(uid: uid) }
```