<template> <view :class="['app-container', theme]...

생성일: 2025년 12월 24일

답변에 사용된 모델 GPT-5.2 Thinking by Chat01

질문

a5852c696378c281f98d0ded4081461e.jpg
<template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <!-- ✅ 顶部 Header:固定不跟随滚动 --> <view class="header-fixed" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <!-- 左:取消 --> <view class="left-box" @click="navigateBack"> <view class="btn-white"> <view>取消</view> </view> </view>
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 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
<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>
</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组件内容

사고

Adjusting sidebar index position

The user wants the sidebar index letters vertically centered within the visible area of the screen, excluding the fixed header. They already use position:fixed but it's not working due to screen padding, safe areas, and header height. I might need to check the exact container behavior and adjust for safe area or custom navigation sizes.

Calculating sidebar position adjustment

To center the sidebar within the visible list area (excluding the header), I need to adjust the top property. The calculation could be something like 50% + headerHeight/2. If the sidebar is too high, I may need to move it down with a small fix, like adding a dynamic value via a CSS variable or inline style, depending on the device's specifics.

Adjusting nav-offset for correct sidebar positioning

It seems like u-index-list uses custom-nav-height for positioning, calculating from navOffsetTop + customNavFix. Right now, there's a mismatch in how the height is calculated—considering the status bar and title bar together could shift the sidebar. The fix involves adjusting the setNavOffsetTop to include both statusBarH and titleBarH.

Adjusting custom-nav-height for sidebar centering

I need to set custom-nav-height to either navOffsetTop + customNavFix or compute it directly as the sum of statusBarH and titleBarH. The up-index-list expects a number, so I’ll pass the computed value. The user wants the sidebar centered relative to the list, not the screen.

Fixing sidebar index bar centering

The user wants the index bar centered vertically within the list area. From the screenshot, it looks top-aligned, which might be due to an incorrect selector or an issue with transforms on ancestors that affect the fixed position. I’ll try multiple deep selectors to ensure it works across both Uview 2 and Uview Plus, adjusting for any transforms.

Centering sidebar within list

It seems fixing the sidebar position requires using position: absolute; with top: 50% inside the index-list container, while making the sidebar stay fixed relative to the viewport if the scroll-container scrolls. I will adjust by using :deep(.u-index-list__sidebar) with a transform and proper styling. I'll also provide a full updated Selectfriends component to include these changes.

Final adjustments for centering sidebar and calculating custom nav height

We can keep the template as is, but modify the wrapper styling and pass :custom-nav-height="customNavHeight" for accurate offsets. I’ll update the computed value for customNavHeight and ensure that navOffsetTop and customNavFix align correctly. For CSS, I’ll set position:relative; for the container and adjust sidebar positioning using absolute so it centers within the list container.

Finalizing CSS and list height adjustments

The style ensures proper centering by using position: absolute and transforming the sidebar. We’ll handle cases where the container or scroll-view affects positioning by slightly adjusting top or overriding margin-top.

For list height, I’ll subtract both the title bar and status bar height, ensuring consistency without breaking the layout. We need to account for this subtraction in the component's height calculation.

Addressing miscenter of index list

It seems the issue arises because the index list's height calculation is based on the window height, not considering the parent container's height (80vh). To fix this, I’ll set the index list to 100% height relative to its parent using CSS. This should match the visible area and correctly center the sidebar.

Fixing index bar centering issue

To keep the sidebar centered properly, I'll remove the JS height calculation and instead use 100% height based on the parent container, fixing the issue with the index bar not being centered. This requires setting .page and .index-list to height: 100%, querying the parent container's height, and adjusting styles accordingly.

Improving sidebar positioning within component

To center the index list inside its container, I’ll modify the component to use 100% height for .page and .page-body, avoiding unnecessary JS height calculations. I'll use a selector query to measure the parent container's height. The sidebar will be positioned absolutely within .index-list, keeping the layout consistent inside the 80vh container. I'll also update necessary props and variables accordingly.

Thought for 2m 29s

답변

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>

이 Q&A 공유하기