<template> <view :class="['app-container', theme]...
Creado el: 24 de diciembre de 2025
Respondido usando GPT-5.2 Thinking por Chat01
Creado el: 24 de diciembre de 2025
Respondido usando GPT-5.2 Thinking por Chat01
</template> <script setup> import ajax from '@/api/ajax.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import Selectfriends from '@/components/Selectfriends.vue' import { usecontacts } from '@/store/usecontacts.js' import { storeToRefs } from 'pinia' import { ref, onMounted, watch, inject, computed } from 'vue' import { useMainStore } from '@/store/index.js' import { onLoad, onUnload } from '@dcloudio/uni-app' // 全局主题 const mainStore = useMainStore() const { theme } = storeToRefs(mainStore) // 顶部安全区与标题栏高度 const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const fixedHeaderTotal = computed(() => Number(statusBarHeight.value || 0) + Number(headerBarHeight.value || 0)) // store const contactsStore = usecontacts() const { keywords, Selectgroupmembers, consumerId } = storeToRefs(contactsStore) const { Creategroupchat, CreateCastingTeam } = contactsStore // ✅ 新增 CreateCastingTeam(castingTeam) // 返回上一页 const navigateBack = () => { uni.navigateBack() } const webProjectUrl = ref(inject('webProjectUrl')) function fullAvatar(path = '') { const trimmed = String(path || '').trim() if (!trimmed) return '/static/default/avatar.png' if (/^https?:\/\//i.test(trimmed)) return trimmed return (webProjectUrl.value || '') + trimmed } // 选中成员 const addFriend = ref([]) const Grouplength = ref(0) const Searchvalue = ref('') const InitialID = ref([]) // 传给 Selectfriends:用于外部删除时同步取消选中 const Acceptdata = (val) => { if (!val) return addFriend.value = val Selectgroupmembers.value = val // 这里保持你原来的写法:Selectgroupmembers => 只存 id Selectgroupmembers.value = (Selectgroupmembers.value || []).map(item => item.id) const userid = uni.getStorageSync('consumerId') if (userid) Selectgroupmembers.value.push(userid) } // 删除已选成员(修复你原来 splice(id,1) 的问题) const delestoption = (items) => { const id = items?.id if (!id) return const idx = addFriend.value.findIndex(item => item.id === id) if (idx !== -1) { addFriend.value.splice(idx, 1) InitialID.value = id // ✅ 通知 Selectfriends 取消选中 } } watch(addFriend, (val) => { Grouplength.value = (val || []).length }, { deep: true, immediate: true }) // ✅ 组中组:默认两个,可增删改名 const subGroupFold = ref(false) const subGroups = ref([{ _localId: `d1_${Date.now()}`, title: '剧本围读组' }, { _localId: `d2_${Date.now()}`, title: '摄影组' } ]) function toggleSubGroupFold() { subGroupFold.value = !subGroupFold.value } function addSubGroup() { subGroups.value.push({ _localId: `n_${Date.now()}_${Math.random().toString(16).slice(2)}`, title: '' }) } function removeSubGroup(idx) { subGroups.value.splice(idx, 1) } // 页面进入时清理一下(避免影响别的页面复用 store) onLoad(() => { try { keywords.value = '' Selectgroupmembers.value = [] addFriend.value = [] InitialID.value = [] Searchvalue.value = '' } catch (e) {} }) onUnload(() => { // 不强制清理也行,但为了不影响“建群页面”体验,退出时做一次轻量清理 try { Selectgroupmembers.value = [] } catch (e) {} }) // ✅ 完成:先建大组 castingTeam,再用 parentId 建小组(0~N 个) const complete = async () => { if (!Selectgroupmembers.value || Selectgroupmembers.value.length <= 0) { uni.showToast({ title: '请添加好友', icon: 'none' }) return } if (!keywords.value) { uni.showToast({ title: '请输入咖组名称', icon: 'none', duration: 2000 }) return } uni.showLoading({ title: '创建中...', mask: true }) try { const consumerIds = Selectgroupmembers.value // 1)创建大组 const bigRes = await CreateCastingTeam({ id: '', parentId: '', title: keywords.value, notice: '', consumerIds }) console.log(bigRes,'里面创建大咖') const okBig = bigRes?.data?.result === 'success' && bigRes?.data?.id if (!okBig) { uni.showToast({ title: bigRes?.data?.message || '创建咖组失败', icon: 'none' }) return } const bigId = bigRes.data.id // 2)创建小组(可为 0 个;title 为空的不建) const createList = (subGroups.value || []) .map(x => ({ ...x, title: String(x?.title || '').trim() })) .filter(x => !!x.title) for (const sg of createList) { await CreateCastingTeam({ id: '', parentId: bigId, title: sg.title, notice: '', consumerIds }) } uni.showToast({ title: '创建成功', icon: 'none' }) // ✅ 跳转逻辑你项目里可能有咖组聊天页,这里默认返回上一页不做假设 setTimeout(() => { uni.hideLoading() uni.navigateBack() }, 300) } catch (e) { uni.hideLoading() uni.showToast({ title: '网络异常', icon: 'none' }) } finally { uni.hideLoading() } } </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container {} .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } /* ✅ 固定 Header */ .header-fixed { position: fixed; left: 0; right: 0; top: 0; width: 94%; margin: 0 auto; z-index: 999; } .header-inner { display: flex; flex-direction: row; align-items: center; justify-content: space-between; height: 100%; } .left-box, .right-box { width: auto; display: flex; justify-content: center; align-items: center; } .center-box { flex: 1; display: flex; justify-content: center; align-items: center; } .center-title { font-size: 28rpx; color: #111; font-weight: 600; } .btn-white { border-radius: 12rpx; height: 60rpx; width: 120rpx; background-color: #ffffff; display: flex; justify-content: center; align-items: center; } .right-box { height: 60rpx; width: 120rpx; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; } .boxback { background-color: var(--box-back-color); } /* ✅ 可滚动主体 */ .scroll-body { height: 88vh; border: 1rpx solid red; } .card-line { width: 100%; height: 76rpx; border-radius: 12rpx; background-color: #ffffff; display: flex; justify-content: center; align-items: center; } .card-avatars { width: 100%; height: 164rpx; background-color: #ffffff; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; margin-top: 16rpx; position: relative; } .avatar-box { height: 100rpx; width: 100rpx; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; position: relative; } .avatar-img { height: 100%; width: 100%; border-radius: 12rpx; } .avatar-close { display: flex; justify-content: center; align-items: center; position: absolute; border-radius: 50%; height: 40rpx; width: 40rpx; background-color: rgba(0, 0, 0, 0.6); right: 0%; top: 0%; } .avatar-count { position: absolute; bottom: 4%; right: 2%; font-size: 24rpx; } /* ✅ 组中组模块样式(贴近截图) */ .subgroup-card { margin-top: 16rpx; background: #ffffff; border-radius: 12rpx; overflow: hidden; } .subgroup-header { height: 76rpx; padding: 0 24rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #f0f0f0; } .subgroup-title { font-size: 28rpx; color: #111; font-weight: 600; } .subgroup-arrow { font-size: 26rpx; color: #999; transform: rotate(180deg); transition: transform 0.15s ease; } .subgroup-arrow.up { transform: rotate(180deg); } .subgroup-arrow.down { transform: rotate(0deg); } .subgroup-body { padding: 12rpx 0 16rpx; } .subgroup-row { margin: 0 16rpx; height: 76rpx; border-radius: 12rpx; background: #f2f2f2; display: flex; align-items: center; justify-content: space-between; padding: 0 18rpx; margin-top: 12rpx; } .subgroup-left { flex: 1; padding-right: 18rpx; } .subgroup-input { width: 100%; font-size: 28rpx; color: #111; } .subgroup-right { width: 96rpx; display: flex; justify-content: flex-end; font-size: 28rpx; color: #666; } .subgroup-add { margin-top: 12rpx; padding: 0 24rpx; display: flex; justify-content: flex-end; font-size: 28rpx; color: #666; } /* 搜索 */ .search-box { width: 100%; height: 60rpx; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; margin-top: 18rpx; border: 1rpx solid #cccccc; background: #fff; } </style>这个组件的<template>text<!-- 中:标题(按你要求:Createcoffeegroup 内容固定不动) --> <view class="center-box"> <text @click="complete()" class="center-title">新建咖组</text> </view> <!-- 右:完成 --> <view class="right-box boxback" @click="complete()"> <text>完成</text> </view> </view> </view> <!-- ✅ 中间内容:可滚动 --> <scroll-view scroll-y class="scroll-body" :style="{ paddingTop: fixedHeaderTotal + 'px' }"> <view style="width: 100%;display: flex;justify-content: center;align-items: center;"> <view style="width: 94%;"> <!-- 咖组名称 --> <view class="card-line" style="margin-top: 32rpx;"> <view style="width: 90%;height: 34rpx;display: flex;align-items: center;"> <view style="width: 30%;display: flex;"> 咖组名称 <text style="margin-left: 10rpx;">:</text> </view> <view style="width: 70%;display: flex;"> <input v-model="keywords" placeholder="请输入" type="text" /> </view> </view> </view> <!-- 已选成员头像 --> <view class="card-avatars"> <view style="height:100rpx;width: 90%;display: flex;align-items: center;"> <scroll-view scroll-x style="width: 100%;white-space: nowrap;overflow: scroll;"> <view style="width: 100%;display: flex;flex-direction: row;align-items: center;"> <view v-for="(items,indexs) in addFriend" :key="items.id" style="display: flex;align-items: center;"> <view class="avatar-box" :style="{marginLeft:indexs===0?'0':'20rpx'}"> <image class="avatar-img" :src="fullAvatar(items.avatarUrl)" mode="aspectFill" /> <view class="avatar-close" @click.stop="delestoption(items,indexs)"> <view style="font-size: 20rpx;color: #ffffff;">X</view> </view> </view> </view> </view> </scroll-view> </view> <view class="avatar-count">{{ `${Grouplength}/2000` }}</view> </view> <!-- ✅ 组中组模块(按图片) --> <view class="subgroup-card"> <view class="subgroup-header" @click="toggleSubGroupFold"> <view class="subgroup-title"> 组中组({{ subGroups.length }}) </view> <view class="subgroup-arrow" :class="subGroupFold ? 'down' : 'up'">^</view> </view> <view v-if="!subGroupFold" class="subgroup-body"> <view v-for="(g, idx) in subGroups" :key="g._localId" class="subgroup-row"> <view class="subgroup-left"> <input class="subgroup-input" v-model="g.title" placeholder="(点击可修改名称)" placeholder-style="color:#999;font-size:24rpx;" /> </view> <view class="subgroup-right" @click.stop="removeSubGroup(idx)"> 删除 </view> </view> <view class="subgroup-add" @click="addSubGroup"> 添加 </view> </view> </view> <!-- 搜索(原样保留) --> <view class="search-box"> <view style="width: 94%;height: 28rpx;"> <view style="width: 100%;display: flex;justify-content: space-between;height: 100%;"> <view style="width:5%;display: flex;"> <view style="height: 100%;width: 100%;display: flex;justify-content: center;align-items: center;"> <uni-icons type="search" color="#cccccc" size="25"></uni-icons> </view> </view> <view style="width: 95%;display: flex;height: 100%;"> <view style="height: 100%;width: 100%;display: flex;justify-content: center;align-items: center;margin-left: 10rpx;"> <input style="height: 100%;width: 100%;" v-model="Searchvalue" type="search" placeholder="请输入" /> </view> </view> </view> </view> </view> <!-- 联系人选择(公用 Selectfriends,保持原逻辑 + 新增在线状态) --> <view style="height: 80vh;"> <Selectfriends :InitialID="InitialID" :keyword="Searchvalue" @seddata="Acceptdata" /> </view> </view> </view> </scroll-view> </view> </view> </view>
</template> <script setup> import { onMounted, ref, nextTick, onActivated, onUnmounted, reactive, computed, watch } from 'vue' import { onPageScroll, onShow } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { usecontacts } from '@/store/usecontacts.js' import { useMainStore } from '@/store/index.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' const props = defineProps({ isinvite: { type: Boolean, default: false }, mode: { type: String, default: 'ordinary' }, existingMemberIds: { type: Array, default: () => [] }, existingManagerIds: { type: Array, default: () => [] }, // ✅ 外部删除头像时传入 id(或数组)用于取消选中 InitialID: { type: [String, Number, Array], default: '' }, // ✅ 搜索关键字(Createcoffeegroup 里输入框) keyword: { type: String, default: '' } }) const emit = defineEmits(['seddata']) /** ---- 全局主仓库:在线状态 ---- */ const mainStore = useMainStore() const { onlineById } = storeToRefs(mainStore) /** ---- 联系人数据仓库 ---- */ const store = usecontacts() const { indexList, groupedItemArr, webProjectUrl, navOffsetTop, itemArr } = storeToRefs(store) const { getInitial, setNavOffsetTop, refreshIndexLayoutFromStore, getGetcontacts } = store const statusBarH = ref(20) const titleBarH = ref(44) const customNavFix = ref(0) const ready = ref(false) const indexListKey = ref(0) const pageScrollTop = ref(0) const stableScrollTop = ref(0) let scrollTimer = null const listHeight = ref(0) const indexListInlineStyle = ref('height:0px;') function itemId(it) { return String(it?.id ?? it?.consumerId ?? '') } function fullAvatar(path = '') { const trimmed = String(path || '').trim() if (!trimmed) return '/static/default/avatar.png' if (/^https?:\/\//i.test(trimmed)) return trimmed return (webProjectUrl.value || '') + trimmed } function measureHeaderAndSetOffset() { try { let sb = getStatusBarHeight() // #ifdef APP-PLUS try { if (typeof plus !== 'undefined' && plus?.navigator?.getStatusbarHeight) { sb = plus.navigator.getStatusbarHeight() || sb } } catch (e) {} // #endif statusBarH.value = sb titleBarH.value = getTitleBarHeight() setNavOffsetTop(titleBarH.value) } catch (e) { statusBarH.value = 20 titleBarH.value = 44 setNavOffsetTop(44) } } function calcListHeight() { try { const sys = uni.getSystemInfoSync() const winH = Number(sys?.windowHeight || 0) const h = Math.max(0, winH - Number(titleBarH.value || 0)) listHeight.value = h indexListInlineStyle.value = `height:${h}px;min-height:${h}px;` } catch (e) { listHeight.value = 600 indexListInlineStyle.value = `height:600px;min-height:600px;` } } async function lightRefresh(reason = '') { await nextTick() refreshIndexLayoutFromStore(reason) } function onAnyImageSettled() { lightRefresh('img-settled') } /** ✅ 搜索过滤:不动 store 的 groupedItemArr,只在组件内过滤 */ const filteredGroupedArr = computed(() => { const kw = String(props.keyword || '').trim().toLowerCase() if (!kw) return groupedItemArr.value || [] return (groupedItemArr.value || []).map(group => { const g = Array.isArray(group) ? group : [] return g.filter(it => { const name = String(it?.nickname || it?.name || '').toLowerCase() return name.includes(kw) }) }) }) /** ✅ 选择逻辑(保留) */ const selectList = reactive(new Set()) const disabledMemberSet = computed(() => new Set((props.existingMemberIds || []).map(v => String(v)))) const disabledManagerSet = computed(() => new Set((props.existingManagerIds || []).map(v => String(v)))) const isDisabled = (id) => props.mode === 'ordinary' ? disabledMemberSet.value.has(String(id)) : disabledManagerSet.value.has(String(id)) function dotClass(id) { return { checked: selectList.has(id), disabled: isDisabled(id) } } function emitSelected() { const selected = (itemArr.value || []) .filter(p => selectList.has(itemId(p))) .map(p => ({ ...p, id: p.id ?? p.consumerId })) emit('seddata', selected) } function onRowTap(val) { const id = itemId(val) if (!val || !id || isDisabled(id)) return selectList.has(id) ? selectList.delete(id) : selectList.add(id) emitSelected() } /** ✅ 外部删除头像:传 InitialID 进来,组件取消选中 */ watch( () => props.InitialID, (v) => { if (!v) return const arr = Array.isArray(v) ? v : [v] arr.map(String).forEach(id => { if (selectList.has(id)) selectList.delete(id) }) emitSelected() indexListKey.value++ nextTick(() => lightRefresh('initialid-changed')) }, { deep: true } ) watch( () => [props.mode, props.existingMemberIds, props.existingManagerIds], () => { for (const id of Array.from(selectList)) { if (isDisabled(id)) selectList.delete(id) } emitSelected() indexListKey.value++ nextTick(() => lightRefresh('props-changed')) }, { deep: true, immediate: true } ) /** ✅ 在线状态拉取(保留) */ async function fetchContactsPresenceOnce() { const ids = (Array.isArray(itemArr.value) ? itemArr.value : []) .map((i) => String(i?.id ?? i?.consumerId ?? '')) .filter(Boolean) if (!ids.length) return await mainStore.fetchOnlineStatusForList(ids) } onMounted(async () => { measureHeaderAndSetOffset() calcListHeight() await getGetcontacts() await fetchContactsPresenceOnce() await nextTick() ready.value = true await nextTick() lightRefresh('mounted') }) watch( itemArr, async (newList, oldList) => { const oldSet = new Set((oldList || []).map((i) => String(i?.id)).filter(Boolean)) const newIds = (newList || []).map((i) => String(i?.id)).filter(Boolean) const delta = newIds.filter((id) => !oldSet.has(id)) if (delta.length) await mainStore.fetchOnlineStatusForList(delta) }, { deep: false } ) onShow(() => { calcListHeight() lightRefresh('onShow') }) onActivated(() => { calcListHeight() lightRefresh('onActivated') }) onPageScroll((e) => { pageScrollTop.value = e?.scrollTop || 0 clearTimeout(scrollTimer) scrollTimer = setTimeout(() => { stableScrollTop.value = pageScrollTop.value }, 16) }) onUnmounted(() => { clearTimeout(scrollTimer) scrollTimer = null }) function resetSelection() { selectList.clear() emit('seddata', []) } defineExpose({ resetSelection }) </script> <style lang="scss" scoped> .page { background: #fff; min-height: 100vh; } .page-body { height: 100%; } .scroll-container { height: 100%; } .group { width: 100%; display: flex; flex-direction: column; } .row { width: 100%; height: 130rpx; display: flex; justify-content: center; align-items: center; } .row+.row { border-top: 1rpx solid #f0f0f0; } .row--disabled { opacity: 0.5; } .row-inner { width: 100%; display: flex; box-sizing: border-box; justify-content: space-between; height: 100%; } .row-left { width: 30%; display: flex; box-sizing: border-box; height: 100%; } .left-wrap { height: 100%; width: 100%; display: flex; justify-content: flex-start; align-items: center; gap: 16rpx; padding-left: 30rpx; } .check-dot { width: 32rpx; height: 32rpx; border-radius: 50%; border: 2rpx solid #dcdcdc; } .check-dot.checked { background: #d89833; border-color: #d89833; } .check-dot.disabled { opacity: 0.5; } .avatar { height: 100rpx; width: 100rpx; border-radius: 12rpx; overflow: hidden; image { height: 100%; width: 100%; border-radius: 12rpx; } } .row-right { width: 70%; display: flex; box-sizing: border-box; } .right-wrap { height: 100%; width: 100%; display: flex; flex-direction: column; justify-content: center; border-bottom: 1rpx solid #eaeaea; padding-right: 20rpx; } .row:last-of-type .right-wrap { border-bottom: none; } .name-line { display: flex; align-items: center; gap: 10rpx; font-size: 30rpx; color: #111; } .name { font-weight: 500; } .initial { color: #d9534f; font-size: 24rpx; } .tag-disabled { margin-left: 10rpx; font-size: 22rpx; color: #999; padding: 2rpx 8rpx; border-radius: 8rpx; background: #f5f5f5; } .status-line { margin-top: 20rpx; color: #666; font-size: 24rpx; } .index-item--empty { height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; } .anchor { font-size: 28rpx; color: #888; padding-left: 30rpx; background: #fafafa; } .anchor--empty { height: 0 !important; padding: 0 !important; margin: 0 !important; line-height: 0 !important; font-size: 0 !important; opacity: 0 !important; overflow: hidden !important; } /* ✅✅✅ 真机偏上修复:强制“按屏幕居中” */ :deep(.u-index-list__sidebar) { position: fixed !important; /* 关键:按屏幕固定 */ top: 50% !important; /* 居中 */ transform: translateY(-50%) !important; /* 居中 */ right: 12rpx !important; /* 往里收一点避免贴边 */ z-index: 9999 !important; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6rpx; padding: 12rpx 6rpx; border-radius: 20rpx; background: rgba(0, 0, 0, 0.04); } :deep(.u-index-list__sidebar__item) { width: 36rpx; height: 36rpx; line-height: 36rpx; font-size: 22rpx; color: #333; text-align: center; } :deep(.u-index-list__sidebar__item--active) { color: #fff !important; background: #d89833 !important; border-radius: 50%; } </style>右面的的字母索引偏上让她一直居中返回一个完整的Selectfriends组件内容text<view class="page"> <view v-if="ready" class="page-body"> <up-index-list class="index-list" :style="indexListInlineStyle" :key="indexListKey" :index-list="indexList" :sticky="true" :custom-nav-height="navOffsetTop + customNavFix" :scroll-top="stableScrollTop"> <scroll-view scroll-y class="scroll-container"> <template v-for="(group, gIdx) in filteredGroupedArr" :key="indexList[gIdx]"> <up-index-item :class="['index-item', !(group && group.length) ? 'index-item--empty' : '']"> <up-index-anchor :text="indexList[gIdx]" :class="['anchor', !(group && group.length) ? 'anchor--empty' : '']" /> <view v-if="group && group.length" class="group"> <view v-for="(item, idx) in group" :key="item.id || item.consumerId || idx" @click="onRowTap(item)" :class="['row', isDisabled(itemId(item)) ? 'row--disabled' : '']"> <view class="row-inner"> <view class="row-left"> <view class="left-wrap"> <view class="check-dot" :class="dotClass(itemId(item))" /> <view class="avatar"> <image :src="fullAvatar(item.avatarUrl)" mode="aspectFill" @load="onAnyImageSettled" @error="onAnyImageSettled" /> </view> </view> </view> <view class="row-right"> <view class="right-wrap"> <view class="name-line"> <text class="name">{{ item.nickname || item.name }}</text> <text class="initial">({{ getInitial(item.nickname || item.name) }})</text> <text v-if="isDisabled(itemId(item))" class="tag-disabled"> {{ mode === 'ordinary' ? '已在群' : '已是管理员' }} </text> </view> <!-- ✅ 在线状态(从 contacts 拷贝过来) --> <view class="status-line"> 状态:{{ onlineById[itemId(item)] ? '在线中' : '离线' }} </view> </view> </view> </view> </view> </view> </up-index-item> </template> </scroll-view> </up-index-list> </view> </view>
vue<template> <view class="page"> <view v-if="ready" class="page-body"> <up-index-list class="index-list" :style="indexListInlineStyle" :key="indexListKey" :index-list="indexList" :sticky="true" :custom-nav-height="customNavHeight" :scroll-top="stableScrollTop" > <scroll-view scroll-y class="scroll-container"> <template v-for="(group, gIdx) in filteredGroupedArr" :key="indexList[gIdx]"> <up-index-item :class="['index-item', !(group && group.length) ? 'index-item--empty' : '']"> <up-index-anchor :text="indexList[gIdx]" :class="['anchor', !(group && group.length) ? 'anchor--empty' : '']" /> <view v-if="group && group.length" class="group"> <view v-for="(item, idx) in group" :key="item.id || item.consumerId || idx" @click="onRowTap(item)" :class="['row', isDisabled(itemId(item)) ? 'row--disabled' : '']" > <view class="row-inner"> <view class="row-left"> <view class="left-wrap"> <view class="check-dot" :class="dotClass(itemId(item))" /> <view class="avatar"> <image :src="fullAvatar(item.avatarUrl)" mode="aspectFill" @load="onAnyImageSettled" @error="onAnyImageSettled" /> </view> </view> </view> <view class="row-right"> <view class="right-wrap"> <view class="name-line"> <text class="name">{{ item.nickname || item.name }}</text> <text class="initial">({{ getInitial(item.nickname || item.name) }})</text> <text v-if="isDisabled(itemId(item))" class="tag-disabled"> {{ mode === 'ordinary' ? '已在群' : '已是管理员' }} </text> </view> <!-- ✅ 在线状态 --> <view class="status-line"> 状态:{{ onlineById[itemId(item)] ? '在线中' : '离线' }} </view> </view> </view> </view> </view> </view> </up-index-item> </template> </scroll-view> </up-index-list> </view> </view> </template> <script setup> import { onMounted, ref, nextTick, onActivated, onUnmounted, reactive, computed, watch, getCurrentInstance } from 'vue' import { onPageScroll, onShow } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { usecontacts } from '@/store/usecontacts.js' import { useMainStore } from '@/store/index.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' const props = defineProps({ isinvite: { type: Boolean, default: false }, mode: { type: String, default: 'ordinary' }, existingMemberIds: { type: Array, default: () => [] }, existingManagerIds: { type: Array, default: () => [] }, // ✅ 外部删除头像时传入 id(或数组)用于取消选中 InitialID: { type: [String, Number, Array], default: '' }, // ✅ 搜索关键字(Createcoffeegroup 里输入框) keyword: { type: String, default: '' } }) const emit = defineEmits(['seddata']) /** ---- 全局主仓库:在线状态 ---- */ const mainStore = useMainStore() const { onlineById } = storeToRefs(mainStore) /** ---- 联系人数据仓库 ---- */ const store = usecontacts() const { indexList, groupedItemArr, webProjectUrl, navOffsetTop, itemArr } = storeToRefs(store) const { getInitial, setNavOffsetTop, refreshIndexLayoutFromStore, getGetcontacts } = store const statusBarH = ref(20) const titleBarH = ref(44) // ✅ 给 up-index-list 的 custom-nav-height:状态栏 + 标题栏(更贴近真机) const customNavHeight = computed(() => Number(statusBarH.value || 0) + Number(titleBarH.value || 0)) const ready = ref(false) const indexListKey = ref(0) const pageScrollTop = ref(0) const stableScrollTop = ref(0) let scrollTimer = null const indexListInlineStyle = ref('height:0px;') const ins = getCurrentInstance() function itemId(it) { return String(it?.id ?? it?.consumerId ?? '') } function fullAvatar(path = '') { const trimmed = String(path || '').trim() if (!trimmed) return '/static/default/avatar.png' if (/^https?:\/\//i.test(trimmed)) return trimmed return (webProjectUrl.value || '') + trimmed } function measureHeaderAndSetOffset() { try { let sb = getStatusBarHeight() // #ifdef APP-PLUS try { if (typeof plus !== 'undefined' && plus?.navigator?.getStatusbarHeight) { sb = plus.navigator.getStatusbarHeight() || sb } } catch (e) {} // #endif statusBarH.value = sb titleBarH.value = getTitleBarHeight() // 让 store 里也保存总高度(组件/库需要) setNavOffsetTop(customNavHeight.value) } catch (e) { statusBarH.value = 20 titleBarH.value = 44 setNavOffsetTop(64) } } /** * ✅ 高度优先取“组件自身容器高度”(因为你是在 80vh 容器里用它) * 这样右侧索引的“居中”才会按当前可视列表区域居中,而不是按整屏/整窗。 */ async function calcListHeight() { await nextTick() // 1) 尝试测量组件容器高度 const domH = await new Promise((resolve) => { try { uni .createSelectorQuery() .in(ins) .select('.page') .boundingClientRect((rect) => resolve(Number(rect?.height || 0))) .exec() } catch (e) { resolve(0) } }) if (domH > 0) { indexListInlineStyle.value = `height:${domH}px;min-height:${domH}px;` return } // 2) 测不到再 fallback windowHeight(兜底) try { const sys = uni.getSystemInfoSync() const winH = Number(sys?.windowHeight || 0) const h = Math.max(0, winH - Number(customNavHeight.value || 0)) indexListInlineStyle.value = `height:${h}px;min-height:${h}px;` } catch (e) { indexListInlineStyle.value = `height:600px;min-height:600px;` } } async function lightRefresh(reason = '') { await nextTick() refreshIndexLayoutFromStore(reason) } function onAnyImageSettled() { lightRefresh('img-settled') } /** ✅ 搜索过滤:不动 store 的 groupedItemArr,只在组件内过滤 */ const filteredGroupedArr = computed(() => { const kw = String(props.keyword || '').trim().toLowerCase() if (!kw) return groupedItemArr.value || [] return (groupedItemArr.value || []).map((group) => { const g = Array.isArray(group) ? group : [] return g.filter((it) => { const name = String(it?.nickname || it?.name || '').toLowerCase() return name.includes(kw) }) }) }) /** ✅ 选择逻辑(保留) */ const selectList = reactive(new Set()) const disabledMemberSet = computed(() => new Set((props.existingMemberIds || []).map((v) => String(v)))) const disabledManagerSet = computed(() => new Set((props.existingManagerIds || []).map((v) => String(v)))) const isDisabled = (id) => props.mode === 'ordinary' ? disabledMemberSet.value.has(String(id)) : disabledManagerSet.value.has(String(id)) function dotClass(id) { return { checked: selectList.has(id), disabled: isDisabled(id) } } function emitSelected() { const selected = (itemArr.value || []) .filter((p) => selectList.has(itemId(p))) .map((p) => ({ ...p, id: p.id ?? p.consumerId })) emit('seddata', selected) } function onRowTap(val) { const id = itemId(val) if (!val || !id || isDisabled(id)) return selectList.has(id) ? selectList.delete(id) : selectList.add(id) emitSelected() } /** ✅ 外部删除头像:传 InitialID 进来,组件取消选中 */ watch( () => props.InitialID, (v) => { if (!v) return const arr = Array.isArray(v) ? v : [v] arr.map(String).forEach((id) => { if (selectList.has(id)) selectList.delete(id) }) emitSelected() indexListKey.value++ nextTick(() => lightRefresh('initialid-changed')) }, { deep: true } ) watch( () => [props.mode, props.existingMemberIds, props.existingManagerIds], () => { for (const id of Array.from(selectList)) { if (isDisabled(id)) selectList.delete(id) } emitSelected() indexListKey.value++ nextTick(() => lightRefresh('props-changed')) }, { deep: true, immediate: true } ) /** ✅ 在线状态拉取(保留) */ async function fetchContactsPresenceOnce() { const ids = (Array.isArray(itemArr.value) ? itemArr.value : []) .map((i) => String(i?.id ?? i?.consumerId ?? '')) .filter(Boolean) if (!ids.length) return await mainStore.fetchOnlineStatusForList(ids) } onMounted(async () => { measureHeaderAndSetOffset() await getGetcontacts() await fetchContactsPresenceOnce() await calcListHeight() await nextTick() ready.value = true await nextTick() lightRefresh('mounted') }) watch( itemArr, async (newList, oldList) => { const oldSet = new Set((oldList || []).map((i) => String(i?.id)).filter(Boolean)) const newIds = (newList || []).map((i) => String(i?.id)).filter(Boolean) const delta = newIds.filter((id) => !oldSet.has(id)) if (delta.length) await mainStore.fetchOnlineStatusForList(delta) }, { deep: false } ) onShow(() => { calcListHeight() lightRefresh('onShow') }) onActivated(() => { calcListHeight() lightRefresh('onActivated') }) onPageScroll((e) => { pageScrollTop.value = e?.scrollTop || 0 clearTimeout(scrollTimer) scrollTimer = setTimeout(() => { stableScrollTop.value = pageScrollTop.value }, 16) }) onUnmounted(() => { clearTimeout(scrollTimer) scrollTimer = null }) function resetSelection() { selectList.clear() emit('seddata', []) } defineExpose({ resetSelection }) </script> <style lang="scss" scoped> .page { background: #fff; height: 100%; min-height: 100%; } .page-body { height: 100%; } /* ✅ 让 index-list 作为定位参照,右侧索引按“当前列表容器”居中 */ .index-list { position: relative; } .scroll-container { height: 100%; } .group { width: 100%; display: flex; flex-direction: column; } .row { width: 100%; height: 130rpx; display: flex; justify-content: center; align-items: center; } .row + .row { border-top: 1rpx solid #f0f0f0; } .row--disabled { opacity: 0.5; } .row-inner { width: 100%; display: flex; box-sizing: border-box; justify-content: space-between; height: 100%; } .row-left { width: 30%; display: flex; box-sizing: border-box; height: 100%; } .left-wrap { height: 100%; width: 100%; display: flex; justify-content: flex-start; align-items: center; gap: 16rpx; padding-left: 30rpx; } .check-dot { width: 32rpx; height: 32rpx; border-radius: 50%; border: 2rpx solid #dcdcdc; } .check-dot.checked { background: #d89833; border-color: #d89833; } .check-dot.disabled { opacity: 0.5; } .avatar { height: 100rpx; width: 100rpx; border-radius: 12rpx; overflow: hidden; image { height: 100%; width: 100%; border-radius: 12rpx; } } .row-right { width: 70%; display: flex; box-sizing: border-box; } .right-wrap { height: 100%; width: 100%; display: flex; flex-direction: column; justify-content: center; border-bottom: 1rpx solid #eaeaea; padding-right: 20rpx; } .row:last-of-type .right-wrap { border-bottom: none; } .name-line { display: flex; align-items: center; gap: 10rpx; font-size: 30rpx; color: #111; } .name { font-weight: 500; } .initial { color: #d9534f; font-size: 24rpx; } .tag-disabled { margin-left: 10rpx; font-size: 22rpx; color: #999; padding: 2rpx 8rpx; border-radius: 8rpx; background: #f5f5f5; } .status-line { margin-top: 20rpx; color: #666; font-size: 24rpx; } .index-item--empty { height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; } .anchor { font-size: 28rpx; color: #888; padding-left: 30rpx; background: #fafafa; } .anchor--empty { height: 0 !important; padding: 0 !important; margin: 0 !important; line-height: 0 !important; font-size: 0 !important; opacity: 0 !important; overflow: hidden !important; } /* ✅✅✅ 右侧字母索引:强制按“列表容器”垂直居中(兼容不同类名) */ :deep(.u-index-list__sidebar), :deep(.up-index-list__sidebar), :deep(.u-index-list__index), :deep(.up-index-list__index) { position: absolute !important; /* 关键:相对 .index-list 居中 */ top: 50% !important; transform: translateY(-50%) !important; right: 12rpx !important; z-index: 9999 !important; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6rpx; padding: 12rpx 6rpx; border-radius: 20rpx; background: rgba(0, 0, 0, 0.04); } :deep(.u-index-list__sidebar__item), :deep(.up-index-list__sidebar__item), :deep(.u-index-list__index__item), :deep(.up-index-list__index__item) { width: 36rpx; height: 36rpx; line-height: 36rpx; font-size: 22rpx; color: #333; text-align: center; } :deep(.u-index-list__sidebar__item--active), :deep(.up-index-list__sidebar__item--active), :deep(.u-index-list__index__item--active), :deep(.up-index-list__index__item--active) { color: #fff !important; background: #d89833 !important; border-radius: 50%; } </style>