← API | 列表 | swift_用户模块_个人资料_设置
提示信息
# 同伴 App — Swift 用户模块、个人资料、设置

> 覆盖:个人主页(我的 Tab)、编辑资料、他人主页、黑名单、举报、账号安全、隐私设置、注销。

---

## 一、API 清单

| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 获取编辑页资料 | GET | `/user/profile/edit` | 返回 `LoginUser`(与登录响应相同结构) |
| 修改单个字段 | POST | `/user/profile/update-field` | `{type, value}` |
| 获取他人主页 | GET | `/user/profile/detail?uid=xxx` | 返回 `UserProfileDetail` |
| 批量获取基础资料 | POST | `/user/profile/openim-batch` | `{user_ids, scene}` |
| 拉黑/取消拉黑 | POST | `/user/block` | `{uid, block: true/false}` |
| 获取黑名单 | GET | `/user/block/list` | |
| 举报 | POST | `/user/report` | `{uid, reason}` |
| 修改手机号 | POST | `/user/security/update-phone` | `{phone, code}` |
| 修改密码 | POST | `/user/security/update-password` | `{phone, code, password}` |
| 注销账号 | POST | `/user/account/cancel` | `{reason}` |

---

## 二、数据模型

```swift
// UserModels.swift

// ── 他人主页 ─────────────────────────────────────────────────────────
struct UserProfileDetail: Decodable, Sendable {
    let uid: Int
    let nickname: String
    let avatar: String
    let gender: Int           // 1=男 2=女 0=未知
    let age: Int
    let residence: String     // 居住地显示文本,例 "上海"
    let bio: String
    let mbti: String
    let isVerified: Bool
    let isBlocked: Bool       // 当前用户是否已拉黑TA
    let attribute: String     // 属性标签文本
    let ipLocation: String
    let userStatus: Int       // 0=正常 1=封禁 2=注销
}

// ── 批量基础资料 ──────────────────────────────────────────────────────
struct UserBasicInfo: Decodable, Sendable {
    let uid: Int
    let avatar: String
    let username: String
    let isCertified: Bool
    let isOfficial: Bool
    // 仅 scene=detail 时返回
    let isBlocked: Bool?
    let isOnline: Bool?
}

// ── 黑名单条目 ────────────────────────────────────────────────────────
struct BlockedUser: Decodable, Sendable {
    let uid: Int
    let nickname: String
    let avatar: String
    let blockTime: Int        // Unix 时间戳
}

// ── 资料可编辑字段枚举 ─────────────────────────────────────────────────
enum ProfileField: String {
    case nickname     = "nickname"
    case gender       = "gender"
    case bio          = "bio"
    case mbti         = "mbti"
    case residence    = "residence"   // value 为 JSON 序列化的 Residence 对象
    case avatar       = "avatar"      // value 为 objectKey
    case orientation  = "orientation" // value 为数字字符串
    case birthDate    = "birth_date"  // value 为 "yyyy-MM-dd"
    case attribute    = "attribute"
}
```

---

## 三、UserProfileCache(SQLite 用户资料缓存)

与 Flutter 的 `user_profile_cache.dart` 逻辑对齐,用 GRDB 实现:

```swift
// UserProfileCache.swift
// 目的:IM 会话列表/聊天页快速显示头像昵称,降级网络请求
import GRDB

struct UserProfileRecord: Codable, FetchableRecord, PersistableRecord {
    static var databaseTableName = "user_profiles"

    let uid: Int
    var avatar: String
    var username: String
    var isCertified: Bool
    var updatedAt: Int       // Unix 时间戳
}

actor UserProfileCache {
    static let shared = UserProfileCache()

    private var db: DatabaseQueue?

    func initialize(userId: String) throws {
        let dir = try FileManager.default.url(
            for: .documentDirectory, in: .userDomainMask,
            appropriateFor: nil, create: true
        ).appendingPathComponent("cache_\(userId)")
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)

        let dbPath = dir.appendingPathComponent("user_profiles.sqlite").path
        db = try DatabaseQueue(path: dbPath)
        try db?.write { db in
            try db.create(table: "user_profiles", ifNotExists: true) { t in
                t.column("uid",          .integer).primaryKey()
                t.column("avatar",       .text).notNull().defaults(to: "")
                t.column("username",     .text).notNull().defaults(to: "")
                t.column("isCertified",  .boolean).notNull().defaults(to: false)
                t.column("updatedAt",    .integer).notNull().defaults(to: 0)
            }
        }
    }

    func saveProfiles(_ profiles: [UserBasicInfo]) throws {
        guard let db else { return }
        let now = Int(Date().timeIntervalSince1970)
        try db.write { db in
            for p in profiles {
                let record = UserProfileRecord(
                    uid: p.uid, avatar: p.avatar, username: p.username,
                    isCertified: p.isCertified, updatedAt: now
                )
                try record.save(db)
            }
        }
    }

    func getProfiles(_ uids: [Int]) throws -> [UserBasicInfo] {
        guard let db else { return [] }
        return try db.read { db in
            try UserProfileRecord.filter(uids.contains(Column("uid")))
                .fetchAll(db)
                .map { r in
                    UserBasicInfo(uid: r.uid, avatar: r.avatar, username: r.username,
                                  isCertified: r.isCertified, isOfficial: false,
                                  isBlocked: nil, isOnline: nil)
                }
        }
    }

    func close() {
        db = nil
    }
}
```

---

## 四、我的 Tab — ProfileView

```swift
// ProfileView.swift
struct ProfileView: View {
    @State private var vm = ProfileViewModel()

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 0) {
                    // 头像 + 昵称卡
                    avatarSection
                    // 资料标签
                    tagsSection
                    // 功能入口列表
                    menuSection
                }
            }
            .navigationTitle("我的")
            .navigationBarTitleDisplayMode(.large)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    NavigationLink(destination: SettingsView()) {
                        Image(systemName: "gearshape")
                    }
                }
            }
        }
        .task { await vm.loadProfile() }
    }

    // ── 头像区 ────────────────────────────────────────────────────────
    private var avatarSection: some View {
        VStack(spacing: 12) {
            AsyncImage(url: URL(string: vm.profile?.avatar ?? "")) { image in
                image.resizable().scaledToFill()
            } placeholder: {
                Circle().fill(.secondary.opacity(0.2))
            }
            .frame(width: 80, height: 80)
            .clipShape(Circle())

            VStack(spacing: 4) {
                HStack(spacing: 6) {
                    Text(vm.profile?.nickname ?? "").font(.title3.bold())
                    if vm.profile?.isVerified == true {
                        Image(systemName: "checkmark.seal.fill").foregroundStyle(.blue)
                    }
                }
                if let mbti = vm.profile?.mbti, !mbti.isEmpty {
                    Text(mbti)
                        .font(.caption)
                        .padding(.horizontal, 8).padding(.vertical, 2)
                        .glassEffect()
                }
            }

            NavigationLink("编辑资料") {
                EditProfileView(profile: vm.profile)
            }
            .glassEffect(.regular.interactive())
            .padding(.top, 4)
        }
        .padding(.vertical, 24)
    }

    private var tagsSection: some View {
        // 地区 / 年龄 / 取向等标签横排
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 8) {
                ForEach(vm.profileTags, id: \.self) { tag in
                    Text(tag).font(.caption)
                        .padding(.horizontal, 10).padding(.vertical, 4)
                        .background(.secondary.opacity(0.15), in: Capsule())
                }
            }.padding(.horizontal)
        }
    }

    private var menuSection: some View {
        VStack(spacing: 0) {
            Divider().padding(.vertical, 8)
            // 通知设置、隐私、帮助等入口
            Group {
                menuRow(icon: "bell",      title: "通知设置") { }
                menuRow(icon: "eye.slash", title: "隐私设置") { }
                menuRow(icon: "questionmark.circle", title: "帮助中心") { }
            }
        }
    }

    private func menuRow(icon: String, title: String, action: @escaping () -> Void) -> some View {
        Button(action: action) {
            HStack {
                Image(systemName: icon).frame(width: 24)
                Text(title)
                Spacer()
                Image(systemName: "chevron.right").foregroundStyle(.secondary)
            }
            .padding()
        }
        .foregroundStyle(.primary)
    }
}

// ── ViewModel ─────────────────────────────────────────────────────────

@MainActor
@Observable
final class ProfileViewModel {
    var profile: LoginUser?
    var isLoading = false

    var profileTags: [String] {
        var tags: [String] = []
        if let r = profile?.residence {
            if !r.cityName.isEmpty { tags.append(r.cityName) }
        }
        if let age = profile?.age { tags.append("\(age)岁") }
        if let mbti = profile?.mbti, !mbti.isEmpty { tags.append(mbti) }
        return tags
    }

    func loadProfile() async {
        guard let cached = AppStorage.getString(StorageKeys.userProfile),
              let data = cached.data(using: .utf8),
              let user = try? JSONDecoder().decode(LoginUser.self, from: data) else { return }
        profile = user
    }

    func refresh() async {
        isLoading = true
        defer { isLoading = false }
        if let user = try? await APIClient.shared.get("/user/profile/edit", as: LoginUser.self) {
            profile = user
            if let data = try? JSONEncoder().encode(user),
               let str = String(data: data, encoding: .utf8) {
                AppStorage.setString(StorageKeys.userProfile, str)
            }
        }
    }
}
```

---

## 五、编辑资料页(EditProfileView)

所有字段均调用 `POST /user/profile/update-field`,逐字段保存(不是整体提交)。

```swift
// EditProfileView.swift
struct EditProfileView: View {
    let profile: LoginUser?
    @State private var vm: EditProfileViewModel

    init(profile: LoginUser?) {
        self.profile = profile
        _vm = State(initialValue: EditProfileViewModel(profile: profile))
    }

    var body: some View {
        List {
            // 头像
            Section {
                HStack {
                    Spacer()
                    avatarButton
                    Spacer()
                }
            }
            // 基础信息
            Section("基础信息") {
                editRow(label: "昵称", value: vm.nickname) {
                    vm.editingField = .nickname
                }
                editRow(label: "性别", value: vm.genderLabel) {
                    vm.editingField = .gender
                }
                editRow(label: "生日", value: vm.birthDate) {
                    vm.showBirthPicker = true
                }
                editRow(label: "常住地", value: vm.residenceLabel) {
                    vm.showRegionPicker = true
                }
            }
            // 性格标签
            Section("性格") {
                editRow(label: "MBTI", value: vm.mbti.isEmpty ? "未填写" : vm.mbti) {
                    vm.editingField = .mbti
                }
            }
            // 个人简介
            Section("简介") {
                editRow(label: "个性签名", value: vm.bio.isEmpty ? "写点什么..." : vm.bio, multiline: true) {
                    vm.editingField = .bio
                }
            }
        }
        .navigationTitle("编辑资料")
        .navigationBarTitleDisplayMode(.inline)
        // 单字段编辑 Sheet
        .sheet(item: $vm.editingField) { field in
            FieldEditSheet(field: field, currentValue: vm.value(for: field)) { newValue in
                Task { await vm.updateField(field, value: newValue) }
            }
        }
        // 生日选择 Sheet
        .sheet(isPresented: $vm.showBirthPicker) {
            DatePickerSheet(date: vm.birthDateValue) { date in
                Task { await vm.updateBirthDate(date) }
            }
        }
        // 省市选择 Sheet
        .sheet(isPresented: $vm.showRegionPicker) {
            RegionPickerSheet(current: vm.residence) { region in
                Task { await vm.updateResidence(region) }
            }
        }
    }

    // 头像按钮
    private var avatarButton: some View {
        Button {
            Task { @MainActor in
                guard let vc = UIApplication.topViewController else { return }
                do {
                    let result = try await MediaPipeline.pickAndUploadAvatar(from: vc)
                    await vm.updateField(.avatar, value: result.objectKey)
                } catch { }
            }
        } label: {
            AsyncImage(url: URL(string: vm.avatarURL)) { img in
                img.resizable().scaledToFill()
            } placeholder: {
                Circle().fill(.secondary.opacity(0.2))
            }
            .frame(width: 80, height: 80)
            .clipShape(Circle())
            .overlay(alignment: .bottomTrailing) {
                Image(systemName: "camera.fill")
                    .font(.caption)
                    .padding(6)
                    .glassEffect()
            }
        }
    }

    private func editRow(
        label: String, value: String,
        multiline: Bool = false,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            HStack {
                Text(label).foregroundStyle(.primary)
                Spacer()
                Text(value)
                    .foregroundStyle(.secondary)
                    .lineLimit(multiline ? 2 : 1)
                Image(systemName: "chevron.right").foregroundStyle(.tertiary).font(.caption)
            }
        }
        .foregroundStyle(.primary)
    }
}

// ── EditProfileViewModel ───────────────────────────────────────────────

@MainActor
@Observable
final class EditProfileViewModel {
    var nickname: String
    var bio: String
    var mbti: String
    var birthDate: String
    var avatarURL: String
    var gender: Int
    var residence: Residence?
    var editingField: ProfileField?
    var showBirthPicker = false
    var showRegionPicker = false

    var genderLabel: String {
        switch gender {
        case 1: return "男"
        case 2: return "女"
        default: return "未设置"
        }
    }

    var residenceLabel: String {
        guard let r = residence else { return "未设置" }
        return [r.provinceName, r.cityName].filter { !$0.isEmpty }.joined(separator: " ")
    }

    var birthDateValue: Date {
        let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
        return fmt.date(from: birthDate) ?? Date()
    }

    init(profile: LoginUser?) {
        nickname  = profile?.nickname  ?? ""
        bio       = profile?.bio       ?? ""
        mbti      = profile?.mbti      ?? ""
        birthDate = profile?.birthDate ?? ""
        avatarURL = profile?.avatar    ?? ""
        gender    = profile?.gender    ?? 0
        residence = profile?.residence
    }

    func value(for field: ProfileField) -> String {
        switch field {
        case .nickname:    return nickname
        case .bio:         return bio
        case .mbti:        return mbti
        default:           return ""
        }
    }

    func updateField(_ field: ProfileField, value: String) async {
        do {
            try await APIClient.shared.post("/user/profile/update-field", body: [
                "type": field.rawValue,
                "value": value
            ] as [String: Any])
            // 更新本地状态
            switch field {
            case .nickname: nickname = value
            case .bio:      bio = value
            case .mbti:     mbti = value
            case .avatar:   break  // URL 需要重新签名后更新,此处先不更新
            default: break
            }
            // 更新 MMKV 缓存
            await refreshLocalCache()
        } catch let e as AppAPIError {
            // Toast 展示错误
        }
    }

    func updateBirthDate(_ date: Date) async {
        let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
        let str = fmt.string(from: date)
        await updateField(.birthDate, value: str)
        birthDate = str
    }

    func updateResidence(_ region: Residence) async {
        guard let json = try? JSONEncoder().encode(region),
              let str  = String(data: json, encoding: .utf8) else { return }
        await updateField(.residence, value: str)
        residence = region
    }

    private func refreshLocalCache() async {
        if let user = try? await APIClient.shared.get("/user/profile/edit", as: LoginUser.self),
           let data = try? JSONEncoder().encode(user),
           let str  = String(data: data, encoding: .utf8) {
            AppStorage.setString(StorageKeys.userProfile, str)
        }
    }
}
```

---

## 六、他人主页(UserProfileView)

```swift
// UserProfileView.swift
struct UserProfileView: View {
    let uid: Int
    @State private var vm = UserProfileViewModel()

    var body: some View {
        ScrollView {
            if let detail = vm.detail {
                VStack(spacing: 16) {
                    // 头像 + 状态
                    profileHeader(detail)
                    // 简介
                    if !detail.bio.isEmpty {
                        Text(detail.bio)
                            .font(.body)
                            .padding()
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
                            .padding(.horizontal)
                    }
                    // 操作按钮
                    actionButtons(detail)
                }
            } else if vm.isLoading {
                ProgressView().padding(.top, 60)
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                moreMenu
            }
        }
        .task { await vm.load(uid: uid) }
    }

    private func profileHeader(_ d: UserProfileDetail) -> some View {
        VStack(spacing: 12) {
            AsyncImage(url: URL(string: d.avatar)) { img in
                img.resizable().scaledToFill()
            } placeholder: {
                Circle().fill(.secondary.opacity(0.2))
            }
            .frame(width: 80, height: 80).clipShape(Circle())

            HStack(spacing: 6) {
                Text(d.nickname).font(.title3.bold())
                if d.isVerified {
                    Image(systemName: "checkmark.seal.fill").foregroundStyle(.blue)
                }
            }

            // 标签:MBTI / 居住地 / IP 归属
            HStack(spacing: 8) {
                if !d.mbti.isEmpty { tag(d.mbti) }
                if !d.residence.isEmpty { tag(d.residence) }
                if !d.ipLocation.isEmpty { tag("IP: \(d.ipLocation)") }
            }
        }
        .padding(.top, 24)
    }

    private func actionButtons(_ d: UserProfileDetail) -> some View {
        GlassEffectContainer {
            HStack(spacing: 12) {
                // 发私信(仅 isImOpened 时才有,否则需要通过信号)
                Button {
                    // 打开聊天
                } label: {
                    Label("私信", systemImage: "message")
                }
                .glassEffect(.regular.interactive())

                // 拉黑
                Button {
                    Task { await vm.toggleBlock(uid: uid) }
                } label: {
                    Label(d.isBlocked ? "已拉黑" : "拉黑", systemImage: d.isBlocked ? "person.fill.xmark" : "person.badge.minus")
                }
                .glassEffect(.regular.interactive())
            }
        }
        .padding(.horizontal)
    }

    private var moreMenu: some View {
        Menu {
            Button(role: .destructive) {
                Task { await vm.report(uid: uid) }
            } label: {
                Label("举报", systemImage: "exclamationmark.triangle")
            }
        } label: {
            Image(systemName: "ellipsis.circle")
        }
    }

    private func tag(_ text: String) -> some View {
        Text(text).font(.caption)
            .padding(.horizontal, 8).padding(.vertical, 3)
            .background(.secondary.opacity(0.15), in: Capsule())
    }
}

@MainActor
@Observable
final class UserProfileViewModel {
    var detail: UserProfileDetail?
    var isLoading = false

    func load(uid: Int) async {
        isLoading = true
        defer { isLoading = false }
        detail = try? await APIClient.shared.get(
            "/user/profile/detail", params: ["uid": uid],
            as: UserProfileDetail.self
        )
    }

    func toggleBlock(uid: Int) async {
        guard let current = detail else { return }
        let newState = !current.isBlocked
        if (try? await APIClient.shared.post("/user/block",
            body: ["uid": uid, "block": newState] as [String: Any])) != nil {
            detail = UserProfileDetail(
                uid: current.uid, nickname: current.nickname, avatar: current.avatar,
                gender: current.gender, age: current.age, residence: current.residence,
                bio: current.bio, mbti: current.mbti, isVerified: current.isVerified,
                isBlocked: newState, attribute: current.attribute,
                ipLocation: current.ipLocation, userStatus: current.userStatus
            )
        }
    }

    func report(uid: Int) async {
        // 弹出 ActionSheet 选举报原因
    }
}
```

---

## 七、设置页(SettingsView)

```swift
// SettingsView.swift
struct SettingsView: View {
    @Environment(AppCoordinator.self) private var coordinator
    @State private var showLogoutAlert = false

    var body: some View {
        List {
            Section("账号与安全") {
                NavigationLink("账号安全") { AccountSecurityView() }
                NavigationLink("隐私设置") { PrivacySettingsView() }
                NavigationLink("黑名单")   { BlocklistView() }
            }
            Section("通用") {
                NavigationLink("缓存管理") { CacheManagementView() }
                NavigationLink("关于同伴") { AboutView() }
            }
            Section {
                Button("退出登录", role: .destructive) {
                    showLogoutAlert = true
                }
            }
        }
        .navigationTitle("设置")
        .alert("退出登录", isPresented: $showLogoutAlert) {
            Button("退出", role: .destructive) {
                Task { await AuthService.shared.logout() }
            }
            Button("取消", role: .cancel) { }
        }
    }
}
```

---

## 八、账号安全(修改手机号 / 修改密码 / 注销)

```swift
// AccountSecurityView.swift
struct AccountSecurityView: View {
    @State private var vm = AccountSecurityViewModel()

    var body: some View {
        List {
            Section {
                Button("修改手机号") { vm.sheet = .changePhone }
                Button("修改密码")   { vm.sheet = .changePassword }
            }
            Section {
                Button("注销账号", role: .destructive) { vm.sheet = .cancelAccount }
            }
        }
        .navigationTitle("账号安全")
        .sheet(item: $vm.sheet) { sheet in
            switch sheet {
            case .changePhone:    ChangePhoneView()
            case .changePassword: ChangePasswordView()
            case .cancelAccount:  CancelAccountView()
            }
        }
    }
}

// ── 修改手机号 ─────────────────────────────────────────────────────────
@MainActor
@Observable
final class AccountSecurityViewModel {
    enum SheetType: String, Identifiable {
        case changePhone, changePassword, cancelAccount
        var id: String { rawValue }
    }
    var sheet: SheetType?
}

// ── 注销账号 ──────────────────────────────────────────────────────────
struct CancelAccountView: View {
    @State private var reason = ""
    @State private var showConfirm = false
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            VStack(alignment: .leading, spacing: 16) {
                Text("注销账号后,您的所有数据将被永久删除,且无法恢复。")
                    .foregroundStyle(.secondary)

                TextEditor(text: $reason)
                    .frame(height: 120)
                    .padding(8)
                    .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
                    .overlay(
                        reason.isEmpty ? Text("请输入注销原因(可选)").foregroundStyle(.tertiary).padding(12) : nil,
                        alignment: .topLeading
                    )

                Spacer()

                Button("确认注销", role: .destructive) {
                    showConfirm = true
                }
                .frame(maxWidth: .infinity)
                .glassEffect()
            }
            .padding()
            .navigationTitle("注销账号")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("取消") { dismiss() }
                }
            }
            .alert("确认注销?", isPresented: $showConfirm) {
                Button("注销账号", role: .destructive) {
                    Task {
                        try? await APIClient.shared.post(
                            "/user/account/cancel",
                            body: ["reason": reason] as [String: Any]
                        )
                        await AuthService.shared.logout()
                    }
                }
                Button("取消", role: .cancel) { }
            } message: {
                Text("此操作不可撤销,所有数据将被永久删除。")
            }
        }
    }
}
```

---

## 九、举报(ReportView)

从他人主页和聊天长按菜单触发:

```swift
// ReportView.swift
struct ReportView: View {
    let uid: Int
    @State private var selectedReason: String?
    @Environment(\.dismiss) private var dismiss

    static let reasons = [
        "色情低俗内容",
        "骚扰谩骂",
        "诈骗欺诈",
        "虚假信息",
        "垃圾广告",
        "其他"
    ]

    var body: some View {
        NavigationStack {
            List(ReportView.reasons, id: \.self) { reason in
                Button {
                    selectedReason = reason
                } label: {
                    HStack {
                        Text(reason)
                        Spacer()
                        if selectedReason == reason {
                            Image(systemName: "checkmark").foregroundStyle(.accent)
                        }
                    }
                }
                .foregroundStyle(.primary)
            }
            .navigationTitle("举报")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("取消") { dismiss() }
                }
                ToolbarItem(placement: .topBarTrailing) {
                    Button("提交") {
                        guard let reason = selectedReason else { return }
                        Task {
                            try? await APIClient.shared.post(
                                "/user/report",
                                body: ["uid": uid, "reason": reason] as [String: Any]
                            )
                            dismiss()
                        }
                    }
                    .disabled(selectedReason == nil)
                }
            }
        }
        .presentationDetents([.medium])
    }
}
```

---

## 十、黑名单(BlocklistView)

```swift
// BlocklistView.swift
struct BlocklistView: View {
    @State private var blockedUsers: [BlockedUser] = []
    @State private var isLoading = true

    var body: some View {
        List {
            if isLoading {
                ProgressView()
            } else if blockedUsers.isEmpty {
                ContentUnavailableView("黑名单为空", systemImage: "person.crop.circle.badge.checkmark")
            } else {
                ForEach(blockedUsers, id: \.uid) { user in
                    HStack {
                        AsyncImage(url: URL(string: user.avatar)) { img in
                            img.resizable().scaledToFill()
                        } placeholder: {
                            Circle().fill(.secondary.opacity(0.2))
                        }
                        .frame(width: 40, height: 40).clipShape(Circle())

                        VStack(alignment: .leading, spacing: 2) {
                            Text(user.nickname).font(.body)
                            Text(Date(timeIntervalSince1970: TimeInterval(user.blockTime)),
                                 style: .relative)
                                .font(.caption).foregroundStyle(.secondary)
                        }

                        Spacer()

                        Button("移除") {
                            Task { await removeBlock(uid: user.uid) }
                        }
                        .glassEffect(.regular.interactive())
                    }
                }
            }
        }
        .navigationTitle("黑名单")
        .task { await loadBlocklist() }
    }

    private func loadBlocklist() async {
        isLoading = true
        blockedUsers = (try? await APIClient.shared.get("/user/block/list",
                                                         as: [BlockedUser].self)) ?? []
        isLoading = false
    }

    private func removeBlock(uid: Int) async {
        try? await APIClient.shared.post("/user/block",
                                          body: ["uid": uid, "block": false] as [String: Any])
        blockedUsers.removeAll { $0.uid == uid }
    }
}
```

---

## 十一、批量用户资料查询(IM 会话列表使用)

```swift
// ConversationListViewModel 中的用法示例
func loadUserProfiles(uids: [Int]) async {
    // 先查 SQLite 缓存
    let cached = (try? await UserProfileCache.shared.getProfiles(uids)) ?? []
    let cachedUIDs = Set(cached.map(\.uid))
    let missingUIDs = uids.filter { !cachedUIDs.contains($0) }

    // 仅对缺失的 UID 发起网络请求
    guard !missingUIDs.isEmpty else { return }
    let fetched = (try? await APIClient.shared.post(
        "/user/profile/openim-batch",
        body: ["user_ids": missingUIDs, "scene": "list"] as [String: Any],
        as: [UserBasicInfo].self
    )) ?? []

    // 写入缓存
    try? await UserProfileCache.shared.saveProfiles(fetched)
}
```