提示信息
# 同伴 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) }
```