这个是Sharedrescue页面的内容模块主要负责展示区<!-- /components/Home...

创建于:2026年1月28日

使用 GPT-5.2 Thinking 回答 Chat01

问题

image.png
image.png
image.png
06b8fc7a81d2f106dccc111000f07063.jpg

这个是Sharedrescue页面的内容模块主要负责展示区<!-- /components/HomepageComponents/CommonModule/Sharedrescue.vue -->
<template>
<view class="shared-rescue-root">
<!-- ✅ 右侧中间悬浮 SOS -->
<view v-if="!popupShow" class="sos-float" @tap="openPopup">
<text class="sos-text">SOS</text>
</view>

text
<!-- ✅ 右侧抽屉弹窗 --> <u-popup :show="popupShow" mode="right" :round="12" zIndex="10075" :safeAreaInsetTop="true" :safeAreaInsetBottom="true" @close="closePopup"> <view class="drawer"> <!-- 顶部栏:返回 / 标题 / 关闭 --> <view class="drawer-header"> <view class="hdr-left" v-if="canBack" @tap="goBack"> <uni-icons type="left" size="22" color="#333"></uni-icons> </view> <view class="hdr-left" v-else></view> <text class="hdr-title">{{ currentTitle }}</text> <view class="hdr-right" @tap="closePopup"> <uni-icons type="closeempty" size="22" color="#333"></uni-icons> </view> </view> <!-- 内容区域 --> <view class="drawer-body"> <!-- ===================== 0) 主菜单 ===================== --> <scroll-view v-if="currentView === 'menu'" scroll-y class="scroll"> <view class="menu-list"> <view v-for="item in menuItems" :key="item.key" class="menu-item" :class="{ active: item.key === subscript }" @tap="enterFromMenu(item)"> <text class="menu-text">{{ item.label }}</text> </view> </view> </scroll-view> <!-- ===================== 1) 预警模块(占位) ===================== --> <!-- ===================== 1) 预警模块(Tabs + 独立内容) ===================== --> <view v-else-if="currentView === 'warning'" class="warn-page"> <!-- 顶部 tabs --> <view class="warn-tabs"> <scroll-view scroll-x class="warn-tabs-scroll" :show-scrollbar="false"> <view class="warn-tabs-row"> <view v-for="t in warningTabs" :key="t" class="warn-tab" :class="{ active: warningTab === t }" @tap="setWarningTab(t)"> <text class="warn-tab-text">{{ t }}</text> </view> </view> </scroll-view> </view> <!-- 内容:独立显示 --> <scroll-view scroll-y class="scroll warn-scroll"> <!-- 1) 天气:点击才挂载 UniversalWeather --> <view v-if="weatherMounted" v-show="warningTab === '天气'" class="warn-panel"> <UniversalWeather embedded :setNavBarColor="false" :enableHistory="true" historyKey="WEATHER_HISTORY_SHAREDRESCUE" /> </view> <!-- 2) 海浪 --> <view v-show="warningTab === '海浪'" class="warn-panel"> <view class="placeholder"> <text class="placeholder-title">海浪预警</text> <text class="placeholder-desc">这里放海浪预警组件/列表。</text> </view> </view> <!-- 3) 台风 --> <view v-show="warningTab === '台风'" class="warn-panel"> <view class="placeholder"> <text class="placeholder-title">台风预警</text> <text class="placeholder-desc">这里放台风路径/预警信息。</text> </view> </view> <!-- 4) 地震 --> <view v-show="warningTab === '地震'" class="warn-panel"> <view class="placeholder"> <text class="placeholder-title">地震预警</text> <text class="placeholder-desc">这里放地震信息/附近震。</text> </view> </view> <!-- 5) 预报信息 --> <view v-show="warningTab === '预报信息'" class="warn-panel"> <view class="placeholder"> <text class="placeholder-title">预报信息</text> <text class="placeholder-desc">这里放综合预报(例如:24h、48h、72h)。</text> </view> </view> <!-- 发布条其他页面 --> <!-- 发布 --> <view v-show="warningTab === '发布'" class="warn-panel"> <view class="publish-card"> <text class="publish-title">发布</text> <view style="margin-top: 10rpx; color:#666; font-size: 24rpx; line-height: 36rpx;"> 将先校验机构认证状态:<text style="font-weight:800;">有效(state=2)</text>才可进入救援同频评论/发布页。 </view> <!-- ✅ 校验中 --> <view v-if="orgLoading" style="padding: 24rpx 0; color:#999; font-size: 26rpx;"> 正在校验机构认证状态... </view> <!-- ✅ 校验完成 --> <view v-else style="margin-top: 14rpx;"> <view style="margin-top: 10rpx; color:#333; font-size: 26rpx; line-height: 40rpx;"> 当前状态:<text style="color:#D89833; font-weight: 800;">{{ orgStateText }}</text> </view> <!-- 驳回原因(有就展示) --> <view v-if="orgRejectReason" style="margin-top: 12rpx; color:#ff4d4f; font-size: 24rpx; line-height: 36rpx;"> 原因:{{ orgRejectReason }} </view> <view style="height: 18rpx;"></view> <!-- ✅ state=2:允许进入评论/发布 --> <view v-if="orgViewStateLocal === 'APPROVED'" class="publish-btn" @tap="enterRescueReleaseHome"> <text class="publish-text">{{ entering ? '进入中...' : '进入评论/发布' }}</text> </view> <!-- ✅ state=0/1:审核中:引导去状态页 --> <view v-else-if="orgViewStateLocal === 'PENDING'" class="publish-btn" @tap="goOrgStatus"> <text class="publish-text">查看审核状态</text> </view> <!-- ✅ state=-1:驳回/停用:引导重新提交 --> <view v-else-if="orgViewStateLocal === 'REJECTED'" class="publish-btn" @tap="goOrgResubmit"> <text class="publish-text">重新提交认证</text> </view> <!-- ✅ NONE:未提交/被删除:去认证 --> <view v-else class="publish-btn" @tap="goOrgAuth"> <text class="publish-text">去认证</text> </view> <!-- ✅ 额外提示(你原来的 enterTip 保留) --> <view v-if="enterTip" style="margin-top: 14rpx; color:#999; font-size: 22rpx;"> {{ enterTip }} </view> </view> </view> </view> <!-- 给底部固定按钮留空 --> <view style="height: 140rpx;"></view> </scroll-view> <!-- ✅ 底部固定:获取手机安全位置 --> <view class="warn-bottom-bar"> <view class="warn-safe-btn" @tap="getSafeLocation"> <text class="warn-safe-text">{{ safeLocLoading ? '定位中...' : '获取手机的安全位置' }}</text> </view> </view> </view> <!-- ===================== 2) 报警(二级菜单) ===================== --> <scroll-view v-else-if="currentView === 'alarm'" scroll-y class="scroll"> <view class="menu-list"> <view v-for="item in alarmItems" :key="item.key" class="menu-item" @tap="enterFromAlarm(item)"> <text class="menu-text">{{ item.label }}</text> </view> </view> </scroll-view> <!-- ===================== 2.1) 紧急报警(SOS 快速拨号页) ===================== --> <scroll-view v-else-if="currentView === 'sosQuick'" scroll-y class="scroll"> <view class="menu-list"> <!-- 图里顶部橙色“设置(SOS)” --> <view class="menu-item active" @tap="pushView('sosSetting', '设置(SOS)')"> <text class="menu-text">设置(SOS)</text> </view> <!-- SOS 号码列表:点击立即拨打 --> <view v-for="n in sosConfig.numbers" :key="n" class="menu-item" @tap="callNow(n)"> <text class="menu-text">{{ formatPhoneDisplay(n) }}</text> </view> </view> <view class="hint"> <text>提示:点击号码会立即拨打;Android 走原生直拨,iOS 会系统确认(系统限制)。</text> </view> </scroll-view> <!-- ===================== 2.2) 设置(SOS):开关 + 长按时长 + 播出电话 + 自定义 + 确定 ===================== --> <view v-else-if="currentView === 'sosSetting'" class="sos-setting"> <scroll-view scroll-y class="scroll sos-scroll"> <view class="sos-block"> <view class="sos-row"> <text class="sos-label">是否开启长按页面(SOS)</text> <switch :checked="!!sosConfig.enabled" @change="onSosEnabledChange" /> </view> <view class="sos-row sos-row-click" @tap="pressPickerShow = true"> <text class="sos-label">长按时长</text> <view class="sos-right"> <text class="sos-val">{{ sosConfig.pressSeconds }}S</text> <text class="chev">∨</text> </view> </view> <view class="sos-row sos-row-click" @tap="numberPickerShow = true"> <text class="sos-label">播出电话</text> <view class="sos-right"> <text class="sos-val">{{ formatPhoneDisplay(sosConfig.selectedNumber) }}</text> <text class="chev">∨</text> </view> </view> <!-- 留一点空白,避免被底部按钮遮住 --> <view style="height: 140rpx;"></view> </view> </scroll-view> <!-- 底部确定按钮(图里是底部按钮) --> <view class="bottom-bar"> <view class="bottom-btn" @tap="saveSosConfig"> <text class="bottom-btn-text">确定</text> </view> </view> <!-- 长按时长选择(底部弹出) --> <u-popup :show="pressPickerShow" mode="bottom" :round="12" zIndex="10090" @close="pressPickerShow = false"> <view class="sheet"> <view class="sheet-title">选择长按时长</view> <view v-for="s in sosPressOptions" :key="s" class="sheet-item" @tap="selectPressSeconds(s)"> <text>{{ s }}S</text> <text v-if="Number(sosConfig.pressSeconds) === Number(s)" class="ok">✓</text> </view> <view class="sheet-cancel" @tap="pressPickerShow = false">取消</view> </view> </u-popup> <!-- 播出电话选择(底部弹出) --> <u-popup :show="numberPickerShow" mode="bottom" :round="12" zIndex="10090" @close="numberPickerShow = false"> <view class="sheet"> <view class="sheet-title">选择播出电话</view> <view v-for="n in sosConfig.numbers" :key="n" class="sheet-item" @tap="selectSosNumber(n)"> <text>{{ formatPhoneDisplay(n) }}</text> <text v-if="String(sosConfig.selectedNumber) === String(n)" class="ok">✓</text> </view> <!-- 自定义:右侧“添加” --> <view class="sheet-item sheet-custom"> <text>自定义</text> <text class="sheet-add" @tap.stop="openCustomPhoneModal">添加</text> </view> <view class="sheet-cancel" @tap="numberPickerShow = false">取消</view> </view> </u-popup> <!-- 自定义号码输入(居中弹窗,带 X) --> <u-popup :show="customPhoneShow" mode="center" :round="12" zIndex="10100" @close="customPhoneShow = false"> <view class="modal"> <view class="modal-close" @tap="customPhoneShow = false"> <uni-icons type="closeempty" size="20" color="#333"></uni-icons> </view> <view class="modal-title">输入自定义号码</view> <input class="modal-input" type="text" placeholder="例如:15290161737" v-model="customPhoneInput" /> <view class="modal-btn" @tap="confirmAddCustomPhone"> <text class="modal-btn-text">确定</text> </view> </view> </u-popup> </view> <!-- ===================== 2.3) 公共服务(接 API) ===================== --> <view v-else-if="currentView === 'publicService'" class="ps-page"> <view class="ps-top"> <view class="ps-tabs"> <text v-for="t in psTabs" :key="t" class="ps-tab" :class="{ active: psTab === t }" @tap="setPsTab(t)"> {{ t }} </text> </view> <view class="ps-search"> <input class="ps-search-input" type="text" placeholder="搜索名称/电话" v-model="psKeyword" /> <uni-icons type="search" size="20" color="#666"></uni-icons> </view> </view> <!-- 地区二级 tabs --> <view v-if="psTab === '地区'" class="ps-region-bar"> <text v-for="r in psRegionTabs" :key="r" class="ps-region" :class="{ active: psRegion === r }" @tap="psRegion = r"> {{ r }} </text> </view> <scroll-view scroll-y class="scroll ps-scroll"> <!-- 热门/地区:审核通过列表 publicServiceAuditList --> <view v-if="psTab === '热门' || psTab === '地区'" class="ps-list"> <view v-for="item in psApprovedFiltered" :key="item.id" class="ps-item" @tap="openPublicServiceDetail(item)"> <text class="ps-name">{{ item.name }}</text> <view class="ps-right" @tap.stop="callNow(item.phone)"> <text class="ps-dot">●</text> <text class="ps-phone">{{ formatPhoneDisplay(item.phone) }}</text> </view> </view> <view v-if="!psLoading && psApprovedFiltered.length === 0" class="empty">暂无数据</view> </view> <!-- 添加:publicService --> <view v-else-if="psTab === '添加'" class="ps-form"> <view class="field"> <text class="label">名称</text> <input class="input" type="text" placeholder="请输入名称" v-model="psForm.name" /> </view> <view class="field"> <text class="label">电话</text> <input class="input" type="text" placeholder="请输入电话" v-model="psForm.phone" /> </view> <view class="field"> <text class="label">描述(可选)</text> <textarea class="textarea" placeholder="请输入描述" v-model="psForm.description"></textarea> </view> <view class="publish-btn" @tap="submitPublicService"> <text class="publish-text">{{ psIsEdit ? '保存' : '添加' }}</text> </view> <view v-if="psIsEdit" class="sheet-cancel" style="margin-top: 16rpx;" @tap="resetPublicServiceForm"> 取消编辑 </view> </view> <!-- 我的:myPublicServiceList --> <view v-else-if="psTab === '我的'" class="ps-my"> <text class="ps-tip">审核通过后可进行一键拨打电话</text> <view v-for="item in psMyList" :key="item.id" class="ps-my-card"> <view class="ps-my-top"> <text class="ps-name">{{ item.name }}</text> <view class="ps-right" @tap.stop="callMyPublicService(item)"> <text class="ps-dot">●</text> <text class="ps-phone">{{ formatPhoneDisplay(item.phone) }}</text> </view> </view> <view class="ps-my-actions"> <text class="link" @tap="editPublicService(item)">修改</text> <text class="state">{{ publicServiceStateText(item.state) }}</text> <text class="link danger" @tap="deletePublicServiceItem(item)">删除</text> </view> </view> <view v-if="!psLoading && psMyList.length === 0" class="empty">暂无维护记录</view> </view> <view style="height: 30rpx;"></view> </scroll-view> <!-- 详情弹窗:publicServiceShow --> <u-popup :show="psDetailShow" mode="center" :round="12" zIndex="10100" @close="psDetailShow = false"> <view class="modal ps-detail"> <view class="modal-title">{{ psDetail?.name || '公共服务详情' }}</view> <view class="kv"> <text class="k">电话</text> <text class="v">{{ formatPhoneDisplay(psDetail?.phone || '-') }}</text> </view> <view class="kv"> <text class="k">状态</text> <text class="v">{{ publicServiceStateText(psDetail?.state) }}</text> </view> <view class="kv"> <text class="k">描述</text> <text class="v">{{ psDetail?.description || '-' }}</text> </view> <view class="modal-btn" @tap="callNow(psDetail?.phone)"> <text class="modal-btn-text">立即拨打</text> </view> </view> </u-popup> </view> <!-- ===================== 2.4) 特殊报警 ===================== --> <scroll-view v-else-if="currentView === 'specialAlarm'" scroll-y class="scroll"> <view class="sa-title">短信报警</view> <view class="ps-list"> <view class="ps-item" @tap="callNow('12110')"> <text class="ps-name">公安短信报警</text> <view class="ps-right"> <text class="ps-dot">●</text> <text class="ps-phone">12110</text> </view> </view> </view> </scroll-view> <!-- ===================== 2.5) 紧急联系人(接 API) ===================== --> <view v-else-if="currentView === 'emergencyContact'" class="ec-page"> <!-- 列表页 --> <view v-if="!ecFormShow" class="ec-list-wrap"> <scroll-view scroll-y class="scroll ec-scroll"> <!-- 空态:居中“去添加” --> <view v-if="!ecLoading && ecList.length === 0" class="ec-empty"> <view class="publish-btn" style="width: 220rpx;" @tap="openEmergencyAdd"> <text class="publish-text">去添加</text> </view> </view> <!-- 有数据:列表 --> <view v-else class="ec-list"> <text class="ps-tip">一键拨打电话</text> <view v-for="item in ecList" :key="item.id" class="ps-my-card"> <view class="ps-my-top"> <text class="ps-name">{{ item.name }}</text> <view class="ps-right" @tap.stop="callNow(item.phone)"> <text class="ps-dot">●</text> <text class="ps-phone">{{ formatPhoneDisplay(item.phone) }}</text> </view> </view> <view class="ps-my-actions"> <text class="link" @tap="openEmergencyEdit(item)">修改</text> <text class="link danger" @tap="deleteEmergency(item)">删除</text> </view> </view> </view> <view style="height: 120rpx;"></view> </scroll-view> <!-- 底部“确定”(图里有灰色按钮) --> <view class="ec-bottom-bar"> <view class="ec-confirm-btn" @tap="goBack"> <text class="ec-confirm-text">确定</text> </view> </view> <view v-if="ecList.length > 0" class="ec-fab" @tap="openEmergencyAdd"> <text class="ec-fab-text">去添加</text> </view> </view> <!-- 表单页 --> <scroll-view v-else scroll-y class="scroll ec-scroll"> <view class="ps-form"> <view class="field"> <text class="label">名称</text> <input class="input" type="text" placeholder="例如:好友" v-model="ecForm.name" /> </view> <view class="field"> <text class="label">电话</text> <input class="input" type="text" placeholder="例如:132****5678" v-model="ecForm.phone" /> </view> <view class="publish-btn" @tap="saveEmergency"> <text class="publish-text">确定</text> </view> <view class="sheet-cancel" style="margin-top: 16rpx;" @tap="ecFormShow = false">取消 </view> </view> </scroll-view> </view> <!-- ===================== 3) 其它模块占位 ===================== --> <!-- <view v-else class="placeholder"> <text class="placeholder-title">{{ currentTitle }}</text> <text class="placeholder-desc">该模块已预留结构,后续直接替换为真实组件即可。</text> <view class="publish-btn" @tap="toast(currentTitle)"> <text class="publish-text">点我展示</text> </view> </view> --> </view> </view> </u-popup> </view>
</template> <script setup> import { ref, reactive, computed, watch } from 'vue' import { usechanne } from '@/store/useChannel.js' const enterTip = ref('') const channelStore = usechanne() import Warningcontent from "@/components/HomepageComponents/CommonModule/CommentModule/Warningcontent.vue" import ajax from '@/api/ajax.js' import UniversalWeather from '@/components/HomepageComponents/CommonModule/weather/UniversalWeather.vue' const entering = ref(false) const orgLoading = ref(false) const orgInfo = ref(null) const orgCertified = computed(() => { return String(orgInfo.value?.state) === '2' }) /** ✅ 页面态:给发布Tab用(不影响你 store 的 orgViewState) */ const orgViewStateLocal = computed(() => { const s = String(orgInfo.value?.state ?? '').trim() if (!s || s === '-2') return 'NONE' if (s === '2') return 'APPROVED' if (s === '1' || s === '0') return 'PENDING' // 你文档里 -1 可能既代表拒绝也代表停用,这里统一当 REJECTED 处理 if (s === '-1') return 'REJECTED' return 'PENDING' }) const orgRejectReason = computed(() => { const r = orgInfo.value?.confirmContent ?? orgInfo.value?.reason ?? '' return String(r || '').trim() }) /** ✅ 让 isSuccess 更兼容你们常见的 {result:'success'} 返回(不删你原来的判断) */ function isSuccess(res) { const code = res?.code ?? res?.status ?? res?.resultCode if (code === 0 || code === 200) return true if (res?.success === true) return true // ✅ 新增:兼容 result=success(很多你们接口是这种) const r1 = String(res?.result || '').toLowerCase() const r2 = String(res?.data?.result || '').toLowerCase() if (r1 === 'success' || r2 === 'success') return true return false } async function fetchOrg() { // ✅ 只要切到“发布”tab 就刷新一次状态 orgLoading.value = true try { // 1) 先拿本地 orgId(store/Storage) let orgId = '' try { orgId = String(channelStore.getOrganizationId?.() || uni.getStorageSync('organizationId') || '').trim() } catch (e) {} console.log('[Sharedrescue] fetchOrg => orgId=', orgId, 'consumerId=', consumerId.value) // 2) 没 orgId 时,尝试用 consumerId 调 organizationShow 兜底拿 orgId(保留你 postApi 的 log) if (!orgId && consumerId.value) { console.log('[Sharedrescue] no orgId, try organizationShow by consumerId') const res = await postApi('organizationShow', { consumerId: String(consumerId.value) }) console.log('[Sharedrescue] organizationShow raw =>', res) if (isSuccess(res)) { const d = respData(res) || null // 下面这些字段名是“防御式取值”,避免后端字段不一致 const maybeId = d?.organizationId || d?.id || d?.organization?.organizationId || d?.organization?.id if (maybeId) { orgId = String(maybeId) try { channelStore.setOrganizationId?.(orgId) } catch (e) {} uni.setStorageSync('organizationId', orgId) } // 如果 organizationShow 也带 state,可以先塞进去让 UI 有内容 if (d) orgInfo.value = d } } // 3) 有 orgId 再拉 organizationDetail(你给的 25) if (!orgId) { orgInfo.value = orgInfo.value || null return } const r = await channelStore.fetchOrgDetail(true) console.log('[Sharedrescue] organizationDetail result =>', r) // fetchOrgDetail 返回的 detail 就是 organizationDetail 的数据 if (r?.detail) orgInfo.value = r.detail } finally { orgLoading.value = false } } async function enterRescueReleaseHome() { if (entering.value) return entering.value = true enterTip.value = '' try { // ✅ 强制刷新一次,避免状态缓存导致误判 await fetchOrg() // ✅ 只有 state=2 才放行 if (orgCertified.value) { closePopup() uni.navigateTo({ url: `/pages/biometrics/institution/RescueReleaseHome?tab=comment` }) return } // ❌ 非有效:只提示,不再“强制跳转”导致你觉得卡住 const st = String(orgInfo.value?.state ?? '').trim() if (!st || st === '-2') { enterTip.value = '未提交认证(或已删除),请先完成认证' return } if (st === '1' || st === '0') { enterTip.value = '当前身份已提交,审核中(state=0/1),暂无法进入评论/发布' return } if (st === '-1') { enterTip.value = '认证未通过/已停用(state=-1),请重新提交认证' return } enterTip.value = `当前状态(state=${st})暂无法进入评论/发布` } finally { entering.value = false } } function goOrgAuth() { const orgTypeName = uni.getStorageSync('orgTypeName') || '' closePopup() uni.navigateTo({ url: `/pages/biometrics/institution/RegistrationPage?orgTypeName=${encodeURIComponent(orgTypeName)}` }) } function goOrgStatus() { const orgTypeName = uni.getStorageSync('orgTypeName') || '' const orgId = String(channelStore.getOrganizationId?.() || uni.getStorageSync('organizationId') || '').trim() if (!orgId) return goOrgAuth() closePopup() uni.navigateTo({ url: `/pages/biometrics/institution/organizationStatus?organizationId=${encodeURIComponent(orgId)}&orgTypeName=${encodeURIComponent(orgTypeName)}` }) } function goOrgResubmit() { const orgTypeName = uni.getStorageSync('orgTypeName') || '' const orgId = String(channelStore.getOrganizationId?.() || uni.getStorageSync('organizationId') || '').trim() closePopup() uni.navigateTo({ url: `/pages/biometrics/institution/RegistrationPage?organizationId=${encodeURIComponent(orgId)}&orgTypeName=${encodeURIComponent(orgTypeName)}` }) } function setWarningTab(t) { warningTab.value = t if (t === '天气') weatherMounted.value = true if (t === '发布') { // ✅ 进入发布:校验认证状态(不删 log) fetchOrg() } } const orgStateText = computed(() => { const s = orgInfo.value?.state if (s === undefined || s === null || s === '') return '未提交' const ss = String(s) if (ss === '2') return '已认证(有效)' if (ss === '1') return '已上报(待审核)' if (ss === '0') return '维护中' if (ss === '-1') return '已拒绝/停用' if (ss === '-2') return '已删除' return `状态 ${ss}` }) function respData(res) { return res?.data ?? res?.result ?? res?.obj ?? res } function respMsg(res) { return res?.msg ?? res?.message ?? res?.errMsg ?? '请求失败' } function cleanObj(obj) { const o = {} Object.keys(obj || {}).forEach(k => { const v = obj[k] if (v === undefined || v === null) return o[k] = v }) return o } async function postApi(url, data) { console.log(url, data, '里面穿过来的形参经济联系人') return await ajax.post({ url, method: 'post', data: cleanObj(data) }) } // 天气 import { regeo } from '@/api/amap' // 用于把经纬度反查成地址 // 当前 tab 默认是 “天气” const warningTabs = ref(['天气', '海浪', '台风', '地震', '预报信息', '发布']) const warningTab = ref('天气') const weatherMounted = ref(warningTab.value === '天气') // function setWarningTab(t) { // warningTab.value = t // if (t === '天气') weatherMounted.value = true // if (t === '发布') { // uni.navigateTo({ // url: '/pages/biometrics/institution/RegistrationPage' // }) // } // } // 发布 tab(占位) const publishText = ref('') function submitWarningPublish() { if (!publishText.value.trim()) { uni.showToast({ title: '请输入发布内容', icon: 'none' }) return } uni.showToast({ title: '已提交(占位)', icon: 'none' }) publishText.value = '' } // ✅ 底部:获取手机安全位置 const safeLocLoading = ref(false) function ensureLocationPermissionLite() { return new Promise((resolve, reject) => { // #ifdef MP-WEIXIN uni.getSetting({ success: (st) => { const ok = st.authSetting['scope.userLocation'] if (ok === true) return resolve() uni.authorize({ scope: 'scope.userLocation', success: () => resolve(), fail: () => { uni.showModal({ title: '需要定位权限', content: '用于获取手机安全位置,是否去开启?', success: (r) => { if (!r.confirm) return reject(new Error( '用户拒绝授权')) uni.openSetting({ success: (s2) => { if (s2.authSetting[ 'scope.userLocation' ]) resolve() else reject(new Error( '未开启定位权限')) }, fail: () => reject(new Error( '打开设置失败')) }) } }) } }) }, fail: () => resolve() }) // #endif // #ifndef MP-WEIXIN resolve() // #endif }) } async function getSafeLocation() { if (safeLocLoading.value) return safeLocLoading.value = true try { await ensureLocationPermissionLite() const loc = await new Promise((resolve, reject) => { uni.getLocation({ type: 'gcj02', success: resolve, fail: reject }) }) const lng = loc.longitude const lat = loc.latitude let text = `经纬度:${lng},${lat}` // ✅ 反查地址(可选) try { const r = await regeo(lng, lat) const addr = r?.formattedAddress || r?.address || r?.cityName || '' if (addr) text = `${addr}\n${text}` } catch (e) {} // ✅ 复制到剪贴板 uni.setClipboardData({ data: text, success: () => { uni.showToast({ title: '已复制安全位置', icon: 'none' }) } }) } catch (e) { uni.showToast({ title: '定位失败', icon: 'none' }) } finally { safeLocLoading.value = false } } // 天气到这 /** ========================= * Drawer 容器 & 栈 * ========================= */ const popupShow = ref(false) const subscript = ref('warning') const stack = ref([{ name: 'menu', title: '救援功能' }]) const currentView = computed(() => stack.value[stack.value.length - 1]?.name || 'menu') const currentTitle = computed(() => stack.value[stack.value.length - 1]?.title || '救援功能') const canBack = computed(() => stack.value.length > 1) function pushView(name, title) { stack.value.push({ name, title }) } function goBack() { if (stack.value.length > 1) stack.value.pop() } function openPopup() { popupShow.value = true stack.value = [{ name: 'menu', title: '救援功能' }] subscript.value = 'warning' } function closePopup() { popupShow.value = false } /** ========================= * Toast * ========================= */ function toast(title) { uni.showToast({ title: String(title), icon: 'none' }) } /** ========================= * consumerId:用于“我的公共服务/紧急联系人” * ========================= */ const consumerId = ref(resolveConsumerId()) function resolveConsumerId() { const a = uni.getStorageSync('consumerId') const b = uni.getStorageSync('userId') const c = uni.getStorageSync('uid') return String(a || b || c || '') } /** ========================= * 立即拨打(核心要求:不要停在拨号盘) * - Android:Intent.ACTION_CALL(需要 CALL_PHONE 权限) * - iOS:tel://(系统限制会弹确认/跳转,但不会停在拨号盘输入) * - uni.makePhoneCall * ========================= */ function normalizePhone(raw) { const s = String(raw ?? '').trim() return s.replace(/[^\d+]/g, '') } function formatPhoneDisplay(raw) { const p = normalizePhone(raw) if (!p) return '' if (/^\d{11}$/.test(p)) return p.replace(/^(\d{3})(\d{4})(\d{4})$/, '$1 $2 $3') return p } async function callNow(rawPhone) { const phone = normalizePhone(rawPhone) if (!phone) return toast('号码为空') // #ifdef APP-PLUS try { const platform = uni.getSystemInfoSync().platform if (platform === 'android') { const ok = await ensureAndroidCallPermission() if (!ok) { toast('未授权电话权限') return } const main = plus.android.runtimeMainActivity() const Intent = plus.android.importClass('android.content.Intent') const Uri = plus.android.importClass('android.net.Uri') const intent = new Intent(Intent.ACTION_CALL, Uri.parse('tel:' + phone)) main.startActivity(intent) return } if (platform === 'ios') { // iOS 系统限制:无法静默直接拨通,但可直接发起呼叫(会系统确认/跳转) plus.runtime.openURL('tel://' + phone) return } } catch (e) { // fallthrough } // #endif uni.makePhoneCall({ phoneNumber: phone, fail: () => toast('当前环境不支持拨号') }) } // #ifdef APP-PLUS function ensureAndroidCallPermission() { return new Promise(resolve => { try { const platform = uni.getSystemInfoSync().platform if (platform !== 'android') return resolve(true) plus.android.requestPermissions( ['android.permission.CALL_PHONE'], e => { const granted = e?.granted || [] resolve(granted.includes('android.permission.CALL_PHONE')) }, () => resolve(false) ) } catch (err) { resolve(false) } }) } // #endif /** ========================= * 主菜单 * ========================= */ const menuItems = ref([{ key: 'warning', label: '预警', title: '预警' }, { key: 'alarm', label: '报警', title: '报警' }, { key: 'security', label: '智能安防', title: '智能安防' }, { key: 'care', label: '电子看护', title: '电子看护' }, { key: 'mall', label: '救援商城', title: '救援商城' }, { key: 'sync', label: '救援同频', title: '救援同频' }, { key: 'everyone', label: '人人救援', title: '人人救援' }, { key: 'instant', label: '及时救助', title: '及时救助' }, { key: 'category', label: '分类救援', title: '分类救援' }, { key: 'kit', label: '应急救援包', title: '应急救援包' }, { key: 'warehouse', label: '社区救援仓', title: '社区救援仓' }, { key: 'ai', label: 'AI智能救援', title: 'AI智能救援' } ]) watch( () => stack.value?.name, (newName, oldName) => { if (newName === 'mall') { goBack() console.log('执行了') } }, { immediate: true } ) function enterFromMenu(item) { subscript.value = item.key if (item.key === 'warning') return pushView('warning', '预警') if (item.key === 'alarm') return pushView('alarm', '报警') if (item.key === 'mall') { uni.navigateTo({ url: '/pages/biometrics/RescueMall/RescueMall' }) pushView('mall', '同频救援') } // 其它先占位 return pushView('placeholder', item.title) } /** ========================= * 预警模块(占位) * ========================= */ const warningActions = ref([{ label: '立即外穿', icon: 'paperplane' }, { label: '注意防晒', icon: 'sunny' }, { label: '室内活动', icon: 'home' }, { label: '今日不出行', icon: 'car' }, { label: '注意饮食', icon: 'info' }, { label: '不用带伞', icon: 'flag' } ]) /** ========================= * 报警模块(二级菜单) * ========================= */ const alarmItems = ref([{ key: 'emergency', label: '紧急报警', title: '紧急报警' }, { key: 'public', label: '公共服务', title: '公共服务' }, { key: 'special', label: '特殊报警', title: '特殊报警' }, { key: 'contact', label: '紧急联系人', title: '紧急联系人' } ]) function enterFromAlarm(item) { if (item.key === 'emergency') return pushView('sosQuick', '紧急报警') if (item.key === 'public') { pushView('publicService', '公共服务') // 进入就拉数据 fetchPublicApproved() fetchMyPublicServices() return } if (item.key === 'special') return pushView('specialAlarm', '特殊报警') if (item.key === 'contact') { pushView('emergencyContact', '紧急联系人') fetchEmergencyList() return } return pushView('placeholder', item.title) } /** ========================= * SOS 配置(本地持久化) * ========================= */ const SOS_STORAGE_KEY = 'shared_rescue_sos_config_v1' const sosConfig = reactive(loadSosConfig()) function loadSosConfig() { const base = { enabled: true, pressSeconds: 5, numbers: ['110', '120', '119'], selectedNumber: '110' } const d = uni.getStorageSync(SOS_STORAGE_KEY) if (!d) return base try { const obj = typeof d === 'string' ? JSON.parse(d) : d const nums = Array.isArray(obj?.numbers) && obj.numbers.length ? obj.numbers : base.numbers return { ...base, ...obj, numbers: nums, selectedNumber: obj?.selectedNumber || nums[0] || base.selectedNumber } } catch (e) { return base } } function onSosEnabledChange(e) { sosConfig.enabled = !!e.detail.value } function saveSosConfig() { uni.setStorageSync( SOS_STORAGE_KEY, JSON.stringify({ enabled: !!sosConfig.enabled, pressSeconds: Number(sosConfig.pressSeconds) || 5, numbers: sosConfig.numbers, selectedNumber: String(sosConfig.selectedNumber || '110') }) ) toast('已保存') goBack() } /** SOS setting popup states */ const pressPickerShow = ref(false) const numberPickerShow = ref(false) const customPhoneShow = ref(false) const customPhoneInput = ref('') const sosPressOptions = ref([3, 5, 8, 10]) function selectPressSeconds(s) { sosConfig.pressSeconds = Number(s) pressPickerShow.value = false } function selectSosNumber(n) { sosConfig.selectedNumber = String(n) numberPickerShow.value = false } function openCustomPhoneModal() { customPhoneInput.value = '' customPhoneShow.value = true } function confirmAddCustomPhone() { const p = normalizePhone(customPhoneInput.value) if (!p) return toast('请输入号码') // 加入列表 & 设为选中 const list = Array.isArray(sosConfig.numbers) ? [...sosConfig.numbers] : [] if (!list.includes(p)) list.push(p) sosConfig.numbers = list sosConfig.selectedNumber = p customPhoneShow.value = false numberPickerShow.value = false } /** ========================= * 公共服务 * ========================= */ const psTabs = ref(['热门', '地区', '添加', '我的']) const psTab = ref('热门') const psRegionTabs = ref(['北京', '天津', '河北', '上海']) const psRegion = ref('北京') const psKeyword = ref('') const psLoading = ref(false) const psApprovedList = ref([]) const psMyList = ref([]) const psIsEdit = ref(false) const psForm = reactive({ id: '', name: '', phone: '', description: '', state: 0 }) const psDetailShow = ref(false) const psDetail = ref(null) function setPsTab(t) { psTab.value = t if (t === '热门' || t === '地区') fetchPublicApproved() if (t === '我的') fetchMyPublicServices() if (t === '添加') resetPublicServiceForm() } function publicServiceStateText(state) { if (String(state) === '1') return '可拨打' if (String(state) === '0') return '审核中' if (state === undefined || state === null || state === '') return '-' return `状态${state}` } function pickPublicArray(d) { if (Array.isArray(d?.publicServiceArray)) return d.publicServiceArray if (Array.isArray(d?.publicServiceList)) return d.publicServiceList if (Array.isArray(d?.list)) return d.list if (Array.isArray(d)) return d return [] } async function fetchPublicApproved() { psLoading.value = true try { const res = await postApi('publicServiceAuditList', { rowStartIdx: 0, rowCount: 200 }) if (!isSuccess(res)) return toast(respMsg(res)) psApprovedList.value = pickPublicArray(respData(res)) } finally { psLoading.value = false } } async function fetchMyPublicServices() { if (!consumerId.value) return psLoading.value = true try { const res = await postApi('myPublicServiceList', { consumerId: String(consumerId.value), rowStartIdx: 0, rowCount: 200 }) if (!isSuccess(res)) return toast(respMsg(res)) psMyList.value = pickPublicArray(respData(res)) } finally { psLoading.value = false } } async function fetchPublicDetail(id) { const res = await postApi('publicServiceShow', { id: String(id) }) if (!isSuccess(res)) { toast(respMsg(res)) return null } return respData(res) } async function openPublicServiceDetail(item) { const d = await fetchPublicDetail(item?.id) if (!d) return psDetail.value = d psDetailShow.value = true } function resetPublicServiceForm() { psIsEdit.value = false psForm.id = '' psForm.name = '' psForm.phone = '' psForm.description = '' psForm.state = 0 } async function submitPublicService() { if (!consumerId.value) return toast('请先登录') if (!String(psForm.name || '').trim()) return toast('请输入名称') if (!normalizePhone(psForm.phone)) return toast('请输入电话') psLoading.value = true try { const res = await postApi('publicService', { id: psForm.id ? String(psForm.id) : undefined, name: String(psForm.name || '').trim(), description: String(psForm.description || '').trim(), phone: normalizePhone(psForm.phone), // 新增默认审核中;编辑则保留原 state state: psIsEdit.value ? psForm.state : 0, creatorId: String(consumerId.value) }) if (!isSuccess(res)) return toast(respMsg(res)) toast(psIsEdit.value ? '已保存' : '已提交') resetPublicServiceForm() psTab.value = '我的' await fetchMyPublicServices() await fetchPublicApproved() } finally { psLoading.value = false } } async function editPublicService(item) { const d = await fetchPublicDetail(item?.id) if (!d) return psIsEdit.value = true psForm.id = String(d?.id || item?.id || '') psForm.name = String(d?.name || '') psForm.phone = String(d?.phone || '') psForm.description = String(d?.description || '') psForm.state = d?.state ?? item?.state ?? 0 psTab.value = '添加' } async function deletePublicServiceItem(item) { const ok = await new Promise(resolve => { uni.showModal({ title: '确认删除', content: `删除:${item?.name || ''}?`, success: r => resolve(!!r.confirm), fail: () => resolve(false) }) }) if (!ok) return psLoading.value = true try { const res = await postApi('deletePublicService', { id: String(item?.id) }) if (!isSuccess(res)) return toast(respMsg(res)) toast('已删除') await fetchMyPublicServices() await fetchPublicApproved() } finally { psLoading.value = false } } function callMyPublicService(item) { // 图里提示:审核通过后可拨打 if (String(item?.state) !== '1') return toast('未审核通过,暂不可拨打') callNow(item?.phone) } // 过滤:关键词 +(地区 tab 时)地区 const psApprovedFiltered = computed(() => { const list = Array.isArray(psApprovedList.value) ? psApprovedList.value : [] const kw = String(psKeyword.value || '').trim() const region = String(psRegion.value || '').trim() let arr = list // 关键词 if (kw) { arr = arr.filter(x => { const name = String(x?.name || '') const phone = String(x?.phone || '') const desc = String(x?.description || '') return name.includes(kw) || phone.includes(kw) || desc.includes(kw) }) } // 地区(后端没给地区字段,这里做“弱过滤”:name/description 含地区名就显示) if (psTab.value === '地区' && region) { const matched = arr.filter(x => String(x?.name || '').includes(region) || String(x?.description || '') .includes(region)) // 如果确实有人匹配,就用匹配结果;否则不强制过滤(避免全空) if (matched.length > 0) arr = matched } return arr }) /** ========================= * 紧急联系人 * ========================= */ const ecLoading = ref(false) const ecList = ref([]) const ecFormShow = ref(false) const ecIsEdit = ref(false) const ecForm = reactive({ id: '', name: '', phone: '' }) function pickEmergencyArray(d) { if (Array.isArray(d?.emergencyContactPersonArray)) return d.emergencyContactPersonArray if (Array.isArray(d?.list)) return d.list if (Array.isArray(d)) return d return [] } async function fetchEmergencyList() { if (!consumerId.value) return ecLoading.value = true try { const res = await postApi('myEmergencyContactPersonList', { consumerId: String(consumerId.value), rowStartIdx: 0, rowCount: 200 }) if (!isSuccess(res)) return toast(respMsg(res)) ecList.value = pickEmergencyArray(respData(res)) } finally { ecLoading.value = false } } function openEmergencyAdd() { ecIsEdit.value = false ecForm.id = '' ecForm.name = '' ecForm.phone = '' ecFormShow.value = true } function openEmergencyEdit(item) { ecIsEdit.value = true ecForm.id = String(item?.id || '') ecForm.name = String(item?.name || '') ecForm.phone = String(item?.phone || '') ecFormShow.value = true } async function saveEmergency() { if (!consumerId.value) return toast('请先登录') if (!String(ecForm.name || '').trim()) return toast('请输入名称') if (!normalizePhone(ecForm.phone)) return toast('请输入电话') ecLoading.value = true try { const res = await postApi('emergencyContactPerson', { id: ecForm.id ? String(ecForm.id) : undefined, name: String(ecForm.name || '').trim(), phone: normalizePhone(ecForm.phone), creatorId: String(consumerId.value) }) if (!isSuccess(res)) return toast(respMsg(res)) toast(ecIsEdit.value ? '已保存' : '已添加') ecFormShow.value = false await fetchEmergencyList() } finally { ecLoading.value = false } } async function deleteEmergency(item) { const ok = await new Promise(resolve => { uni.showModal({ title: '确认删除', content: `删除联系人:${item?.name || ''}?`, success: r => resolve(!!r.confirm), fail: () => resolve(false) }) }) if (!ok) return ecLoading.value = true try { const res = await postApi('deleteEmergencyContactPerson', { id: String(item?.id) }) if (!isSuccess(res)) return toast(respMsg(res)) toast('已删除') await fetchEmergencyList() } finally { ecLoading.value = false } } </script> <style lang="scss" scoped> .shared-rescue-root { /* 组件容器本身不占位,不影响页面布局 */ } /* 右侧中间悬浮 SOS */ .sos-float { position: fixed; right: 22rpx; top: 50%; transform: translateY(-50%); width: 92rpx; height: 92rpx; border-radius: 46rpx; background: #d89833; box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.18); display: flex; align-items: center; justify-content: center; z-index: 10080; } .sos-text { color: #fff; font-weight: 800; font-size: 28rpx; letter-spacing: 1rpx; } /* 抽屉 */ .drawer { width: 680rpx; max-width: 84vw; height: 100vh; background: #fff; display: flex; flex-direction: column; } .drawer-header { height: 92rpx; display: flex; align-items: center; justify-content: space-between; padding: 0 18rpx; border-bottom: 1rpx solid #f1f1f1; } .hdr-left, .hdr-right { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; } .hdr-title { font-size: 30rpx; color: #333; font-weight: 700; } .drawer-body { flex: 1; overflow: hidden; } .scroll { height: 100%; } /* 列表按钮(主菜单/报警/设置) */ .menu-list { padding: 18rpx 22rpx; } .menu-item { height: 82rpx; border-radius: 12rpx; background: #f2f2f2; margin-bottom: 18rpx; display: flex; align-items: center; justify-content: center; } .menu-item.active { background: #d89833; } .menu-text { font-size: 28rpx; color: #333; font-weight: 600; } .menu-item.active .menu-text { color: #fff; } /* 预警 tabs */ .warn-tabs { padding: 14rpx 18rpx 0 18rpx; } .warn-tabs-scroll { width: 100%; white-space: nowrap; } .warn-tabs-row { display: flex; align-items: center; gap: 14rpx; padding-bottom: 12rpx; } .warn-tab { padding: 10rpx 18rpx; border-radius: 999rpx; background: #f4f4f4; flex-shrink: 0; } .warn-tab.active { background: #d89833; } .warn-tab-text { font-size: 26rpx; color: #333; } .warn-tab.active .warn-tab-text { color: #fff; } /* 预警内容 */ .panel { padding: 18rpx 18rpx 30rpx 18rpx; } .card { border-radius: 16rpx; background: #f7f9ff; padding: 20rpx; margin-bottom: 18rpx; } .card-title { display: flex; align-items: baseline; gap: 10rpx; font-size: 30rpx; font-weight: 700; color: #333; margin-bottom: 12rpx; } .card-sub { font-size: 22rpx; color: #888; font-weight: 400; } .card-content { display: flex; flex-direction: column; gap: 10rpx; } .card-line { font-size: 24rpx; color: #555; } /* 快捷操作 */ .actions { display: flex; flex-wrap: wrap; background: #fff; border-radius: 16rpx; padding: 16rpx; gap: 14rpx; border: 1rpx solid #f2f2f2; margin-bottom: 18rpx; } .action { width: calc((100% - 14rpx * 2) / 3); padding: 16rpx 10rpx; border-radius: 14rpx; background: #fafafa; display: flex; flex-direction: column; align-items: center; gap: 10rpx; } .action-label { font-size: 22rpx; color: #333; } /* 发布按钮 / 占位按钮 */ .publish-btn { height: 82rpx; border-radius: 12rpx; background: #d89833; display: flex; align-items: center; justify-content: center; } .publish-text { color: #fff; font-size: 28rpx; font-weight: 700; } /* 其它模块占位 */ .placeholder { padding: 30rpx 22rpx; } // 天气 /* 预警页面:上 tabs + 中间滚动 + 底部固定按钮 */ .warn-page { height: 100%; display: flex; flex-direction: column; } .warn-scroll { flex: 1; height: 0; } /* 内容容器 */ .warn-panel { padding: 18rpx; } /* 发布 tab 的简单样式 */ .publish-card { border-radius: 16rpx; background: #f7f9ff; padding: 20rpx; } .publish-title { font-size: 30rpx; font-weight: 800; color: #333; margin-bottom: 14rpx; display: block; } .publish-input { height: 220rpx; border-radius: 12rpx; background: #fff; border: 1rpx solid #eee; padding: 14rpx; font-size: 26rpx; color: #333; margin-bottom: 16rpx; } /* ✅ 底部固定:安全位置 */ .warn-bottom-bar { padding: 16rpx 18rpx calc(16rpx + env(safe-area-inset-bottom)); padding-bottom: calc(16rpx + constant(safe-area-inset-bottom)); border-top: 1rpx solid #f1f1f1; background: #fff; } .warn-safe-btn { height: 82rpx; border-radius: 12rpx; background: #d89833; display: flex; align-items: center; justify-content: center; } .warn-safe-text { color: #fff; font-size: 28rpx; font-weight: 800; } //到这 .placeholder-title { font-size: 34rpx; font-weight: 800; color: #333; margin-bottom: 16rpx; display: block; } .placeholder-desc { font-size: 24rpx; color: #666; margin-bottom: 22rpx; display: block; } /* hint */ .hint { padding: 0 22rpx 22rpx 22rpx; color: #888; font-size: 22rpx; } /* SOS Setting */ .sos-setting { position: relative; height: 100%; } .sos-scroll { height: 100%; } .sos-block { padding: 18rpx 22rpx; } .sos-row { height: 82rpx; border-radius: 12rpx; background: #f2f2f2; margin-bottom: 18rpx; padding: 0 18rpx; display: flex; align-items: center; justify-content: space-between; } .sos-row-click:active { opacity: 0.85; } .sos-label { font-size: 26rpx; color: #333; font-weight: 600; } .sos-right { display: flex; align-items: center; gap: 10rpx; } .sos-val { font-size: 26rpx; color: #333; font-weight: 700; } .chev { font-size: 22rpx; color: #666; } /* bottom bar */ .bottom-bar { position: absolute; left: 0; right: 0; bottom: 0; padding: 18rpx 22rpx calc(18rpx + env(safe-area-inset-bottom)); padding-bottom: calc(18rpx + constant(safe-area-inset-bottom)); background: #fff; border-top: 1rpx solid #f1f1f1; } .bottom-btn { height: 82rpx; border-radius: 12rpx; background: #d89833; display: flex; align-items: center; justify-content: center; } .bottom-btn-text { color: #fff; font-size: 28rpx; font-weight: 800; } /* bottom sheet */ .sheet { padding: 18rpx 22rpx 26rpx 22rpx; } .sheet-title { font-size: 28rpx; font-weight: 800; color: #333; margin-bottom: 10rpx; } .sheet-item { height: 76rpx; border-radius: 12rpx; background: #f2f2f2; margin-bottom: 14rpx; padding: 0 18rpx; display: flex; align-items: center; justify-content: space-between; font-size: 26rpx; color: #333; } .sheet-custom { background: #fafafa; } .sheet-add { color: #d89833; font-weight: 800; } .ok { color: #d89833; font-weight: 900; } .sheet-cancel { text-align: center; padding: 18rpx 0; color: #666; font-size: 26rpx; } /* modal */ .modal { position: relative; width: 560rpx; max-width: 78vw; background: #fff; border-radius: 14rpx; padding: 22rpx; } .modal-close { position: absolute; right: 12rpx; top: 10rpx; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; } .modal-title { font-size: 28rpx; font-weight: 800; color: #333; margin-bottom: 18rpx; text-align: center; } .modal-input { height: 76rpx; border-radius: 12rpx; background: #f2f2f2; padding: 0 18rpx; font-size: 26rpx; color: #333; margin-bottom: 18rpx; } .modal-btn { height: 82rpx; border-radius: 12rpx; background: #d89833; display: flex; align-items: center; justify-content: center; } .modal-btn-text { color: #fff; font-size: 28rpx; font-weight: 800; } /* 公共服务页面布局 */ .ps-page { height: 100%; display: flex; flex-direction: column; } .ps-top { padding: 14rpx 18rpx 0 18rpx; } .ps-tabs { display: flex; align-items: center; gap: 16rpx; margin-bottom: 12rpx; } .ps-tab { font-size: 26rpx; color: #333; padding: 8rpx 14rpx; border-radius: 999rpx; background: #f4f4f4; } .ps-tab.active { background: #d89833; color: #fff; font-weight: 800; } .ps-search { display: flex; align-items: center; gap: 10rpx; background: #f2f2f2; border-radius: 12rpx; padding: 10rpx 14rpx; } .ps-search-input { flex: 1; font-size: 24rpx; color: #333; } .ps-region-bar { padding: 10rpx 18rpx 0 18rpx; display: flex; gap: 18rpx; flex-wrap: wrap; } .ps-region { font-size: 26rpx; color: #333; } .ps-region.active { color: #d89833; font-weight: 900; } .ps-scroll { flex: 1; height: 0; } .ps-list { padding: 18rpx 22rpx; } .ps-item { height: 82rpx; border-radius: 12rpx; background: #f2f2f2; margin-bottom: 18rpx; padding: 0 18rpx; display: flex; align-items: center; justify-content: space-between; } .ps-name { font-size: 26rpx; color: #333; font-weight: 700; } .ps-right { display: flex; align-items: center; gap: 10rpx; } .ps-dot { color: #111; font-size: 20rpx; } .ps-phone { font-size: 26rpx; color: #333; font-weight: 700; } .ps-form { padding: 18rpx 22rpx; } .field { margin-bottom: 18rpx; } .label { display: block; font-size: 24rpx; color: #666; margin-bottom: 10rpx; } .input { height: 78rpx; border-radius: 12rpx; background: #f2f2f2; padding: 0 18rpx; font-size: 26rpx; color: #333; } .textarea { height: 160rpx; border-radius: 12rpx; background: #f2f2f2; padding: 14rpx 18rpx; font-size: 26rpx; color: #333; } .ps-my { padding: 18rpx 22rpx; } .ps-tip { font-size: 22rpx; color: #777; display: block; margin-bottom: 14rpx; } .ps-my-card { border-radius: 12rpx; background: #f2f2f2; padding: 14rpx 18rpx; margin-bottom: 18rpx; } .ps-my-top { display: flex; align-items: center; justify-content: space-between; } .ps-my-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 12rpx; } .link { font-size: 24rpx; color: #333; font-weight: 700; } .link.danger { color: #d9534f; } .state { font-size: 24rpx; color: #666; font-weight: 700; } /* 详情 kv */ .ps-detail .kv { display: flex; gap: 18rpx; margin-bottom: 12rpx; } .ps-detail .k { width: 88rpx; color: #666; font-size: 24rpx; } .ps-detail .v { flex: 1; color: #333; font-size: 24rpx; } /* empty */ .empty { text-align: center; color: #999; font-size: 24rpx; padding: 30rpx 0; } /* 特殊报警标题 */ .sa-title { padding: 18rpx 22rpx 0 22rpx; font-size: 24rpx; color: #666; } /* 紧急联系人 */ .ec-page { height: 100%; position: relative; } .ec-scroll { height: 100%; } .ec-empty { height: 70vh; display: flex; align-items: center; justify-content: center; } .ec-bottom-bar { position: absolute; left: 0; right: 0; bottom: 0; padding: 18rpx 22rpx calc(18rpx + env(safe-area-inset-bottom)); padding-bottom: calc(18rpx + constant(safe-area-inset-bottom)); background: #fff; border-top: 1rpx solid #f1f1f1; } .ec-confirm-btn { height: 82rpx; border-radius: 12rpx; background: #e6e6e6; display: flex; align-items: center; justify-content: center; } .ec-confirm-text { color: #666; font-size: 28rpx; font-weight: 800; } .ec-fab { position: absolute; right: 22rpx; top: 18rpx; height: 60rpx; padding: 0 18rpx; border-radius: 999rpx; background: #d89833; display: flex; align-items: center; justify-content: center; } .ec-fab-text { color: #fff; font-size: 24rpx; font-weight: 800; } </style>这个是负责展示发布评论展示内容的PublicRelease<template>
text
<view style="" class="wc-root"> <!-- 顶部二级 Tab(推荐/我的) --> <view class="wc-tabs"> <view v-for="(t, i) in tabs" :key="t.key" class="wc-tab" :class="{ active: i === tabIndex }" @tap="switchTab(i)"> <text class="wc-tab-text">{{ t.name }}</text> </view> </view> <scroll-view scroll-y class="wc-scroll" @scrolltolower="loadMore" refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="refresh"> <view v-if="feedList.length" class="wc-list"> <view v-for="(row, idx) in feedList" :key="row.id || idx" class="wc-card"> <!-- 头部:头像/昵称/时间/关注 --> <view class="wc-head"> <image class="wc-avatar" :src="row.avatarFull" mode="aspectFill" /> <view class="wc-head-mid"> <view class="wc-name-row"> <text class="wc-name">{{ row.nickname || '用户' }}</text> </view> <text class="wc-time">{{ formatTime(row.createTime || 0) }}</text> </view> <view class="wc-follow" @tap="onFollow(idx)"> <text class="wc-follow-text">{{ row.follow ? '已关注' : '+关注' }}</text> </view> </view> <!-- 正文 --> <view class="wc-content"> <text class="wc-content-text">{{ row.content || '' }}</text> </view> <!-- 图集 --> <up-album v-if="row.imageList.length" :urls="row.imageList" multiple /> <!-- 视频 --> <view v-for="(vurl, vIdx) in row.videos" :key="'video-'+(row.id || idx)+'-'+vIdx" style="height: 200rpx;width: 150rpx;border-radius: 12rpx;margin-top: 12rpx;"> <video :id="'video-'+(row.id || idx)+'-'+vIdx" :show-fullscreen-btn="false" :show-play-btn="false" :show-center-play-btn="true" :url="vurl" style="height: 100%;width: 100%;" /> </view> <!-- 展开评论区 --> <view class="wc-expand" @tap="toggleComments(idx)"> <text class="wc-expand-text"> {{ row.showComments ? '收起评论区' : `展开评论区${fmtCount(row.commentCount)}` }} </text> </view> <!-- 互动栏 --> <view class="wc-actions"> <!-- ✅ 发评论:即使未展开,也要能弹(已修复) --> <view class="wc-input" @tap="openFirstComment(row)"> <image class="wc-input-avatar" :src="myInfo.user_avatar || row.avatarFull" mode="aspectFill" /> <view class="wc-input-box"> <text class="wc-input-placeholder">发评论</text> </view> </view> <view class="wc-icons"> <view class="wc-icon" @tap.stop="toggleRowComments(idx)"> <uni-icons type="chatboxes" size="22" color="#383838" /> <text class="wc-icon-text">{{ row.commentCount || 0 }}</text> </view> <!-- 评论 --> <view class="wc-icon" @tap.stop="onLike(idx)"> <uni-icons :type="row.like ? 'hand-up-filled' : 'hand-up'" :size="22" :color="row.like ? '#D89833' : '#383838'" /> <text class="wc-icon-text">{{ row.likeCount || 0 }}</text> </view> <!-- 点赞 --> <view class="wc-icon" @tap.stop="onCollect(idx)"> <uni-icons :type="row.collect ? 'star-filled' : 'star'" :size="22" :color="row.collect ? '#D89833' : '#383838'" /> <text class="wc-icon-text">{{ row.collectCount || 0 }}</text> </view> <!-- 收藏 --> <view class="wc-icon" @tap.stop="openForward(row)"> <uni-icons type="redo" size="22" color="#383838" /> <text class="wc-icon-text">{{ row.forwardCount || 0 }}</text> </view> <!-- 转发 --> </view> </view> <!-- 评论盒子 --> <view v-if="row.showComments" class="wc-comment-box"> <scroll-view scroll-y style="border: 1rpx solid yellow;height: 82vh;" class="wc-comment-scroll"> <Warningcomment :ref="(el)=> setCmtRef(row.id, el)" v-model:myInfo="myInfo" v-model:userInfo="userInfo" v-model:tableData="row.tableData" v-model:tableTotal="row.tableTotal" :deleteMode="'all'" :allowImage="true" :allowVideo="true" :hideImages="false" :hideVideos="false" :enableFirstDelete="false" @likeFun="(payload, cb)=> commentLikeProxy(row, payload, cb)" @replyFun="(payload, cb)=> commentReplyProxy(row, payload, cb)" @deleteFun="(payload, cb)=> commentDeleteProxy(row, payload, cb)" /> </scroll-view> </view> </view> <view class="wc-foot"> <text v-if="loadingMore">加载中...</text> <text v-else-if="!hasMore">没有更多了</text> </view> </view> <!-- 空态 --> <view v-else class="wc-empty"> <text style="color:#999;">正在加载中...</text> </view> </scroll-view> <!-- 转发弹窗 --> <up-popup :show="forwardShow" :round="14" mode="center" :safeAreaInsetBottom="false" :closeable="true" @close="forwardShow=false"> <view class="fw-wrap"> <text class="fw-title">分享到</text> <view class="fw-grid"> <view v-for="app in appList" :key="app.pname" class="fw-item" @tap="launchApp(app)"> <view class="fw-icon">{{ app.short }}</view> <text class="fw-name">{{ app.name }}</text> </view> </view> <view class="fw-actions"> <view class="fw-btn" @tap="doForwardAndClose"> <text class="fw-btn-text">确认转发</text> </view> </view> </view> </up-popup> </view>
</template> <script setup> import { ref, computed, inject, onMounted, watch, nextTick } from 'vue' import ajax from '@/api/ajax.js' import Warningcomment from './Warningcomment/WarningComment.vue' const props = defineProps({ type: { type: Number, default: 2 } }) function isSuccess(res) { if (!res) return false // uni.request 标准 if (res?.statusCode === 200) return true if (res?.data?.statusCode === 200) return true if (typeof res?.errMsg === 'string' && res.errMsg.includes('ok')) return true // 兼容旧结构 if (res?.result === 'success') return true if (res?.data?.result === 'success') return true const code = res?.code ?? res?.status ?? res?.resultCode ?? res?.data?.code ?? res?.data?.status if (code === 0 || code === 200) return true if (res?.success === true || res?.data?.success === true) return true return false } function respData(res) { // 你给的真实返回:{ data: { rescueCircleArray: [...] }, statusCode:200 } return res?.data?.data ?? res?.data ?? res?.result ?? res?.obj ?? res } /** 注入 & 当前用户 */ const webProjectUrl = ref(inject('webProjectUrl') || '') const serverUrl = inject('serverUrl') const consumerId = ref(String( uni.getStorageSync('consumerId') || uni.getStorageSync('userId') || uni.getStorageSync('uid') || '' )) const myInfo = ref({ user_id: consumerId.value, user_name: uni.getStorageSync('nickname'), user_avatar: webProjectUrl.value + (uni.getStorageSync('avatarUrl') || '') }) const userInfo = ref({ ...myInfo.value }) watch(() => webProjectUrl.value, () => { myInfo.value.user_avatar = webProjectUrl.value + (uni.getStorageSync('avatarUrl') || '') userInfo.value.user_avatar = myInfo.value.user_avatar }) /** Tabs(推荐/我的) */ const tabs = ref([{ key: 'recommend', name: '推荐' }, { key: 'mine', name: '我的' }]) const tabIndex = ref(0) const currentTabKey = computed(() => tabs.value[tabIndex.value]?.key || 'recommend') /** 列表状态 */ const feedList = ref([]) const rowCount = 10 const rowStartIdx = ref(0) const loadingMore = ref(false) const hasMore = ref(true) const refreshing = ref(false) /** 本地点赞持久化 */ const LS_FIRST = 'liked_rescue_cmt_first' const LS_SECOND = 'liked_rescue_cmt_second' function readLiked(type) { const k = type === 'first' ? LS_FIRST : LS_SECOND try { return new Set(uni.getStorageSync(k) || []) } catch { return new Set() } } function writeLiked(type, set) { const k = type === 'first' ? LS_FIRST : LS_SECOND uni.setStorageSync(k, Array.from(set || [])) } const likedFirst = readLiked('first') const likedSecond = readLiked('second') /** 评论 ref */ const cmtRefs = ref({}) function setCmtRef(id, el) { if (id && el) cmtRefs.value[id] = el } /** ✅ 关键修复:未展开时先展开,再 nextTick 弹窗 */ async function openFirstComment(row) { if (!row) return if (!row.showComments) { row.showComments = true await nextTick() } const inst = cmtRefs.value[row.id] if (inst && inst.newCommentFun) inst.newCommentFun() } /** 时间格式化 */ function formatTime(val) { const d = new Date(Number(val) || 0) const pad = n => (n < 10 ? '0' + n : n) return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } function fmtCount(n) { const x = Number(n || 0) if (x <= 0) return '' if (x > 999) return '999+' return String(x) } function pickRescueArray(d) { if (Array.isArray(d?.rescueCircleArray)) return d.rescueCircleArray if (Array.isArray(d?.data?.rescueCircleArray)) return d.data.rescueCircleArray if (Array.isArray(d?.list)) return d.list if (Array.isArray(d)) return d return [] } function fullFileUrl(fileName, playURL) { if (playURL) return playURL if (!fileName) return '' if (String(fileName).startsWith('http')) return fileName return (webProjectUrl.value || '') + fileName } /** ✅ 拉列表:补上 finally,彻底避免 loading/refresh 卡死 */ async function fetchList(reset = false) { if (loadingMore.value) return if (!hasMore.value && !reset) return loadingMore.value = true try { if (reset) { rowStartIdx.value = 0 hasMore.value = true } const tabKey = currentTabKey.value const cid = tabKey === 'mine' ? consumerId.value : '' let option = { consumerId: cid, rowStartIdx: rowStartIdx.value, rowCount, type: Number(props.type) || 2 } const res = await ajax.post({ url: 'rescueCircleIndexList', method: 'POST', data: option }) // result===success 后面再加”,所以列表不强拦截 // if (!isSuccess(res)) { // uni.showToast({ title: '列表获取失败', icon: 'none' }) // return // } const arr = pickRescueArray(respData(res)) console.log(res, '里面的数据接口111111111111') const mapped = (arr || []).map(mapFeedRow) if (reset) feedList.value = mapped else feedList.value = [...feedList.value, ...mapped] if (!arr || arr.length < rowCount) hasMore.value = false rowStartIdx.value += rowCount } catch (e) { console.log(e, 'fetchList error') } finally { loadingMore.value = false refreshing.value = false } } function mapFeedRow(p) { const files = p.rescueCircleFileArray || [] const imageList = files .filter(f => f.fileName && !/\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean) const videos = files .filter(f => f.fileName && /\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean) const { tableData, tableTotal } = mapCommentsFromRescue(p) return { ...p, avatarFull: (p.avatarUrl?.startsWith('http') ? p.avatarUrl : (webProjectUrl.value || '') + (p.avatarUrl || '')), imageList, videos, tableData, tableTotal, showComments: false, follow: !!p.follow } } function mapCommentsFromRescue(row) { const list = []; // ✅ 关键:必须有分号,避免 ASI 把下一行的 () 当成调用 (row.rescueCircleLikeArray || []).forEach(first => { if (!first.comment && !(first.rescueCircleLikeCommentArray || []).length) return; const firstFiles = first.rescueCircleLikeFileArray || []; const firstImgs = firstFiles .filter(f => f.fileName && !/\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean); const firstVids = firstFiles .filter(f => f.fileName && /\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean); const firstIsLike = (typeof first.like !== 'undefined') ? !!first.like : likedFirst.has(first.id); list.push({ id: first.id, parent_id: null, reply_id: null, reply_name: null, reply_user_id: null, user_id: first.consumerId, user_name: first.nickname, user_avatar: first.avatarUrl?.startsWith('http') ? first.avatarUrl : (webProjectUrl.value || '') + (first.avatarUrl || ''), user_content: first.comment, is_like: firstIsLike, like_count: Number(first.likeCount || 0), create_time: first.createTime || 0, imageList: firstImgs, videos: firstVids, owner: String(first.consumerId) === String(consumerId.value) }); (first.rescueCircleLikeCommentArray || []).forEach(sec => { const secFiles = sec.rescueCircleLikeCommentFileArray || []; const secImgs = secFiles .filter(f => f.fileName && !/\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean); const secVids = secFiles .filter(f => f.fileName && /\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean); const secondIsLike = (typeof sec.like !== 'undefined') ? !!sec.like : likedSecond.has(sec.id); list.push({ id: sec.id, parent_id: first.id, reply_id: sec.replyConsumerId ? first.id : null, reply_name: sec.replyConsumerNickname || null, reply_user_id: sec.replyConsumerId || null, user_id: sec.consumerId, user_name: sec.nickname, user_avatar: sec.avatarUrl?.startsWith('http') ? sec.avatarUrl : (webProjectUrl.value || '') + (sec.avatarUrl || ''), user_content: sec.comment, is_like: secondIsLike, like_count: Number(sec.likeCount || 0), create_time: sec.createTime || 0, imageList: secImgs, videos: secVids, owner: String(sec.consumerId) === String(consumerId.value) }); }); }); return { tableData: list, tableTotal: Number(row.commentCount || list.length || 0) }; } /** 展开 */ function toggleComments(idx) { const row = feedList.value[idx] row.showComments = !row.showComments } function toggleRowComments(idx) { toggleComments(idx) } /** 关注(你旧接口接这里) */ function onFollow(idx) { const row = feedList.value[idx] row.follow = !row.follow } /** 帖子点赞/收藏/转发 */ async function rescueOperate(row, operateType) { const before = { like: row.like, collect: row.collect, forward: row.forward, likeCount: row.likeCount, collectCount: row.collectCount, forwardCount: row.forwardCount } if (operateType === 'like') { row.like = !row.like row.likeCount = row.like ? (Number(row.likeCount || 0) + 1) : Math.max(0, Number(row.likeCount || 0) - 1) } if (operateType === 'collect') { row.collect = !row.collect row.collectCount = row.collect ? (Number(row.collectCount || 0) + 1) : Math.max(0, Number(row .collectCount || 0) - 1) } if (operateType === 'forward') { row.forward = !row.forward row.forwardCount = row.forward ? (Number(row.forwardCount || 0) + 1) : Math.max(0, Number(row .forwardCount || 0) - 1) } try { let option = { consumerId: consumerId.value, operateType, rescueCircleId: row.id } console.log(option, '点赞评论里面的从哪苏打额打完的') const res = await ajax.post({ url: 'rescueCircleLike', method: 'POST', data: option }) if (!isSuccess(res)) throw new Error('operate fail') } catch (e) { Object.assign(row, before) uni.showToast({ title: '操作失败', icon: 'none' }) } } function onLike(idx) { rescueOperate(feedList.value[idx], 'like') } function onCollect(idx) { rescueOperate(feedList.value[idx], 'collect') } /** 评论点赞代理 */ function commentLikeProxy(_row, { params }, rollback) { const updateLocal = (type, id, isLike) => { const set = type === 'first' ? likedFirst : likedSecond if (isLike) set.add(id); else set.delete(id) writeLiked(type, set) } let option = { consumerId: consumerId.value, rescueCircleLikeId: params.id } console.log(option, '一级评论的点赞功能参数') if (!params.parent_id) { ajax.post({ url: 'rescueCircleLikeLike', method: 'POST', data: option }).then(res => { console.log(res, '一级评论的点赞内容模块1111111111') if (!isSuccess(res)) return rollback && rollback() updateLocal('first', params.id, !!params.is_like) }).catch(() => rollback && rollback()) } else { ajax.post({ url: 'likeRescueCircleLikeComment', method: 'POST', data: { id: params.id, consumerId: consumerId.value } }).then(res => { if (!isSuccess(res)) return rollback && rollback() updateLocal('second', params.id, !!params.is_like) }).catch(() => rollback && rollback()) } } /** 评论回复/上传 */ // ⚠️ 你接口文档写:rescueCircleLike 上传字段 circleLikeFile // 如果你后端实际字段是 rescueCircleLikeFile,把下面常量改回去即可 const UPLOAD_FIRST_FIELD = 'circleLikeFile' const UPLOAD_SECOND_FIELD = 'rescueCircleLikeCommentFile' function uniUpload(url, fieldName, formData, filePath) { return new Promise((resolve, reject) => { uni.uploadFile({ url: serverUrl + url, filePath, name: fieldName, header: { "Content-Type": "multipart/form-data" }, formData, success: (res) => { try { resolve(typeof res.data === 'string' ? JSON.parse(res.data) : res.data) } catch { resolve({}) } }, fail: reject }) }) } function commentReplyProxy(row, { params, files }, done) { const cid = consumerId.value if (!params.parent_id) { // 一级 const firstFile = files && files[0] const formData = { consumerId: cid, rescueCircleId: row.id, comment: params.user_content } const doUploadMore = (payloadId) => { const remains = (files || []).slice(firstFile ? 1 : 0) if (!remains.length) return done && done({ id: payloadId, files }) let chain = Promise.resolve() remains.forEach(f => { chain = chain.then(() => uniUpload('rescueCircleLike', UPLOAD_FIRST_FIELD, { consumerId: cid, id: payloadId }, f.url)) }) chain.then(() => done && done({ id: payloadId, files })).catch(() => done && done({ id: payloadId, files })) } if (firstFile) { uniUpload('rescueCircleLike', UPLOAD_FIRST_FIELD, formData, firstFile.url) .then((res) => { console.log(res, '评论上窜图片') const realId = res?.data?.id || res?.id || res?.rescueCircleLikeId doUploadMore(realId) }) .catch(() => { ajax.post({ url: 'rescueCircleLike', method: 'POST', data: formData }).then(r => { console.log(r, '里面的详情344444444444444444444444444444444444444444') done && done({ id: r?.data?.id || r?.id, files }) }) }) } else { ajax.post({ url: 'rescueCircleLike', method: 'POST', data: formData }) .then(r => done && done({ id: r?.data?.id || r?.id, files })) } } else { // 二级 const firstFile = files && files[0] const formData = { id: '', consumerId: cid, rescueCircleLikeId: params.parent_id, comment: params.user_content, replyConsumerId: params.reply_user_id ? params.reply_user_id : '' } const doUploadMoreSecond = (payloadId) => { const remains = (files || []).slice(firstFile ? 1 : 0) if (!remains.length) return done && done({ id: payloadId, files }) let chain = Promise.resolve() remains.forEach(f => { chain = chain.then(() => uniUpload('rescueCircleLikeComment', UPLOAD_SECOND_FIELD, { id: payloadId, consumerId: cid }, f.url)) }) chain.then(() => done && done({ id: payloadId, files })).catch(() => done && done({ id: payloadId, files })) } if (firstFile) { uniUpload('rescueCircleLikeComment', UPLOAD_SECOND_FIELD, formData, firstFile.url) .then((res) => { const realId = res?.data?.id || res?.id doUploadMoreSecond(realId) }) .catch(() => { ajax.post({ url: 'rescueCircleLikeComment', method: 'POST', data: formData }) .then(r => { console.log(r, '1111111111111111111111111111111111111111里面的二级评论内容模块11222222223') done && done({ id: r?.data?.id || r?.id, files }) }) }) } else { console.log(formData, '里面的参数来源111111') ajax.post({ url: 'rescueCircleLikeComment', method: 'POST', data: formData }) .then(r => { console.log(r, '1111111111111111111111111111111111111111里面的二级评论内容模块112222222233243没图片') done && done({ id: r?.data?.id || r?.id, files }) }) } } } /** 二级删除 */ function commentDeleteProxy(_row, { params }, done) { const id = params?.[0] ajax.post({ url: 'deleteRescueCircleLikeComment', method: 'POST', data: { id } }).then(res => { if (!isSuccess(res)) return uni.showToast({ title: '删除失败', icon: 'none' }) done && done() }).catch(() => uni.showToast({ title: '删除失败', icon: 'none' })) } /** 切换/加载更多/刷新 */ function switchTab(i) { tabIndex.value = i; refresh() } function loadMore() { fetchList(false) } function refresh() { refreshing.value = true; fetchList(true) } /** 转发弹窗 */ const forwardShow = ref(false) const forwardRow = ref(null) const appList = ref([{ name: '支付宝', short: '支', pname: 'com.eg.android.AlipayGphone', scheme: 'alipay://' }, { name: '淘宝', short: '淘', pname: 'com.taobao.taobao', scheme: 'taobao://' }, { name: 'QQ', short: 'Q', pname: 'com.tencent.mobileqq', scheme: 'mqq://' }, { name: '微信', short: '微', pname: 'com.tencent.mm', scheme: 'weixin://' }, { name: '京东', short: '东', pname: 'com.jingdong.app.mall', scheme: 'openApp.jdMobile://' }, { name: '微博', short: '博', pname: 'com.sina.weibo', scheme: 'sinaweibo://' }, { name: '头条', short: '头', pname: 'com.ss.android.article.news', scheme: 'snssdk141://' } ]) function openForward(row) { forwardRow.value = row; forwardShow.value = true } function launchApp(app) { try { // #ifdef APP-PLUS plus.runtime.openURL(app.scheme) // #endif } catch (e) {} } async function doForwardAndClose() { if (!forwardRow.value) return await rescueOperate(forwardRow.value, 'forward') forwardShow.value = false forwardRow.value = null } onMounted(() => { fetchList(true) }) </script> <style lang="scss" scoped> .wc-root { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; } .wc-tabs { flex-shrink: 0; display: flex; align-items: center; gap: 28rpx; padding: 12rpx 8rpx 18rpx 8rpx; } .wc-tab { font-size: 26rpx; color: #999; position: relative; padding: 6rpx 6rpx; } .wc-tab.active { color: #D89833; font-weight: 800; } .wc-tab.active::after { content: ''; position: absolute; left: 8rpx; right: 8rpx; bottom: -10rpx; height: 4rpx; border-radius: 2rpx; background: #D89833; } .wc-tab-text { font-size: 26rpx; } .wc-scroll { flex: 1; min-height: 0; } .wc-list { padding: 0 0 22rpx 0; } .wc-card { background: #fff; border-radius: 14rpx; padding: 16rpx; margin-bottom: 18rpx; box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.06); } .wc-head { display: flex; align-items: center; justify-content: space-between; } .wc-avatar { width: 74rpx; height: 74rpx; border-radius: 50%; } .wc-head-mid { flex: 1; margin-left: 14rpx; } .wc-name { font-size: 28rpx; font-weight: 800; color: #333; } .wc-time { font-size: 22rpx; color: #999; margin-top: 6rpx; } .wc-follow { width: 120rpx; height: 50rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; background: linear-gradient(to bottom, #D8983F, #E3D89D); } .wc-follow-text { font-size: 24rpx; color: #fff; font-weight: 800; } .wc-content { margin-top: 14rpx; font-size: 26rpx; color: #333; line-height: 40rpx; } .wc-expand { margin-top: 12rpx; padding: 10rpx 0; border-top: 1rpx solid #f1f1f1; border-bottom: 1rpx solid #f1f1f1; } .wc-expand-text { font-size: 24rpx; color: #999; } .wc-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 12rpx; } .wc-input { width: 56%; display: flex; align-items: center; gap: 10rpx; } .wc-input-avatar { width: 56rpx; height: 56rpx; border-radius: 50%; } .wc-input-box { flex: 1; height: 60rpx; border-radius: 999rpx; background: #EDEDED; display: flex; align-items: center; padding: 0 18rpx; } .wc-input-placeholder { font-size: 24rpx; color: #999; } .wc-icons { width: 44%; display: flex; align-items: center; justify-content: space-between; } .wc-icon { display: flex; flex-direction: column; align-items: center; gap: 4rpx; min-width: 68rpx; } .wc-icon-text { font-size: 22rpx; color: #333; } .wc-comment-box { margin-top: 12rpx; background: #fafafa; border-radius: 12rpx; overflow: hidden; } .wc-comment-scroll { height: 100%; } .wc-foot { text-align: center; color: #999; padding: 16rpx 0; font-size: 24rpx; } .wc-empty { padding: 60rpx 0; display: flex; align-items: center; justify-content: center; } /* 转发弹窗 */ .fw-wrap { width: 620rpx; padding: 20rpx 18rpx 22rpx 18rpx; } .fw-title { display: block; text-align: center; font-size: 28rpx; font-weight: 900; color: #333; margin-bottom: 18rpx; } .fw-grid { display: flex; flex-wrap: wrap; gap: 16rpx; justify-content: space-between; } .fw-item { width: calc((100% - 16rpx*3) / 4); height: 140rpx; border-radius: 14rpx; background: #f2f2f2; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10rpx; } .fw-icon { width: 70rpx; height: 70rpx; border-radius: 16rpx; background: #D89833; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: 30rpx; } .fw-name { font-size: 22rpx; color: #333; font-weight: 700; } .fw-actions { margin-top: 18rpx; } .fw-btn { height: 82rpx; border-radius: 12rpx; background: #D89833; display: flex; align-items: center; justify-content: center; } .fw-btn-text { color: #fff; font-size: 28rpx; font-weight: 900; } </style>这个是统一的发布内容页面流程PublishContentPage.vue<template>
text
<view :class="['app-container', theme]"> <view class="rr-root"> <!-- 顶部 Header(fixed) --> <view class="rr-header" :style="{ paddingTop: statusBarHeight + 'px' }"> <view class="rr-header-inner" :style="{ height: navBarHeight + 'px' }"> <view class="rr-left" @tap="navigateBack"> <uni-icons type="left" color="#000000" size="26" /> </view> <view class="rr-center"> <text class="rr-title">{{ currentTitle }}</text> </view> <view class="rr-right" /> </view> </view> <view class="rr-main" :style="{ paddingTop: headerTotalPx + 'px' }"> <!-- 评论/发布(必须永远露出来) --> <view class="rr-tabs"> <view class="rr-tab" :class="{ active: tab === 'comment' }" @tap="switchTab('comment')">评论</view> <view class="rr-tab" :class="{ active: tab === 'publish' }" @tap="switchTab('publish')">发布</view> </view> <!-- body:flex 打通高度链 --> <view class="rr-body"> <!-- 评论 --> <view v-show="tab === 'comment'" style="border: 1rpx solid red;" class="rr-comment-wrap"> <!-- ✅ type 由路由传入,默认 2 --> <Warningcontent :key="contentKey" :type="rescueType" /> </view> <!-- 发布 --> <view v-show="tab === 'publish'" class="rr-publish-wrap"> <scroll-view scroll-y class="rr-publish-scroll"> <view class="page-body"> <view class="describe"> <view class="texter"> <textarea v-model="textAreaContent" placeholder="添加作品描述..." maxlength="500" @input="onTextInput" style="width: 100%;" /> <view class="counter"> <text class="counter-text">{{ textAreaContentLength }}/500字</text> </view> </view> <view class="actions-row"> <view class="chip" @tap="toggleTopics">#话题</view> <view class="chip" @tap="toggleFriends">@朋友</view> <view class="chip ghost" @tap="selectsfile()"> 上传类型:{{ TemporaryValue === 'video' ? '视频' : '图片' }} </view> </view> <TopicList v-if="showTopics" :topics="topics" @select-topic="handleSelectTopic" /> <FriendList v-if="showFriends" :friends="friends" @select-friend="handleSelectFriend" /> <!-- ✅ 视频预览:选了视频就展示第一个 --> <view class="video-wrap" v-if="videoSrc"> <video :src="videoSrc" style="height: 100%; width: 100%;" :controls="true" :show-fullscreen-btn="false" loop show-center-play-btn="false" object-fit="fill" /> </view> </view> <view class="upload-card"> <view class="upload-title"> 添加视频/图片 <text class="upload-sub">(最多 9 个)</text> </view> <up-upload :fileList="fileList1" @afterRead="afterRead" :accept="TemporaryValue" @delete="deletePic" name="1" multiple :maxCount="9" /> <view class="upload-tip" v-if="fileList1.length"> <text>已选择:{{ fileList1.length }} 个</text> <text v-if="uploadingCount > 0" style="margin-left: 14rpx;">上传中:{{ uploadingCount }} 个</text> <text v-if="failedCount > 0" style="margin-left: 14rpx; color:#ff4d4f;">失败:{{ failedCount }} 个</text> </view> </view> <view style="width: 100%; height: 116rpx;"></view> <view class="publish-row"> <view class="publish-btn" :class="{ disabled: publishing }" @tap="handlePublish"> <view class="publish-inner"> <uni-icons v-if="publishing" type="spinner-cycle" size="18" color="#fff" /> <text class="publish-text">{{ publishing ? publishProgressText : '发布' }}</text> </view> </view> </view> <view style="height: 36rpx;"></view> </view> </scroll-view> </view> </view> </view> </view> </view>
</template> <script setup> import { ref, computed, onMounted, inject, watch } from 'vue' import { onLoad, onShow } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import ajax from '@/api/ajax.js' import Warningcontent from '@/components/HomepageComponents/CommonModule/CommentModule/Warningcontent.vue' import TopicList from '@/components/TopicList.vue' import FriendList from '@/components/FriendList.vue' import { Getcontacts, Gettopics } from '@/api/api.js' import { usechanne } from '@/store/useChannel.js' /** theme */ const store = useMainStore() const { theme } = storeToRefs(store) /** ✅ 高度:优先用你们 util;小程序再用胶囊修正 */ const sys = uni.getSystemInfoSync() const statusBarHeight = ref(Number(getStatusBarHeight?.() || sys?.statusBarHeight || 0)) const navBarHeight = ref(Number(getTitleBarHeight?.() || 44)) try { const menu = uni.getMenuButtonBoundingClientRect?.() if (menu?.height) { const gap = Number(menu.top || 0) - Number(statusBarHeight.value || 0) navBarHeight.value = Number(menu.height || 44) + gap * 2 } } catch (e) {} const headerTotalPx = computed(() => Number(statusBarHeight.value || 0) + Number(navBarHeight.value || 0)) /** tab */ const tab = ref('comment') const contentKey = ref(1) const currentTitle = computed(() => (tab.value === 'publish' ? '发布作品' : '救援同频')) /** route options */ const backOnSuccess = ref(false) /** ✅ type(救援同频/即时救援/分类救援) */ const rescueType = ref(2) /** org guard */ const channelStore = usechanne() async function guardOrgOrRoute() { const orgTypeName = uni.getStorageSync('orgTypeName') || '' return await channelStore.ensureOrgCertifiedAndRoute({ orgTypeName, redirect: true }) } function navigateBack() { uni.navigateBack() } function switchTab(next) { tab.value = next if (next === 'comment') contentKey.value += 1 } onLoad(async (option) => { tab.value = String(option?.tab || 'comment') backOnSuccess.value = String(option?.backOnSuccess || '') === '1' rescueType.value = Number(option?.type || 2) const ok = await guardOrgOrRoute() if (!ok) return }) onShow(async () => { const ok = await guardOrgOrRoute() if (!ok) return if (tab.value === 'comment') contentKey.value += 1 }) /** ===== 发布逻辑 ===== */ const textAreaContent = ref('') const textAreaContentLength = computed(() => textAreaContent.value.length) const onTextInput = () => { if (textAreaContent.value.length > 500) { textAreaContent.value = textAreaContent.value.slice(0, 500) uni.showToast({ title: '文字长度不能超过500字', icon: 'none' }) } } const showTopics = ref(false) const showFriends = ref(false) const topics = ref([]) const friends = ref([]) const selectedTopicIds = ref([]) const FriendID = ref('') const toggleTopics = () => { if (!textAreaContent.value.endsWith('#')) textAreaContent.value += '#' showTopics.value = true showFriends.value = false } const toggleFriends = () => { showFriends.value = true showTopics.value = false } const handleSelectTopic = (topic) => { textAreaContent.value += topic?.name ? topic.name : '' showTopics.value = false if (topic?.id != null) { const id = String(topic.id) if (!selectedTopicIds.value.includes(id)) selectedTopicIds.value.push(id) } } const handleSelectFriend = (friend) => { textAreaContent.value += friend?.nickname ? '@' + friend.nickname : friend?.name ? '@' + friend.name : '' showFriends.value = false FriendID.value = friend?.id || '' } let consumerId = ref(uni.getStorageSync('consumerId') || '') let optionFriend = ref({ consumerId: consumerId.value, keyword: '', rowStartIdx: 0, rowCount: '20' }) async function triggerfunction() { try { let res = await Getcontacts(optionFriend.value) if (res?.result === 'success') friends.value = res.friendArray || [] else friends.value = res?.friendArray || [] } catch { uni.showToast({ title: '好友列表获取失败', icon: 'none' }) } } let paramsTopic = { keyword: '', rowStartIdx: 0, rowCount: '20' } const TopicDetails = async () => { try { let res = await Gettopics(paramsTopic) topics.value = res.tagArray || [] } catch { uni.showToast({ title: '话题获取失败', icon: 'none' }) } } onMounted(() => { triggerfunction() TopicDetails() }) const TemporaryValue = ref('image') const fileList1 = ref([]) const deletePic = (event) => fileList1.value.splice(event.index, 1) const afterRead = (event) => { const lists = Array.isArray(event.file) ? event.file : [].concat(event.file) lists.forEach((item) => { const url = item?.url || item?.path || item?.tempFilePath if (!url) return fileList1.value.push({ ...item, url, status: 'ready', message: '等待上传' }) }) } /** ✅ 切换上传类型时,清空列表,避免混传 */ const selectsfile = () => { if (publishing.value) return uni.showActionSheet({ itemList: ['上传图片', '上传视频'], itemColor: '#000000', success: (res) => { const next = res.tapIndex === 1 ? 'video' : 'image' if (TemporaryValue.value !== next) { fileList1.value = [] WorkID.value = '' videoSrc.value = '' } TemporaryValue.value = next uni.showToast({ title: `已切换为${next === 'video' ? '视频' : '图片'}`, icon: 'none' }) }, fail: () => uni.showToast({ title: '取消选择', icon: 'none' }) }) } /** ✅ video 预览 */ const videoSrc = ref('') watch([fileList1, TemporaryValue], () => { if (TemporaryValue.value !== 'video') { videoSrc.value = '' return } const first = fileList1.value?.[0] videoSrc.value = first?.url || '' }, { deep: true }) const publishing = ref(false) const publishIndex = ref(0) const publishTotal = ref(0) const uploadingCount = computed(() => fileList1.value.filter((f) => f.status === 'uploading').length) const failedCount = computed(() => fileList1.value.filter((f) => f.status === 'failed').length) const publishProgressText = computed(() => { if (!publishing.value) return '' if (publishTotal.value <= 0) return '发布中...' return `发布中 ${publishIndex.value}/${publishTotal.value}` }) function safeParseJSON(maybe) { if (maybe == null) return { ok: false, data: null, raw: '' } if (typeof maybe === 'object') return { ok: true, data: maybe, raw: '' } const raw = String(maybe) const trimmed = raw.trim() if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html') || trimmed.startsWith('<')) return { ok: false, data: null, raw } try { return { ok: true, data: JSON.parse(raw), raw } } catch { return { ok: false, data: null, raw } } } function shortText(str = '', n = 160) { const s = String(str || '') return s.length > n ? s.slice(0, n) + '...' : s } /** ✅ 兼容取 id:id / data.id / data.data.id */ function pickId(obj) { return obj?.id || obj?.data?.id || obj?.data?.data?.id || obj?.rescueCircleId || obj?.data?.rescueCircleId || '' } /** ✅ 统一组装 payload(把 friendIds/tagIds 做成数组) */ function buildPayload() { const tagIds = (selectedTopicIds.value || []).map(String) const friendIds = FriendID.value ? [String(FriendID.value)] : [] return { consumerId: String(consumerId.value || ''), content: String(textAreaContent.value || ''), id: String(WorkID.value || ''), tagIds, // ✅ 数组 friendIds, // ✅ 数组 type: Number(rescueType.value || 2) } } const serverUrl = inject('serverUrl') const WorkID = ref('') const UPLOAD_URL = 'rescueCircle' const uploadFilePromise = (filePath) => { return new Promise((resolve, reject) => { const payload = buildPayload() uni.uploadFile({ url: serverUrl + UPLOAD_URL, filePath, name: 'rescueCircleFile', header: { 'Content-Type': 'multipart/form-data' }, formData: payload, success: (res) => { const parsed = safeParseJSON(res?.data) if (!parsed.ok) return reject(new Error('接口返回非JSON:' + shortText(parsed.raw))) const data = parsed.data || {} const newId = pickId(data) if (newId) WorkID.value = newId resolve(data) }, fail: (err) => reject(err) }) }) } /** ✅ 只有文字也要发请求(否则你现在会“假成功”) */ async function publishTextOnly() { const payload = buildPayload() // 后端若不接受数组,可改成:tagIds: payload.tagIds.join(',') / friendIds: payload.friendIds.join(',') const res = await ajax.post({ url: 'rescueCircle', method: 'POST', data: payload }) // 兼容:uni.request 风格 const ok = (res?.statusCode === 200) || (res?.data?.statusCode === 200) || (res?.errMsg?.includes('ok')) if (!ok) throw new Error('发布失败') const newId = pickId(res?.data || res) if (newId) WorkID.value = newId return res } async function guardBeforePublish() { return await guardOrgOrRoute() } const handlePublish = async () => { if (publishing.value) return const ok = await guardBeforePublish() if (!ok) return const readyFiles = fileList1.value.filter((f) => f.status === 'ready') if (readyFiles.length === 0 && !textAreaContent.value.trim()) { uni.showToast({ title: '请添加内容或图片/视频', icon: 'none' }) return } if (!consumerId.value) { uni.showToast({ title: '缺少consumerId,请先登录', icon: 'none' }) return } publishing.value = true publishIndex.value = 0 publishTotal.value = readyFiles.length uni.showLoading({ title: '发布中...', mask: true }) try { // ✅ 只有文字:也要走接口 if (readyFiles.length === 0) { await publishTextOnly() } else { for (let i = 0; i < readyFiles.length; i++) { const file = readyFiles[i] publishIndex.value = i + 1 file.status = 'uploading' file.message = '上传中...' try { const res = await uploadFilePromise(file.url) file.status = 'success' file.message = '' // 若后端返回 url,可替换 const u = res?.url || res?.data?.url if (u) file.url = u } catch (err) { file.status = 'failed' file.message = '上传失败' uni.hideLoading() uni.showModal({ title: '上传失败', content: '原因:' + (err?.message || '上传失败') + '\n\n常见原因:\n1)UPLOAD_URL 写错 / 后端返回 HTML\n2)服务 502/404\n3)后端返回不是 JSON', showCancel: false }) throw err } } } uni.hideLoading() uni.showToast({ title: '发布成功', icon: 'success' }) // ✅ 重置发布态 const resetPublishState = () => { textAreaContent.value = '' selectedTopicIds.value = [] FriendID.value = '' fileList1.value = [] WorkID.value = '' videoSrc.value = '' showTopics.value = false showFriends.value = false } setTimeout(() => { resetPublishState() if (backOnSuccess.value) uni.navigateBack({ delta: 1 }) else { tab.value = 'comment' contentKey.value += 1 } }, 500) } catch (e) { uni.hideLoading() } finally { publishing.value = false } } </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container { padding-top: 0 !important; } .rr-root { height: 100vh; overflow: hidden; background: #fff; } /* header */ .rr-header { position: fixed; left: 0; right: 0; top: 0; z-index: 999; background: #fff; } .rr-header-inner { position: relative; display: flex; align-items: center; justify-content: center; } .rr-left, .rr-right { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .rr-left { left: 0; justify-content: flex-start; } .rr-right { right: 0; justify-content: flex-end; } .rr-title { font-size: 32rpx; color: #000; font-weight: 700; } /* ✅ main:关键!高度链完整 + padding-top 顶开 header */ .rr-main { height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; min-height: 0; } /* 顶部 评论/发布 tabs 一定露出来 */ .rr-tabs { height: 84rpx; display: flex; align-items: center; justify-content: center; gap: 36rpx; border-bottom: 1rpx solid #f1f1f1; background: #fff; flex-shrink: 0; } .rr-tab { font-size: 28rpx; color: #999; padding: 14rpx 18rpx; border-radius: 999rpx; } .rr-tab.active { color: #fff; background: #D89833; font-weight: 800; } /* body */ .rr-body { flex: 1; min-height: 0; display: flex; flex-direction: column; } /* 评论模块必须吃满 */ .rr-comment-wrap { flex: 1; min-height: 0; display: flex; } /* 发布也吃满 */ .rr-publish-wrap { flex: 1; min-height: 0; display: flex; } .rr-publish-scroll { flex: 1; min-height: 0; background: #fff; } /* ===== 发布样式 ===== */ .page-body { width: 94%; margin: 0 auto; padding: 24rpx 0 40rpx 0; } .describe .texter { position: relative; border-radius: 20rpx; box-shadow: 0 2rpx 14rpx rgba(0, 0, 0, 0.06); background: #fff; padding: 20rpx; min-height: 240rpx; } .describe textarea { min-height: 200rpx; font-size: 28rpx; } .counter { position: absolute; right: 10rpx; bottom: 10rpx; } .counter-text { font-size: 26rpx; color: #999; } .actions-row { display: flex; align-items: center; margin-top: 24rpx; gap: 20rpx; flex-wrap: wrap; } .chip { height: 50rpx; padding: 0 24rpx; border-radius: 50rpx; background-color: #D89833; font-size: 26rpx; display: flex; align-items: center; justify-content: center; color: #fff; } .chip.ghost { background-color: transparent; border: 1rpx solid rgba(0, 0, 0, 0.12); color: #333; } .video-wrap { width: 220rpx; height: 300rpx; margin-top: 24rpx; border-radius: 15rpx; overflow: hidden; background: #000; } .upload-card { margin-top: 24rpx; padding: 20rpx; background-color: #ffffff; border-radius: 12rpx; } .upload-title { font-size: 28rpx; color: #111; display: flex; align-items: center; gap: 10rpx; margin-bottom: 12rpx; } .upload-sub { font-size: 24rpx; color: #999; } .upload-tip { margin-top: 10rpx; font-size: 24rpx; color: #666; } .publish-row { width: 100%; display: flex; justify-content: center; align-items: center; } .publish-btn { width: 224rpx; height: 72rpx; background-color: #D89833; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; } .publish-btn.disabled { opacity: 0.6; } .publish-inner { display: flex; align-items: center; gap: 10rpx; } .publish-text { color: #ffffff; font-size: 28rpx; } </style>现在这个页面PublishContentPage不在负责展示评论了就全部负责发布评论内容模块 type值不是2了是Rescue Channel这个发布页面是通用的页面好几个发布流程都是用的这个页面就是根据type不同而已

这个也是主要展示评论内容的一个公用的组件PublicRelease WarningComment这些就是评论的组件了<template>
<view style="height: 100%;" class="c-root">
<view v-if="props.CommentType !== 'modeling'" class="c_total">评论 {{ props.tableTotal }}</view>

text
<template v-if="dataList && dataList.length"> <view class="c_comment" v-for="(item1, index1) in dataList" :key="item1.id"> <!-- 一级评论 --> <CommonComp :data="item1" :pData="null" :hideImages="props.hideImages" :hideVideos="props.hideVideos" :enableFirstDelete="props.enableFirstDelete" @likeClick="() => onLike({ item1, index1 })" @replyClick="() => onReply({ item1, index1 })" @deleteClick="() => onDelete({ item1, index1 })" /> <!-- 二级评论 --> <view class="children_item" v-if="item1.children && item1.children.length"> <CommonComp v-for="(item2, index2) in item1.childrenShow" :key="item2.id" :data="item2" :pData="item1" :hideImages="props.hideImages" :hideVideos="props.hideVideos" :enableFirstDelete="props.enableFirstDelete" @likeClick="() => onLike({ item1, index1, item2, index2 })" @replyClick="() => onReply({ item1, index1, item2, index2 })" @deleteClick="() => onDelete({ item1, index1, item2, index2 })" /> <!-- 展开二级 --> <view class="expand_reply" v-if="expandTxtShow(item1)" @tap="() => expandReply(index1)"> <span class="txt"> 展开{{ item1.children.length - item1.childrenShow.length }}条回复 </span> <uni-icons type="down" size="22" color="#D89833" /> </view> <!-- 收起二级 --> <view class="shrink_reply" v-if="shrinkTxtShow(item1)" @tap="() => shrinkReply(index1)"> <span class="txt"> 收起回复内容 </span> <uni-icons type="up" size="22" color="#D89833" /> </view> </view> </view> </template> <!-- 空态 --> <view class="empty_box" v-else> <uni-icons type="chatboxes" size="36" color="#c0c0c0" /> <view> <span class="txt"> 这里是一片荒草地, </span> <span class="txt click" @click="newComment">说点什么...</span> </view> </view> <!-- 评论弹窗(统一一套) --> <up-popup :show="iscomment" :round="12" mode="center" :safeAreaInsetBottom="false" :closeable="true" @close="closeComment"> <view style="height: 80rpx;width: 100%;"></view> <view style="height: 320rpx;padding: 10rpx;width:750rpx;"> <view style="width: 100%;display: flex;justify-content: space-between;box-sizing: border-box;height: 286rpx;"> <view style="width: 10%;display: flex;box-sizing: border-box;"> <view style="height: 60rpx;width: 60rpx;border-radius: 50%;"> <image style="height: 100%;width: 100%;border-radius: 50%;" :src="myInfo.user_avatar || defaultAvatar" /> </view> </view> <view style="width: 90%;display: flex;box-sizing: border-box;height: 100%;"> <view style="height: 100%;width: 100%;position: relative;"> <view style="width: 100%;height: 170rpx;"> <textarea v-model="textAreaContent" :placeholder="replyPlaceholder" maxlength="500" style="width: 100%;background-color: #EDEDED;border-radius: 12rpx;margin-top: 10rpx;margin-left: 10rpx;height: 100%;padding: 10rpx;box-sizing: border-box;" /> </view> <view style="position: absolute;bottom: 0%;width: 100%;display: flex;justify-content: space-between;box-sizing: border-box;z-index: 9;"> <view style="width: 50%;display: flex;box-sizing: border-box;"> <view v-if="props.allowImage || props.allowVideo" @click="selectsfile()"> <up-upload height="50rpx" width="50rpx" :fileList="fileList" @afterRead="afterRead" :accept="TemporaryValue" @delete="deletePic" name="1" multiple :maxCount="99" /> </view> </view> <view style="width: 50%;display: flex;box-sizing: border-box;justify-content: flex-end;"> <up-button @click="handleSend()" style="border-radius: 12rpx;width: 160rpx;height: 56rpx;" type="warning" text="发送" /> </view> </view> </view> </view> </view> </view> </up-popup> </view>
</template> <script setup> import CommonComp from "./componets/common.vue" import { ref, watch, computed, defineExpose } from "vue" const props = defineProps({ myInfo: { type: Object, default: () => ({}) }, userInfo: { type: Object, default: () => ({}) }, tableData: { type: Array, default: () => [] }, tableTotal: { type: Number, default: 0 }, deleteMode: { type: String, default: "all" }, CommentType: { type: String, default: "" }, hideImages: { type: Boolean, default: false }, hideVideos: { type: Boolean, default: false }, allowImage: { type: Boolean, default: true }, allowVideo: { type: Boolean, default: true }, enableFirstDelete: { type: Boolean, default: false } }) const emit = defineEmits(["update:tableTotal", "likeFun", "replyFun", "deleteFun"]) const defaultAvatar = 'https://pic.rmb.bdstatic.com/bjh/down/p3mNb7w9etGzEXpYdGi2Ww4e49cb34a4c7d3e57a4f9676fbd8b426.jpg?for=bg' /** 树结构 */ const dataList = ref([]) watch( () => props.tableData, (newVal) => { if (!newVal) { dataList.value = []; return } dataList.value = treeTransForm(newVal) }, { deep: true, immediate: true } ) function treeTransForm(data) { const newData = JSON.parse(JSON.stringify(data || [])) const result = [], map = {} newData.forEach((item) => { item.owner = String(item.user_id) === String(props.myInfo.user_id) item.author = String(item.user_id) === String(props.userInfo.user_id) map[item.id] = item }) newData.forEach((item) => { const parent = map[item.parent_id] if (parent) { ; (parent.children || (parent.children = [])).push(item) if (parent.children.length === 1)(parent.childrenShow = []).push(item) } else { result.push(item) } }) return result } /** 点赞(一级/二级) */ function setLike(item) { item.is_like = !item.is_like item.like_count = item.is_like ? (item.like_count || 0) + 1 : Math.max(0, (item.like_count || 0) - 1) } function onLike({ item1, item2 }) { const item = item2 || item1 setLike(item) emit("likeFun", { params: item }, () => { // 失败回滚 setLike(item) }) } /** 弹窗:评论/回复 */ const iscomment = ref(false) const replyTemp = ref({}) const textAreaContent = ref("") const TemporaryValue = ref("image") const fileList = ref([]) const replyPlaceholder = computed(() => { if (!Object.keys(replyTemp.value).length) return "发评论" const t = replyTemp.value const user = (t.item2 || t.item1) || {} return user.user_name ? `回复 @${user.user_name}` : "发评论" }) function newComment() { replyTemp.value = {} textAreaContent.value = "" fileList.value = [] TemporaryValue.value = props.allowImage ? "image" : (props.allowVideo ? "video" : "image") iscomment.value = true } defineExpose({ newCommentFun: newComment }) function onReply(ctx) { replyTemp.value = JSON.parse(JSON.stringify(ctx || {})) textAreaContent.value = "" fileList.value = [] TemporaryValue.value = props.allowImage ? "image" : (props.allowVideo ? "video" : "image") iscomment.value = true } function closeComment() { iscomment.value = false replyTemp.value = {} textAreaContent.value = "" fileList.value = [] } /** 选择文件:按允许项动态展示 */ function selectsfile() { if (!props.allowImage && !props.allowVideo) { uni.showToast({ title: "此处不支持上传附件", icon: "none" }) return } const items = [] if (props.allowImage) items.push("上传图片") if (props.allowVideo) items.push("上传视频") uni.showActionSheet({ itemList: items, itemColor: "#D89833", success: (res) => { const pick = items[res.tapIndex] TemporaryValue.value = pick === "上传视频" ? "video" : "image" } }) } function deletePic(e) { fileList.value.splice(e.index, 1) } function afterRead(e) { if ((TemporaryValue.value === "image" && !props.allowImage) || (TemporaryValue.value === "video" && !props.allowVideo)) { uni.showToast({ title: "当前不允许该类型上传", icon: "none" }) return } const lists = [].concat(e.file || []) lists.forEach((f) => fileList.value.push({ ...f, status: "ready", message: "待上传" })) } /** 发送(一级/二级) */ async function handleSend() { const content = (textAreaContent.value || "").trim() const hasFiles = (props.allowImage || props.allowVideo) && fileList.value.some(f => f.status === "ready") if (!content && !hasFiles) { uni.showToast({ title: "请填写内容或选择图片/视频", icon: "none" }) return } // 组装 params let params = {} if (!Object.keys(replyTemp.value).length) { // 一级 params = { id: Math.random(), parent_id: null, reply_id: null, reply_name: null, reply_user_id: null } } else { const { item1, item2 } = replyTemp.value const target = item2 || item1 params = { id: Math.random(), parent_id: target?.parent_id ?? target.id, reply_id: target.id, reply_name: target.user_name, reply_user_id: target.user_id } } params = { ...params, user_id: props.myInfo.user_id, user_name: props.myInfo.user_name, user_avatar: props.myInfo.user_avatar, user_content: content, is_like: false, like_count: 0, create_time: Date.now(), owner: true } const readyFiles = (props.allowImage || props.allowVideo) ? fileList.value.filter(f => f.status === "ready") : [] const filesPayload = readyFiles.map(f => ({ url: f.url, name: f.name || "" })) uni.showLoading({ title: "正在发送", mask: true }) emit("replyFun", { params, files: filesPayload }, (res) => { uni.hideLoading() params.id = res?.id || params.id // 写入图片/视频数组 const imgs = [] const vids = [] if (!props.hideImages || !props.hideVideos) { ; (res?.files || filesPayload).forEach((f) => { const u = f.playURL || f.url || "" if (!u) return if (/\.(mp4|mov|avi|mkv)$/i.test(u)) vids.push(u) else imgs.push(u) }) } params.imageList = props.hideImages ? [] : imgs params.videos = props.hideVideos ? [] : vids // 插入本地 if (!Object.keys(replyTemp.value).length) { dataList.value.push(params) } else { const { index1 } = replyTemp.value const cData = dataList.value[index1]; (cData.children || (cData.children = [])).push(params) if (cData.children.length === (cData.childrenShow || (cData.childrenShow = [])).length + 1) { cData.childrenShow.push(params) } } emit("update:tableTotal", props.tableTotal + 1) closeComment() }) } /** 删除(支持二级;一级默认不开) */ function onDelete({ index1, index2 }) { if (index2 >= 0) { // 二级 const willDelId = dataList.value[index1].children[index2].id uni.showModal({ title: "删除评论", content: "你确定要删除评论吗", confirmText: "确定", success: (res) => { if (!res.confirm) return uni.showLoading({ title: "正在删除", mask: true }) emit("deleteFun", { params: [willDelId], mode: "only", layer: "second" }, () => { uni.hideLoading() const c = dataList.value[index1] c.children.splice(index2, 1) c.childrenShow.splice(index2, 1) emit("update:tableTotal", Math.max(0, props.tableTotal - 1)) }) } }) } else { // 一级(默认不删,保持你原逻辑) if (!props.enableFirstDelete) return } } /** 展开/收起二级 */ function expandTxtShow(item1) { return (item1.childrenShow?.length || 0) && (item1.children.length - item1.childrenShow.length) } function expandReply(index1) { const csLen = dataList.value[index1].childrenShow.length dataList.value[index1].childrenShow.push(...dataList.value[index1].children.slice(csLen, csLen + 6)) } function shrinkTxtShow(item1) { return (item1.childrenShow?.length || 0) >= 2 && (item1.children.length - item1.childrenShow.length === 0) } function shrinkReply(index1) { dataList.value[index1].childrenShow = [] dataList.value[index1].childrenShow.push(...dataList.value[index1].children.slice(0, 1)) } </script> <style lang="scss" scoped> .c_total { padding: 20rpx 30rpx 0 30rpx; font-size: 28rpx; font-weight: 800; color: #333; } .empty_box { display: flex; justify-content: center; align-items: center; flex-direction: column; padding: 150rpx 10rpx; font-size: 28rpx; .txt { color: #D89833; } .click { color: #D89833; font-weight: 800; } } .c_comment { padding: 16rpx 18rpx; font-size: 28rpx; .children_item { padding: 14rpx 16rpx; margin-top: 10rpx; margin-left: 72rpx; background: #fff; border-radius: 12rpx; .expand_reply, .shrink_reply { margin-top: 10rpx; margin-left: 60rpx; display: flex; align-items: center; gap: 10rpx; .txt { font-weight: 900; color: #D89833; font-size: 24rpx; } } } } </style>common<template>
text
<view class="cm-root"> <image class="cm-avatar" :src="avatarSrc" mode="aspectFill" /> <view class="cm-body"> <view class="cm-top"> <view class="cm-name-line"> <text class="cm-name">{{ data.user_name || '用户' }}</text> <text v-if="isSecond && data.reply_name" class="cm-reply"> 回复 <text class="cm-at">@{{ data.reply_name }}</text> </text> </view> <text class="cm-time">{{ timeText }}</text> </view> <view class="cm-content"> <text class="cm-content-text">{{ data.user_content || '' }}</text> </view> <!-- 图片/视频(由上层映射进 imageList/videos) --> <view v-if="!hideImages && data.imageList && data.imageList.length" class="cm-media"> <up-album :urls="data.imageList" multiple /> </view> <view v-if="!hideVideos && data.videos && data.videos.length" class="cm-media"> <view v-for="(vurl, vIdx) in data.videos" :key="data.id + '-v-' + vIdx" class="cm-video"> <video :id="data.id + '-v-' + vIdx" :show-fullscreen-btn="false" :show-play-btn="false" :show-center-play-btn="true" :url="vurl" style="height: 100%;width: 100%;" /> </view> </view> <!-- 操作区 --> <view class="cm-actions"> <view class="cm-action" @tap="$emit('likeClick')"> <uni-icons :type="data.is_like ? 'hand-up-filled' : 'hand-up'" :size="20" :color="data.is_like ? '#D89833' : '#999'" /> <text class="cm-action-text">{{ data.like_count || 0 }}</text> </view> <view class="cm-action" @tap="$emit('replyClick')"> <uni-icons type="chat" size="20" color="#999" /> <text class="cm-action-text">回复</text> </view> <view v-if="canDelete" class="cm-action danger" @tap="$emit('deleteClick')"> <uni-icons type="trash" size="20" color="#d9534f" /> <text class="cm-action-text danger-text">删除</text> </view> </view> </view> </view>
</template> <script setup> import { computed } from 'vue' const props = defineProps({ data: { type: Object, default: () => ({}) }, pData: { type: Object, default: null }, // 有 pData 表示二级 hideImages: { type: Boolean, default: false }, hideVideos: { type: Boolean, default: false }, enableFirstDelete: { type: Boolean, default: false } }) defineEmits(['likeClick', 'replyClick', 'deleteClick']) const isSecond = computed(() => !!props.pData) const avatarSrc = computed(() => { return props.data.user_avatar || 'https://pic.rmb.bdstatic.com/bjh/down/p3mNb7w9etGzEXpYdGi2Ww4e49cb34a4c7d3e57a4f9676fbd8b426.jpg?for=bg' }) const timeText = computed(() => { const t = Number(props.data.create_time || 0) if (!t) return '' const d = new Date(t) const pad = n => (n < 10 ? '0' + n : n) return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` }) const canDelete = computed(() => { // 二级:本人可删;一级:enableFirstDelete 且本人可删 const owner = !!props.data.owner if (isSecond.value) return owner return owner && !!props.enableFirstDelete }) </script> <style lang="scss" scoped> .cm-root { display: flex; gap: 12rpx; padding: 12rpx 0; } .cm-avatar { width: 56rpx; height: 56rpx; border-radius: 50%; } .cm-body { flex: 1; } .cm-top { display: flex; flex-direction: column; gap: 6rpx; } .cm-name-line { display: flex; align-items: center; gap: 10rpx; flex-wrap: wrap; } .cm-name { font-size: 26rpx; font-weight: 900; color: #333; } .cm-reply { font-size: 22rpx; color: #666; } .cm-at { color: #D89833; font-weight: 800; } .cm-time { font-size: 22rpx; color: #999; } .cm-content { margin-top: 8rpx; } .cm-content-text { font-size: 26rpx; color: #333; line-height: 38rpx; } .cm-media { margin-top: 10rpx; } .cm-video { height: 200rpx; width: 150rpx; border-radius: 12rpx; margin-top: 10rpx; overflow: hidden; } .cm-actions { display: flex; align-items: center; gap: 22rpx; margin-top: 10rpx; } .cm-action { display: flex; align-items: center; gap: 8rpx; } .cm-action-text { font-size: 22rpx; color: #999; font-weight: 700; } .danger-text { color: #d9534f; font-weight: 900; } </style>按我的需要 根据图片来 将这个PublicRelease组件 点击救援同频就是展示这个组件 然后右面是榜单 有图拍给你参考的最右面是个按钮发布点击发布就跳到pages/biometrics/Publicrescue/PublishContentPage

这个发布页面了 其中这个PublishContentPage 发布是公共的页面和PublicRelease这个是展示评论的公共的组件这次的发布type 是RescueChannel 拉取的时候也是这个 修复一下这个PublicRelease
展示的时候为什么只漏出一半另一半被遮挡了 然后返回一个最完整的PublicRelease页面的超级组件返回全部的代码功能 和PublishContentPage这个通用的发布页面也是返回全部的代码功能
一点代码都不少并且需要按照我说的去完成改善 这个Sharedrescue 把新扩展的功能加上 返回局部的代码量最后一张图是目前的组件内容被遮挡效果
这RescueChannel组件展示页面负责展示点击救援同频就展示这个组件里面有救援同频 榜单 然后可以切换 点击发布就跳走了到时候组件Sharedrescue引用 组件路径@/components/HomepageComponents/CommonModule/CommentModule/RescueChannel/RescueChannel.vue
一共返回的页面有Sharedrescue 局部的方法部分方法功能 和完整的RescueChannel超级页面一点代码不少的的那种 还有就是PublicRelease 组件 和PublishContentPage发布页面都是一点代码不能少

思考

Thought for 7m 32s

回答

下面按你说的来做(Sharedrescue 只回“局部新增/修改代码”RescueChannel / PublicRelease / PublishContentPage 三个文件都给“完整代码”,一行不省),并把 PublicRelease 只露出一半被遮挡的问题彻底修掉(核心是高度链 + 取消嵌套 scroll-view 的 82vh 固定高度,改成“跟随父级滚动/自适应高度”,避免在抽屉/分栏容器里被裁切)。


1)Sharedrescue.vue(只返回你要的“局部新增/修改”)

目的:点击 救援同频 → 进入新页面态 rescueChannel → 渲染 RescueChannel.vue
右侧 发布按钮 → 跳转到 pages/biometrics/Publicrescue/PublishContentPage(type=RescueChannel)

✅(A)template 里新增一个分支渲染 RescueChannel

把下面这段插入到 drawer-body 里(跟其它 v-else-if="currentView === ..." 同级):

vue
<!-- ===================== X) 救援同频(新:RescueChannel 两栏) ===================== --> <view v-else-if="currentView === 'rescueChannel'" class="rc-page"> <RescueChannel :type="'RescueChannel'" @publish="goRescueChannelPublish" /> </view>

并在 <style scoped> 里补一个(确保高度链不断,避免被 drawer-body overflow 截断):

scss
.rc-page{ height: 100%; min-height: 0; display: flex; }

✅(B)script 里新增 import + enterFromMenu 跳转 + 发布跳转方法

把下面代码加到 <script setup> 对应位置即可:

js
// ✅ 新增:救援同频(两栏:评论/榜单 + 发布按钮) import RescueChannel from '@/components/HomepageComponents/CommonModule/CommentModule/RescueChannel/RescueChannel.vue' // ✅ 新增:救援同频发布跳转 function goRescueChannelPublish(payload) { // payload: { type:'RescueChannel' }(RescueChannel 里 emit 出来的) const t = payload?.type || 'RescueChannel' closePopup() uni.navigateTo({ url: `/pages/biometrics/Publicrescue/PublishContentPage?type=${encodeURIComponent(String(t))}` }) }

然后把你原来的 enterFromMenu(item)增加这条分支(不要再走 placeholder):

js
function enterFromMenu(item) { subscript.value = item.key if (item.key === 'warning') return pushView('warning', '预警') if (item.key === 'alarm') return pushView('alarm', '报警') // ✅ 新增:救援同频 -> RescueChannel 两栏页面 if (item.key === 'sync') return pushView('rescueChannel', '救援同频') if (item.key === 'mall') { uni.navigateTo({ url: '/pages/biometrics/RescueMall/RescueMall' }) pushView('mall', '同频救援') } return pushView('placeholder', item.title) }

✅ Sharedrescue 到此结束(按你要求只回“局部修改”,没把整份文件重复贴出来)


2)RescueChannel.vue(完整文件:两栏【评论/榜单】+ 右侧发布按钮)

路径:@/components/HomepageComponents/CommonModule/CommentModule/RescueChannel/RescueChannel.vue
左侧:PublicRelease(评论/帖子流)
右侧:榜单列表(带 tabs)+ 最右侧发布按钮(点了 emit 给 Sharedrescue 跳转)
并做了高度链/分栏 min-height:0 处理,保证不会“只露一半”

vue
<template> <view class="rc-root"> <!-- 左:救援同频(评论/帖子流) --> <view class="rc-left"> <PublicRelease ref="releaseRef" :type="type" /> </view> <!-- 右:榜单 + 发布按钮 --> <view class="rc-right"> <view class="rc-right-header"> <view class="rc-rank-tabs"> <view v-for="t in rankTabs" :key="t.key" class="rc-rank-tab" :class="{ active: rankTab === t.key }" @tap="setRankTab(t.key)" > <text class="rc-rank-tab-text">{{ t.name }}</text> </view> </view> <!-- ✅ 最右侧按钮:发布(你要的) --> <view class="rc-publish-btn" @tap="onPublish"> <text class="rc-publish-text">发布</text> </view> </view> <scroll-view scroll-y class="rc-rank-scroll" @scrolltolower="loadMoreRank" refresher-enabled :refresher-triggered="rankRefreshing" @refresherrefresh="refreshRank"> <view class="rc-rank-list"> <view v-for="(row, idx) in rankList" :key="row.id || idx" class="rc-rank-item"> <view class="rc-rank-left"> <view class="rc-rank-index">{{ idx + 1 }}</view> <view class="rc-rank-title">{{ row.title || row.name || '内容标题' }}</view> </view> <view class="rc-rank-right"> <text class="rc-rank-score">{{ row.score ?? row.hot ?? row.value ?? '8.6' }}</text> <text class="rc-rank-arrow">›</text> </view> </view> <view v-if="!rankLoading && rankList.length === 0" class="rc-rank-empty"> 暂无榜单数据 </view> <view class="rc-rank-foot"> <text v-if="rankLoading">加载中...</text> <text v-else-if="!rankHasMore">没有更多了</text> </view> </view> <view style="height: 20rpx;"></view> </scroll-view> </view> </view> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import ajax from '@/api/ajax.js' import PublicRelease from '@/components/HomepageComponents/CommonModule/CommentModule/PublicRelease.vue' const props = defineProps({ type: { type: [String, Number], default: 'RescueChannel' } }) const emit = defineEmits(['publish']) /** 左侧 PublicRelease 引用:发布成功回来可刷新 */ const releaseRef = ref(null) /** 发布事件:交给 Sharedrescue 去 navigateTo */ function onPublish() { emit('publish', { type: props.type }) } /** ✅ 发布完成事件(PublishContentPage 会 uni.$emit) */ const EVT_PUBLISHED = 'RESCUE_CHANNEL_PUBLISHED' function onPublished(payload) { const t = payload?.type if (String(t || '') && String(t) !== String(props.type)) return releaseRef.value?.refresh?.() // 右侧榜单也可刷新(可选) refreshRank() } onMounted(() => { uni.$on(EVT_PUBLISHED, onPublished) fetchRank(true) }) onBeforeUnmount(() => { uni.$off(EVT_PUBLISHED, onPublished) }) /** ===================== 右侧榜单(可接接口;接口名你按后端实际改) ===================== */ const rankTabs = ref([ { key: 'event', name: '2025年年度救援事件榜' }, { key: 'person', name: '2025年年度人物榜' } ]) const rankTab = ref('event') const rankList = ref([]) const rankRowCount = 20 const rankRowStartIdx = ref(0) const rankLoading = ref(false) const rankHasMore = ref(true) const rankRefreshing = ref(false) /** 防御式成功判断(跟你项目里常见结构兼容) */ function isSuccess(res) { if (!res) return false if (res?.statusCode === 200) return true if (res?.data?.statusCode === 200) return true if (typeof res?.errMsg === 'string' && res.errMsg.includes('ok')) return true if (String(res?.result || '').toLowerCase() === 'success') return true if (String(res?.data?.result || '').toLowerCase() === 'success') return true const code = res?.code ?? res?.status ?? res?.resultCode ?? res?.data?.code ?? res?.data?.status if (code === 0 || code === 200) return true if (res?.success === true || res?.data?.success === true) return true return false } function respData(res) { return res?.data?.data ?? res?.data ?? res?.result ?? res?.obj ?? res } function pickRankArray(d) { if (Array.isArray(d?.rankArray)) return d.rankArray if (Array.isArray(d?.list)) return d.list if (Array.isArray(d?.data?.list)) return d.data.list if (Array.isArray(d)) return d return [] } /** * ✅ 榜单接口: * 你按后端实际接口名替换即可(这里给一个默认占位,不会导致页面挂死) */ async function fetchRank(reset = false) { if (rankLoading.value) return if (!rankHasMore.value && !reset) return rankLoading.value = true try { if (reset) { rankRowStartIdx.value = 0 rankHasMore.value = true } // ⚠️ 接口名按你后端改: // 例如:rescueCircleRankList / rescueCircleHotRank / rescueCircleBoardList ... const res = await ajax.post({ url: 'rescueCircleRankList', method: 'POST', data: { boardType: rankTab.value, // event / person rowStartIdx: rankRowStartIdx.value, rowCount: rankRowCount, type: props.type // RescueChannel } }) // 如果后端没这个接口,会走 catch;不会卡死 const arr = pickRankArray(respData(res)) const mapped = (arr || []).map(x => ({ id: x?.id, title: x?.title || x?.name || x?.contentTitle, score: x?.score ?? x?.hot ?? x?.value })) if (reset) rankList.value = mapped else rankList.value = [...rankList.value, ...mapped] if (!arr || arr.length < rankRowCount) rankHasMore.value = false rankRowStartIdx.value += rankRowCount } catch (e) { // ✅ 接口缺失时给“静态占位”,保证 UI 跟你截图一致 if (reset) { rankList.value = Array.from({ length: 10 }).map((_, i) => ({ id: String(i + 1), title: '内容标题', score: '8.6' })) rankHasMore.value = false } } finally { rankLoading.value = false rankRefreshing.value = false } } function setRankTab(k) { if (rankTab.value === k) return rankTab.value = k refreshRank() } function loadMoreRank() { fetchRank(false) } function refreshRank() { rankRefreshing.value = true fetchRank(true) } </script> <style lang="scss" scoped> .rc-root { height: 100%; min-height: 0; display: flex; flex-direction: row; background: #fff; } /* 左侧评论流:关键是 min-height:0 + flex,保证内部 scroll-view 能撑开 */ .rc-left { flex: 1; min-width: 0; min-height: 0; display: flex; border-right: 1rpx solid #f1f1f1; } /* 右侧榜单 */ .rc-right { width: 260rpx; min-width: 240rpx; max-width: 320rpx; min-height: 0; display: flex; flex-direction: column; background: #fff; } .rc-right-header { flex-shrink: 0; padding: 14rpx 12rpx 10rpx 12rpx; border-bottom: 1rpx solid #f1f1f1; display: flex; flex-direction: column; gap: 10rpx; } .rc-rank-tabs { display: flex; flex-wrap: wrap; gap: 10rpx; } .rc-rank-tab { padding: 8rpx 12rpx; border-radius: 999rpx; background: #f4f4f4; } .rc-rank-tab.active { background: #d89833; } .rc-rank-tab-text { font-size: 22rpx; color: #333; } .rc-rank-tab.active .rc-rank-tab-text { color: #fff; font-weight: 800; } /* 发布按钮 */ .rc-publish-btn { height: 66rpx; border-radius: 12rpx; background: #d89833; display: flex; align-items: center; justify-content: center; } .rc-publish-text { color: #fff; font-size: 26rpx; font-weight: 900; } /* 榜单滚动区:关键 min-height:0 + flex:1 */ .rc-rank-scroll { flex: 1; min-height: 0; } .rc-rank-list { padding: 12rpx; } .rc-rank-item { height: 72rpx; border-radius: 12rpx; background: #f2f2f2; margin-bottom: 12rpx; padding: 0 12rpx; display: flex; align-items: center; justify-content: space-between; } .rc-rank-left{ display: flex; align-items: center; gap: 10rpx; min-width: 0; } .rc-rank-index{ width: 34rpx; height: 34rpx; border-radius: 10rpx; background: #d89833; color: #fff; font-size: 20rpx; font-weight: 900; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .rc-rank-title{ font-size: 22rpx; color: #333; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rc-rank-right{ display: flex; align-items: center; gap: 8rpx; flex-shrink: 0; } .rc-rank-score{ font-size: 22rpx; font-weight: 800; color: #d89833; } .rc-rank-arrow{ color: #999; font-size: 26rpx; font-weight: 900; } .rc-rank-empty{ text-align: center; color: #999; font-size: 22rpx; padding: 20rpx 0; } .rc-rank-foot{ text-align: center; color: #999; font-size: 22rpx; padding: 12rpx 0; } </style>

3)PublicRelease.vue(完整文件:修复“只露一半被遮挡”+ type=RescueChannel)

关键修复点(都已写进代码):
1)高度链保证:root/scroll 都用 flex:1 + min-height:0,适配抽屉/分栏
2)删除你原来评论区 scroll-view height:82vh 的嵌套滚动:这个在抽屉/分栏里非常容易被裁切,只露一半
3)type 支持 String/Number,请求不再强转 Number(兼容 “RescueChannel”)

vue
<template> <view class="wc-root"> <!-- 顶部二级 Tab(推荐/我的) --> <view class="wc-tabs"> <view v-for="(t, i) in tabs" :key="t.key" class="wc-tab" :class="{ active: i === tabIndex }" @tap="switchTab(i)"> <text class="wc-tab-text">{{ t.name }}</text> </view> </view> <scroll-view scroll-y class="wc-scroll" @scrolltolower="loadMore" refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="refresh"> <view v-if="feedList.length" class="wc-list"> <view v-for="(row, idx) in feedList" :key="row.id || idx" class="wc-card"> <!-- 头部:头像/昵称/时间/关注 --> <view class="wc-head"> <image class="wc-avatar" :src="row.avatarFull" mode="aspectFill" /> <view class="wc-head-mid"> <view class="wc-name-row"> <text class="wc-name">{{ row.nickname || '用户' }}</text> </view> <text class="wc-time">{{ formatTime(row.createTime || 0) }}</text> </view> <view class="wc-follow" @tap="onFollow(idx)"> <text class="wc-follow-text">{{ row.follow ? '已关注' : '+关注' }}</text> </view> </view> <!-- 正文 --> <view class="wc-content"> <text class="wc-content-text">{{ row.content || '' }}</text> </view> <!-- 图集 --> <up-album v-if="row.imageList.length" :urls="row.imageList" multiple /> <!-- 视频 --> <view v-for="(vurl, vIdx) in row.videos" :key="'video-'+(row.id || idx)+'-'+vIdx" class="wc-video"> <video :id="'video-'+(row.id || idx)+'-'+vIdx" :show-fullscreen-btn="false" :show-play-btn="false" :show-center-play-btn="true" :src="vurl" style="height: 100%;width: 100%;" /> </view> <!-- 展开评论区 --> <view class="wc-expand" @tap="toggleComments(idx)"> <text class="wc-expand-text"> {{ row.showComments ? '收起评论区' : `展开评论区${fmtCount(row.commentCount)}` }} </text> </view> <!-- 互动栏 --> <view class="wc-actions"> <!-- ✅ 发评论:即使未展开,也要能弹(已修复) --> <view class="wc-input" @tap="openFirstComment(row)"> <image class="wc-input-avatar" :src="myInfo.user_avatar || row.avatarFull" mode="aspectFill" /> <view class="wc-input-box"> <text class="wc-input-placeholder">发评论</text> </view> </view> <view class="wc-icons"> <view class="wc-icon" @tap.stop="toggleRowComments(idx)"> <uni-icons type="chatboxes" size="22" color="#383838" /> <text class="wc-icon-text">{{ row.commentCount || 0 }}</text> </view> <view class="wc-icon" @tap.stop="onLike(idx)"> <uni-icons :type="row.like ? 'hand-up-filled' : 'hand-up'" :size="22" :color="row.like ? '#D89833' : '#383838'" /> <text class="wc-icon-text">{{ row.likeCount || 0 }}</text> </view> <view class="wc-icon" @tap.stop="onCollect(idx)"> <uni-icons :type="row.collect ? 'star-filled' : 'star'" :size="22" :color="row.collect ? '#D89833' : '#383838'" /> <text class="wc-icon-text">{{ row.collectCount || 0 }}</text> </view> <view class="wc-icon" @tap.stop="openForward(row)"> <uni-icons type="redo" size="22" color="#383838" /> <text class="wc-icon-text">{{ row.forwardCount || 0 }}</text> </view> </view> </view> <!-- ✅ 评论盒子(关键修复:不再嵌套 82vh scroll-view,避免抽屉/分栏被裁切) --> <view v-if="row.showComments" class="wc-comment-box"> <Warningcomment :ref="(el)=> setCmtRef(row.id, el)" v-model:myInfo="myInfo" v-model:userInfo="userInfo" v-model:tableData="row.tableData" v-model:tableTotal="row.tableTotal" :deleteMode="'all'" :allowImage="true" :allowVideo="true" :hideImages="false" :hideVideos="false" :enableFirstDelete="false" @likeFun="(payload, cb)=> commentLikeProxy(row, payload, cb)" @replyFun="(payload, cb)=> commentReplyProxy(row, payload, cb)" @deleteFun="(payload, cb)=> commentDeleteProxy(row, payload, cb)" /> </view> </view> <view class="wc-foot"> <text v-if="loadingMore">加载中...</text> <text v-else-if="!hasMore">没有更多了</text> </view> </view> <!-- 空态 --> <view v-else class="wc-empty"> <text style="color:#999;">正在加载中...</text> </view> <!-- 给底部安全区留白(防止抽屉底部/系统手势遮挡) --> <view style="height: calc(24rpx + env(safe-area-inset-bottom));"></view> </scroll-view> <!-- 转发弹窗 --> <up-popup :show="forwardShow" :round="14" mode="center" :safeAreaInsetBottom="false" :closeable="true" @close="forwardShow=false"> <view class="fw-wrap"> <text class="fw-title">分享到</text> <view class="fw-grid"> <view v-for="app in appList" :key="app.pname" class="fw-item" @tap="launchApp(app)"> <view class="fw-icon">{{ app.short }}</view> <text class="fw-name">{{ app.name }}</text> </view> </view> <view class="fw-actions"> <view class="fw-btn" @tap="doForwardAndClose"> <text class="fw-btn-text">确认转发</text> </view> </view> </view> </up-popup> </view> </template> <script setup> import { ref, computed, inject, onMounted, watch, nextTick, defineExpose } from 'vue' import ajax from '@/api/ajax.js' import Warningcomment from './Warningcomment/WarningComment.vue' const props = defineProps({ type: { type: [Number, String], default: 'RescueChannel' } }) function isSuccess(res) { if (!res) return false if (res?.statusCode === 200) return true if (res?.data?.statusCode === 200) return true if (typeof res?.errMsg === 'string' && res.errMsg.includes('ok')) return true if (res?.result === 'success') return true if (res?.data?.result === 'success') return true const code = res?.code ?? res?.status ?? res?.resultCode ?? res?.data?.code ?? res?.data?.status if (code === 0 || code === 200) return true if (res?.success === true || res?.data?.success === true) return true return false } function respData(res) { return res?.data?.data ?? res?.data ?? res?.result ?? res?.obj ?? res } /** 注入 & 当前用户 */ const webProjectUrl = ref(inject('webProjectUrl') || '') const serverUrl = inject('serverUrl') const consumerId = ref(String( uni.getStorageSync('consumerId') || uni.getStorageSync('userId') || uni.getStorageSync('uid') || '' )) const myInfo = ref({ user_id: consumerId.value, user_name: uni.getStorageSync('nickname'), user_avatar: webProjectUrl.value + (uni.getStorageSync('avatarUrl') || '') }) const userInfo = ref({ ...myInfo.value }) watch(() => webProjectUrl.value, () => { myInfo.value.user_avatar = webProjectUrl.value + (uni.getStorageSync('avatarUrl') || '') userInfo.value.user_avatar = myInfo.value.user_avatar }) /** Tabs(推荐/我的) */ const tabs = ref([{ key: 'recommend', name: '推荐' }, { key: 'mine', name: '我的' }]) const tabIndex = ref(0) const currentTabKey = computed(() => tabs.value[tabIndex.value]?.key || 'recommend') /** 列表状态 */ const feedList = ref([]) const rowCount = 10 const rowStartIdx = ref(0) const loadingMore = ref(false) const hasMore = ref(true) const refreshing = ref(false) /** 本地点赞持久化 */ const LS_FIRST = 'liked_rescue_cmt_first' const LS_SECOND = 'liked_rescue_cmt_second' function readLiked(type) { const k = type === 'first' ? LS_FIRST : LS_SECOND try { return new Set(uni.getStorageSync(k) || []) } catch { return new Set() } } function writeLiked(type, set) { const k = type === 'first' ? LS_FIRST : LS_SECOND uni.setStorageSync(k, Array.from(set || [])) } const likedFirst = readLiked('first') const likedSecond = readLiked('second') /** 评论 ref */ const cmtRefs = ref({}) function setCmtRef(id, el) { if (id && el) cmtRefs.value[id] = el } /** ✅ 关键修复:未展开时先展开,再 nextTick 弹窗 */ async function openFirstComment(row) { if (!row) return if (!row.showComments) { row.showComments = true await nextTick() } const inst = cmtRefs.value[row.id] if (inst && inst.newCommentFun) inst.newCommentFun() } /** 时间格式化 */ function formatTime(val) { const d = new Date(Number(val) || 0) const pad = n => (n < 10 ? '0' + n : n) return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } function fmtCount(n) { const x = Number(n || 0) if (x <= 0) return '' if (x > 999) return '999+' return String(x) } function pickRescueArray(d) { if (Array.isArray(d?.rescueCircleArray)) return d.rescueCircleArray if (Array.isArray(d?.data?.rescueCircleArray)) return d.data.rescueCircleArray if (Array.isArray(d?.list)) return d.list if (Array.isArray(d)) return d return [] } function fullFileUrl(fileName, playURL) { if (playURL) return playURL if (!fileName) return '' if (String(fileName).startsWith('http')) return fileName return (webProjectUrl.value || '') + fileName } /** ✅ 拉列表:finally 防卡死 */ async function fetchList(reset = false) { if (loadingMore.value) return if (!hasMore.value && !reset) return loadingMore.value = true try { if (reset) { rowStartIdx.value = 0 hasMore.value = true } const tabKey = currentTabKey.value const cid = tabKey === 'mine' ? consumerId.value : '' const option = { consumerId: cid, rowStartIdx: rowStartIdx.value, rowCount, // ✅ 不再强转 Number:支持 'RescueChannel' type: props.type } const res = await ajax.post({ url: 'rescueCircleIndexList', method: 'POST', data: option }) const arr = pickRescueArray(respData(res)) const mapped = (arr || []).map(mapFeedRow) if (reset) feedList.value = mapped else feedList.value = [...feedList.value, ...mapped] if (!arr || arr.length < rowCount) hasMore.value = false rowStartIdx.value += rowCount } catch (e) { console.log(e, 'fetchList error') } finally { loadingMore.value = false refreshing.value = false } } function mapFeedRow(p) { const files = p.rescueCircleFileArray || [] const imageList = files .filter(f => f.fileName && !/\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean) const videos = files .filter(f => f.fileName && /\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean) const { tableData, tableTotal } = mapCommentsFromRescue(p) return { ...p, avatarFull: (p.avatarUrl?.startsWith('http') ? p.avatarUrl : (webProjectUrl.value || '') + (p.avatarUrl || '')), imageList, videos, tableData, tableTotal, showComments: false, follow: !!p.follow } } function mapCommentsFromRescue(row) { const list = []; (row.rescueCircleLikeArray || []).forEach(first => { if (!first.comment && !(first.rescueCircleLikeCommentArray || []).length) return; const firstFiles = first.rescueCircleLikeFileArray || []; const firstImgs = firstFiles .filter(f => f.fileName && !/\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean); const firstVids = firstFiles .filter(f => f.fileName && /\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean); const firstIsLike = (typeof first.like !== 'undefined') ? !!first.like : likedFirst.has(first.id); list.push({ id: first.id, parent_id: null, reply_id: null, reply_name: null, reply_user_id: null, user_id: first.consumerId, user_name: first.nickname, user_avatar: first.avatarUrl?.startsWith('http') ? first.avatarUrl : (webProjectUrl.value || '') + (first.avatarUrl || ''), user_content: first.comment, is_like: firstIsLike, like_count: Number(first.likeCount || 0), create_time: first.createTime || 0, imageList: firstImgs, videos: firstVids, owner: String(first.consumerId) === String(consumerId.value) }); (first.rescueCircleLikeCommentArray || []).forEach(sec => { const secFiles = sec.rescueCircleLikeCommentFileArray || []; const secImgs = secFiles .filter(f => f.fileName && !/\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean); const secVids = secFiles .filter(f => f.fileName && /\.mp4$/i.test(f.fileName)) .map(f => fullFileUrl(f.fileName, f.playURL)) .filter(Boolean); const secondIsLike = (typeof sec.like !== 'undefined') ? !!sec.like : likedSecond.has(sec.id); list.push({ id: sec.id, parent_id: first.id, reply_id: sec.replyConsumerId ? first.id : null, reply_name: sec.replyConsumerNickname || null, reply_user_id: sec.replyConsumerId || null, user_id: sec.consumerId, user_name: sec.nickname, user_avatar: sec.avatarUrl?.startsWith('http') ? sec.avatarUrl : (webProjectUrl.value || '') + (sec.avatarUrl || ''), user_content: sec.comment, is_like: secondIsLike, like_count: Number(sec.likeCount || 0), create_time: sec.createTime || 0, imageList: secImgs, videos: secVids, owner: String(sec.consumerId) === String(consumerId.value) }); }); }); return { tableData: list, tableTotal: Number(row.commentCount || list.length || 0) }; } /** 展开 */ function toggleComments(idx) { const row = feedList.value[idx] row.showComments = !row.showComments } function toggleRowComments(idx) { toggleComments(idx) } /** 关注(你旧接口接这里) */ function onFollow(idx) { const row = feedList.value[idx] row.follow = !row.follow } /** 帖子点赞/收藏/转发 */ async function rescueOperate(row, operateType) { const before = { like: row.like, collect: row.collect, forward: row.forward, likeCount: row.likeCount, collectCount: row.collectCount, forwardCount: row.forwardCount } if (operateType === 'like') { row.like = !row.like row.likeCount = row.like ? (Number(row.likeCount || 0) + 1) : Math.max(0, Number(row.likeCount || 0) - 1) } if (operateType === 'collect') { row.collect = !row.collect row.collectCount = row.collect ? (Number(row.collectCount || 0) + 1) : Math.max(0, Number(row.collectCount || 0) - 1) } if (operateType === 'forward') { row.forward = !row.forward row.forwardCount = row.forward ? (Number(row.forwardCount || 0) + 1) : Math.max(0, Number(row.forwardCount || 0) - 1) } try { const option = { consumerId: consumerId.value, operateType, rescueCircleId: row.id } const res = await ajax.post({ url: 'rescueCircleLike', method: 'POST', data: option }) if (!isSuccess(res)) throw new Error('operate fail') } catch (e) { Object.assign(row, before) uni.showToast({ title: '操作失败', icon: 'none' }) } } function onLike(idx) { rescueOperate(feedList.value[idx], 'like') } function onCollect(idx) { rescueOperate(feedList.value[idx], 'collect') } /** 评论点赞代理 */ function commentLikeProxy(_row, { params }, rollback) { const updateLocal = (type, id, isLike) => { const set = type === 'first' ? likedFirst : likedSecond if (isLike) set.add(id); else set.delete(id) writeLiked(type, set) } const option = { consumerId: consumerId.value, rescueCircleLikeId: params.id } if (!params.parent_id) { ajax.post({ url: 'rescueCircleLikeLike', method: 'POST', data: option }).then(res => { if (!isSuccess(res)) return rollback && rollback() updateLocal('first', params.id, !!params.is_like) }).catch(() => rollback && rollback()) } else { ajax.post({ url: 'likeRescueCircleLikeComment', method: 'POST', data: { id: params.id, consumerId: consumerId.value } }).then(res => { if (!isSuccess(res)) return rollback && rollback() updateLocal('second', params.id, !!params.is_like) }).catch(() => rollback && rollback()) } } /** 评论回复/上传 */ const UPLOAD_FIRST_FIELD = 'circleLikeFile' const UPLOAD_SECOND_FIELD = 'rescueCircleLikeCommentFile' function uniUpload(url, fieldName, formData, filePath) { return new Promise((resolve, reject) => { uni.uploadFile({ url: serverUrl + url, filePath, name: fieldName, header: { "Content-Type": "multipart/form-data" }, formData, success: (res) => { try { resolve(typeof res.data === 'string' ? JSON.parse(res.data) : res.data) } catch { resolve({}) } }, fail: reject }) }) } function commentReplyProxy(row, { params, files }, done) { const cid = consumerId.value if (!params.parent_id) { // 一级 const firstFile = files && files[0] const formData = { consumerId: cid, rescueCircleId: row.id, comment: params.user_content } const doUploadMore = (payloadId) => { const remains = (files || []).slice(firstFile ? 1 : 0) if (!remains.length) return done && done({ id: payloadId, files }) let chain = Promise.resolve() remains.forEach(f => { chain = chain.then(() => uniUpload('rescueCircleLike', UPLOAD_FIRST_FIELD, { consumerId: cid, id: payloadId }, f.url)) }) chain.then(() => done && done({ id: payloadId, files })) .catch(() => done && done({ id: payloadId, files })) } if (firstFile) { uniUpload('rescueCircleLike', UPLOAD_FIRST_FIELD, formData, firstFile.url) .then((res) => { const realId = res?.data?.id || res?.id || res?.rescueCircleLikeId doUploadMore(realId) }) .catch(() => { ajax.post({ url: 'rescueCircleLike', method: 'POST', data: formData }).then(r => done && done({ id: r?.data?.id || r?.id, files })) }) } else { ajax.post({ url: 'rescueCircleLike', method: 'POST', data: formData }) .then(r => done && done({ id: r?.data?.id || r?.id, files })) } } else { // 二级 const firstFile = files && files[0] const formData = { id: '', consumerId: cid, rescueCircleLikeId: params.parent_id, comment: params.user_content, replyConsumerId: params.reply_user_id ? params.reply_user_id : '' } const doUploadMoreSecond = (payloadId) => { const remains = (files || []).slice(firstFile ? 1 : 0) if (!remains.length) return done && done({ id: payloadId, files }) let chain = Promise.resolve() remains.forEach(f => { chain = chain.then(() => uniUpload('rescueCircleLikeComment', UPLOAD_SECOND_FIELD, { id: payloadId, consumerId: cid }, f.url)) }) chain.then(() => done && done({ id: payloadId, files })) .catch(() => done && done({ id: payloadId, files })) } if (firstFile) { uniUpload('rescueCircleLikeComment', UPLOAD_SECOND_FIELD, formData, firstFile.url) .then((res) => { const realId = res?.data?.id || res?.id doUploadMoreSecond(realId) }) .catch(() => { ajax.post({ url: 'rescueCircleLikeComment', method: 'POST', data: formData }).then(r => done && done({ id: r?.data?.id || r?.id, files })) }) } else { ajax.post({ url: 'rescueCircleLikeComment', method: 'POST', data: formData }) .then(r => done && done({ id: r?.data?.id || r?.id, files })) } } } /** 二级删除 */ function commentDeleteProxy(_row, { params }, done) { const id = params?.[0] ajax.post({ url: 'deleteRescueCircleLikeComment', method: 'POST', data: { id } }).then(res => { if (!isSuccess(res)) return uni.showToast({ title: '删除失败', icon: 'none' }) done && done() }).catch(() => uni.showToast({ title: '删除失败', icon: 'none' })) } /** 切换/加载更多/刷新 */ function switchTab(i) { tabIndex.value = i; refresh() } function loadMore() { fetchList(false) } function refresh() { refreshing.value = true; fetchList(true) } /** 转发弹窗 */ const forwardShow = ref(false) const forwardRow = ref(null) const appList = ref([ { name: '支付宝', short: '支', pname: 'com.eg.android.AlipayGphone', scheme: 'alipay://' }, { name: '淘宝', short: '淘', pname: 'com.taobao.taobao', scheme: 'taobao://' }, { name: 'QQ', short: 'Q', pname: 'com.tencent.mobileqq', scheme: 'mqq://' }, { name: '微信', short: '微', pname: 'com.tencent.mm', scheme: 'weixin://' }, { name: '京东', short: '东', pname: 'com.jingdong.app.mall', scheme: 'openApp.jdMobile://' }, { name: '微博', short: '博', pname: 'com.sina.weibo', scheme: 'sinaweibo://' }, { name: '头条', short: '头', pname: 'com.ss.android.article.news', scheme: 'snssdk141://' } ]) function openForward(row) { forwardRow.value = row forwardShow.value = true } function launchApp(app) { try { // #ifdef APP-PLUS plus.runtime.openURL(app.scheme) // #endif } catch (e) {} } async function doForwardAndClose() { if (!forwardRow.value) return await rescueOperate(forwardRow.value, 'forward') forwardShow.value = false forwardRow.value = null } onMounted(() => { fetchList(true) }) /** ✅ 暴露给 RescueChannel:发布回来强制刷新 */ defineExpose({ refresh }) </script> <style lang="scss" scoped> .wc-root { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; } /* tabs 固定占位 */ .wc-tabs { flex-shrink: 0; display: flex; align-items: center; gap: 28rpx; padding: 12rpx 8rpx 18rpx 8rpx; } .wc-tab { font-size: 26rpx; color: #999; position: relative; padding: 6rpx 6rpx; } .wc-tab.active { color: #D89833; font-weight: 800; } .wc-tab.active::after { content: ''; position: absolute; left: 8rpx; right: 8rpx; bottom: -10rpx; height: 4rpx; border-radius: 2rpx; background: #D89833; } .wc-tab-text { font-size: 26rpx; } /* ✅ scroll 关键:flex:1 + min-height:0,才能在抽屉/分栏里不被裁切 */ .wc-scroll { flex: 1; min-height: 0; } .wc-list { padding: 0 0 22rpx 0; } .wc-card { background: #fff; border-radius: 14rpx; padding: 16rpx; margin-bottom: 18rpx; box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.06); } .wc-head { display: flex; align-items: center; justify-content: space-between; } .wc-avatar { width: 74rpx; height: 74rpx; border-radius: 50%; } .wc-head-mid { flex: 1; margin-left: 14rpx; } .wc-name { font-size: 28rpx; font-weight: 800; color: #333; } .wc-time { font-size: 22rpx; color: #999; margin-top: 6rpx; } .wc-follow { width: 120rpx; height: 50rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; background: linear-gradient(to bottom, #D8983F, #E3D89D); } .wc-follow-text { font-size: 24rpx; color: #fff; font-weight: 800; } .wc-content { margin-top: 14rpx; font-size: 26rpx; color: #333; line-height: 40rpx; } .wc-expand { margin-top: 12rpx; padding: 10rpx 0; border-top: 1rpx solid #f1f1f1; border-bottom: 1rpx solid #f1f1f1; } .wc-expand-text { font-size: 24rpx; color: #999; } .wc-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 12rpx; } .wc-input { width: 56%; display: flex; align-items: center; gap: 10rpx; } .wc-input-avatar { width: 56rpx; height: 56rpx; border-radius: 50%; } .wc-input-box { flex: 1; height: 60rpx; border-radius: 999rpx; background: #EDEDED; display: flex; align-items: center; padding: 0 18rpx; } .wc-input-placeholder { font-size: 24rpx; color: #999; } .wc-icons { width: 44%; display: flex; align-items: center; justify-content: space-between; } .wc-icon { display: flex; flex-direction: column; align-items: center; gap: 4rpx; min-width: 68rpx; } .wc-icon-text { font-size: 22rpx; color: #333; } /* ✅ 评论容器:不再固定高度,不嵌套 vh scroll,避免被抽屉裁切 */ .wc-comment-box { margin-top: 12rpx; background: #fafafa; border-radius: 12rpx; overflow: hidden; padding-bottom: 8rpx; } .wc-video{ height: 200rpx; width: 150rpx; border-radius: 12rpx; margin-top: 12rpx; overflow: hidden; } .wc-foot { text-align: center; color: #999; padding: 16rpx 0; font-size: 24rpx; } .wc-empty { padding: 60rpx 0; display: flex; align-items: center; justify-content: center; } /* 转发弹窗 */ .fw-wrap { width: 620rpx; padding: 20rpx 18rpx 22rpx 18rpx; } .fw-title { display: block; text-align: center; font-size: 28rpx; font-weight: 900; color: #333; margin-bottom: 18rpx; } .fw-grid { display: flex; flex-wrap: wrap; gap: 16rpx; justify-content: space-between; } .fw-item { width: calc((100% - 16rpx*3) / 4); height: 140rpx; border-radius: 14rpx; background: #f2f2f2; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10rpx; } .fw-icon { width: 70rpx; height: 70rpx; border-radius: 16rpx; background: #D89833; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: 30rpx; } .fw-name { font-size: 22rpx; color: #333; font-weight: 700; } .fw-actions { margin-top: 18rpx; } .fw-btn { height: 82rpx; border-radius: 12rpx; background: #D89833; display: flex; align-items: center; justify-content: center; } .fw-btn-text { color: #fff; font-size: 28rpx; font-weight: 900; } </style>

4)PublishContentPage.vue(完整文件:现在只做“发布”,type=RescueChannel 通用)

路径:/pages/biometrics/Publicrescue/PublishContentPage.vue(你说的跳转目标)
关键点:

  • 不再展示评论,只负责发布(通用)
  • type 支持 RescueChannel(字符串)
  • 发布成功后:uni.$emit('RESCUE_CHANNEL_PUBLISHED', {type}),RescueChannel 会自动刷新左侧列表
vue
<template> <view :class="['app-container', theme]"> <view class="pc-root"> <!-- 顶部 Header(fixed) --> <view class="pc-header" :style="{ paddingTop: statusBarHeight + 'px' }"> <view class="pc-header-inner" :style="{ height: navBarHeight + 'px' }"> <view class="pc-left" @tap="navigateBack"> <uni-icons type="left" color="#000000" size="26" /> </view> <view class="pc-center"> <text class="pc-title">发布</text> </view> <view class="pc-right" /> </view> </view> <!-- 主体 --> <view class="pc-main" :style="{ paddingTop: headerTotalPx + 'px' }"> <scroll-view scroll-y class="pc-scroll"> <view class="page-body"> <view class="describe"> <view class="texter"> <textarea v-model="textAreaContent" placeholder="添加作品描述..." maxlength="500" @input="onTextInput" style="width: 100%;" /> <view class="counter"> <text class="counter-text">{{ textAreaContentLength }}/500字</text> </view> </view> <view class="actions-row"> <view class="chip" @tap="toggleTopics">#话题</view> <view class="chip" @tap="toggleFriends">@朋友</view> <view class="chip ghost" @tap="selectsfile()"> 上传类型:{{ TemporaryValue === 'video' ? '视频' : '图片' }} </view> </view> <TopicList v-if="showTopics" :topics="topics" @select-topic="handleSelectTopic" /> <FriendList v-if="showFriends" :friends="friends" @select-friend="handleSelectFriend" /> <!-- ✅ 视频预览:选了视频就展示第一个 --> <view class="video-wrap" v-if="videoSrc"> <video :src="videoSrc" style="height: 100%; width: 100%;" :controls="true" :show-fullscreen-btn="false" loop show-center-play-btn="false" object-fit="fill" /> </view> </view> <view class="upload-card"> <view class="upload-title"> 添加视频/图片 <text class="upload-sub">(最多 9 个)</text> </view> <up-upload :fileList="fileList1" @afterRead="afterRead" :accept="TemporaryValue" @delete="deletePic" name="1" multiple :maxCount="9" /> <view class="upload-tip" v-if="fileList1.length"> <text>已选择:{{ fileList1.length }} 个</text> <text v-if="uploadingCount > 0" style="margin-left: 14rpx;">上传中:{{ uploadingCount }} 个</text> <text v-if="failedCount > 0" style="margin-left: 14rpx; color:#ff4d4f;">失败:{{ failedCount }} 个</text> </view> </view> <!-- 底部留白给固定按钮 --> <view style="height: 140rpx;"></view> </view> </scroll-view> <!-- 底部固定发布按钮 --> <view class="pc-bottom"> <view class="publish-btn" :class="{ disabled: publishing }" @tap="handlePublish"> <view class="publish-inner"> <uni-icons v-if="publishing" type="spinner-cycle" size="18" color="#fff" /> <text class="publish-text">{{ publishing ? publishProgressText : '发布' }}</text> </view> </view> </view> </view> </view> </view> </template> <script setup> import { ref, computed, onMounted, inject, watch } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import ajax from '@/api/ajax.js' import TopicList from '@/components/TopicList.vue' import FriendList from '@/components/FriendList.vue' import { Getcontacts, Gettopics } from '@/api/api.js' import { usechanne } from '@/store/useChannel.js' /** theme */ const store = useMainStore() const { theme } = storeToRefs(store) /** 高度 */ const sys = uni.getSystemInfoSync() const statusBarHeight = ref(Number(getStatusBarHeight?.() || sys?.statusBarHeight || 0)) const navBarHeight = ref(Number(getTitleBarHeight?.() || 44)) try { const menu = uni.getMenuButtonBoundingClientRect?.() if (menu?.height) { const gap = Number(menu.top || 0) - Number(statusBarHeight.value || 0) navBarHeight.value = Number(menu.height || 44) + gap * 2 } } catch (e) {} const headerTotalPx = computed(() => Number(statusBarHeight.value || 0) + Number(navBarHeight.value || 0)) /** ✅ type:这次是 RescueChannel(字符串),也支持其它流程复用 */ const publishType = ref('RescueChannel') function normalizeType(t) { const s = String(t ?? '').trim() if (!s) return 'RescueChannel' // 数字就转数字,否则保持字符串(RescueChannel) if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s) return s } /** org guard(你原来就有:保持一致,防止未认证发布) */ const channelStore = usechanne() async function guardOrgOrRoute() { const orgTypeName = uni.getStorageSync('orgTypeName') || '' return await channelStore.ensureOrgCertifiedAndRoute({ orgTypeName, redirect: true }) } function navigateBack() { uni.navigateBack() } onLoad(async (option) => { publishType.value = normalizeType(option?.type || 'RescueChannel') const ok = await guardOrgOrRoute() if (!ok) return }) /** ===== 发布逻辑 ===== */ const textAreaContent = ref('') const textAreaContentLength = computed(() => textAreaContent.value.length) const onTextInput = () => { if (textAreaContent.value.length > 500) { textAreaContent.value = textAreaContent.value.slice(0, 500) uni.showToast({ title: '文字长度不能超过500字', icon: 'none' }) } } const showTopics = ref(false) const showFriends = ref(false) const topics = ref([]) const friends = ref([]) const selectedTopicIds = ref([]) const FriendID = ref('') const toggleTopics = () => { if (!textAreaContent.value.endsWith('#')) textAreaContent.value += '#' showTopics.value = true showFriends.value = false } const toggleFriends = () => { showFriends.value = true showTopics.value = false } const handleSelectTopic = (topic) => { textAreaContent.value += topic?.name ? topic.name : '' showTopics.value = false if (topic?.id != null) { const id = String(topic.id) if (!selectedTopicIds.value.includes(id)) selectedTopicIds.value.push(id) } } const handleSelectFriend = (friend) => { textAreaContent.value += friend?.nickname ? '@' + friend.nickname : friend?.name ? '@' + friend.name : '' showFriends.value = false FriendID.value = friend?.id || '' } /** contacts/topics */ let consumerId = ref(uni.getStorageSync('consumerId') || '') let optionFriend = ref({ consumerId: consumerId.value, keyword: '', rowStartIdx: 0, rowCount: '20' }) async function triggerfunction() { try { let res = await Getcontacts(optionFriend.value) if (res?.result === 'success') friends.value = res.friendArray || [] else friends.value = res?.friendArray || [] } catch { uni.showToast({ title: '好友列表获取失败', icon: 'none' }) } } let paramsTopic = { keyword: '', rowStartIdx: 0, rowCount: '20' } const TopicDetails = async () => { try { let res = await Gettopics(paramsTopic) topics.value = res.tagArray || [] } catch { uni.showToast({ title: '话题获取失败', icon: 'none' }) } } onMounted(() => { triggerfunction() TopicDetails() }) /** upload */ const TemporaryValue = ref('image') const fileList1 = ref([]) const deletePic = (event) => fileList1.value.splice(event.index, 1) const afterRead = (event) => { const lists = Array.isArray(event.file) ? event.file : [].concat(event.file) lists.forEach((item) => { const url = item?.url || item?.path || item?.tempFilePath if (!url) return fileList1.value.push({ ...item, url, status: 'ready', message: '等待上传' }) }) } /** 切换上传类型时清空 */ const selectsfile = () => { if (publishing.value) return uni.showActionSheet({ itemList: ['上传图片', '上传视频'], itemColor: '#000000', success: (res) => { const next = res.tapIndex === 1 ? 'video' : 'image' if (TemporaryValue.value !== next) { fileList1.value = [] WorkID.value = '' videoSrc.value = '' } TemporaryValue.value = next uni.showToast({ title: `已切换为${next === 'video' ? '视频' : '图片'}`, icon: 'none' }) }, fail: () => uni.showToast({ title: '取消选择', icon: 'none' }) }) } /** video preview */ const videoSrc = ref('') watch([fileList1, TemporaryValue], () => { if (TemporaryValue.value !== 'video') { videoSrc.value = '' return } const first = fileList1.value?.[0] videoSrc.value = first?.url || '' }, { deep: true }) /** publish */ const publishing = ref(false) const publishIndex = ref(0) const publishTotal = ref(0) const uploadingCount = computed(() => fileList1.value.filter((f) => f.status === 'uploading').length) const failedCount = computed(() => fileList1.value.filter((f) => f.status === 'failed').length) const publishProgressText = computed(() => { if (!publishing.value) return '' if (publishTotal.value <= 0) return '发布中...' return `发布中 ${publishIndex.value}/${publishTotal.value}` }) function safeParseJSON(maybe) { if (maybe == null) return { ok: false, data: null, raw: '' } if (typeof maybe === 'object') return { ok: true, data: maybe, raw: '' } const raw = String(maybe) const trimmed = raw.trim() if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html') || trimmed.startsWith('<')) return { ok: false, data: null, raw } try { return { ok: true, data: JSON.parse(raw), raw: '' } } catch { return { ok: false, data: null, raw } } } function shortText(str = '', n = 160) { const s = String(str || '') return s.length > n ? s.slice(0, n) + '...' : s } function pickId(obj) { return obj?.id || obj?.data?.id || obj?.data?.data?.id || obj?.rescueCircleId || obj?.data?.rescueCircleId || '' } /** 统一 payload(friendIds/tagIds 数组) */ function buildPayload() { const tagIds = (selectedTopicIds.value || []).map(String) const friendIds = FriendID.value ? [String(FriendID.value)] : [] return { consumerId: String(consumerId.value || ''), content: String(textAreaContent.value || ''), id: String(WorkID.value || ''), tagIds, friendIds, type: publishType.value } } const serverUrl = inject('serverUrl') const WorkID = ref('') const UPLOAD_URL = 'rescueCircle' const uploadFilePromise = (filePath) => { return new Promise((resolve, reject) => { const payload = buildPayload() uni.uploadFile({ url: serverUrl + UPLOAD_URL, filePath, name: 'rescueCircleFile', header: { 'Content-Type': 'multipart/form-data' }, formData: payload, success: (res) => { const parsed = safeParseJSON(res?.data) if (!parsed.ok) return reject(new Error('接口返回非JSON:' + shortText(parsed.raw))) const data = parsed.data || {} const newId = pickId(data) if (newId) WorkID.value = newId resolve(data) }, fail: (err) => reject(err) }) }) } /** 只有文字也要发请求 */ async function publishTextOnly() { const payload = buildPayload() const res = await ajax.post({ url: 'rescueCircle', method: 'POST', data: payload }) const ok = (res?.statusCode === 200) || (res?.data?.statusCode === 200) || (res?.errMsg?.includes('ok')) if (!ok) throw new Error('发布失败') const newId = pickId(res?.data || res) if (newId) WorkID.value = newId return res } const handlePublish = async () => { if (publishing.value) return const ok = await guardOrgOrRoute() if (!ok) return const readyFiles = fileList1.value.filter((f) => f.status === 'ready') if (readyFiles.length === 0 && !textAreaContent.value.trim()) { uni.showToast({ title: '请添加内容或图片/视频', icon: 'none' }) return } if (!consumerId.value) { uni.showToast({ title: '缺少consumerId,请先登录', icon: 'none' }) return } publishing.value = true publishIndex.value = 0 publishTotal.value = readyFiles.length uni.showLoading({ title: '发布中...', mask: true }) try { if (readyFiles.length === 0) { await publishTextOnly() } else { for (let i = 0; i < readyFiles.length; i++) { const file = readyFiles[i] publishIndex.value = i + 1 file.status = 'uploading' file.message = '上传中...' try { const res = await uploadFilePromise(file.url) file.status = 'success' file.message = '' const u = res?.url || res?.data?.url if (u) file.url = u } catch (err) { file.status = 'failed' file.message = '上传失败' uni.hideLoading() uni.showModal({ title: '上传失败', content: '原因:' + (err?.message || '上传失败') + '\n\n常见原因:\n1)UPLOAD_URL 写错 / 后端返回 HTML\n2)服务 502/404\n3)后端返回不是 JSON', showCancel: false }) throw err } } } uni.hideLoading() uni.showToast({ title: '发布成功', icon: 'success' }) // ✅ 通知 RescueChannel 刷新 uni.$emit('RESCUE_CHANNEL_PUBLISHED', { type: publishType.value }) // ✅ 重置 textAreaContent.value = '' selectedTopicIds.value = [] FriendID.value = '' fileList1.value = [] WorkID.value = '' videoSrc.value = '' showTopics.value = false showFriends.value = false setTimeout(() => { uni.navigateBack({ delta: 1 }) }, 350) } catch (e) { uni.hideLoading() } finally { publishing.value = false } } </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container { padding-top: 0 !important; } .pc-root { height: 100vh; overflow: hidden; background: #fff; } /* header */ .pc-header { position: fixed; left: 0; right: 0; top: 0; z-index: 999; background: #fff; } .pc-header-inner { position: relative; display: flex; align-items: center; justify-content: center; } .pc-left, .pc-right { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .pc-left { left: 0; justify-content: flex-start; } .pc-right { right: 0; justify-content: flex-end; } .pc-title { font-size: 32rpx; color: #000; font-weight: 700; } /* main */ .pc-main { height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; min-height: 0; } .pc-scroll { flex: 1; min-height: 0; background: #fff; } /* bottom fixed */ .pc-bottom{ flex-shrink: 0; padding: 16rpx 18rpx calc(16rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #f1f1f1; background: #fff; } .publish-btn { height: 82rpx; border-radius: 12rpx; background: #D89833; display: flex; justify-content: center; align-items: center; } .publish-btn.disabled { opacity: 0.6; } .publish-inner { display: flex; align-items: center; gap: 10rpx; } .publish-text { color: #fff; font-size: 28rpx; font-weight: 900; } /* body */ .page-body { width: 94%; margin: 0 auto; padding: 24rpx 0 40rpx 0; } .describe .texter { position: relative; border-radius: 20rpx; box-shadow: 0 2rpx 14rpx rgba(0, 0, 0, 0.06); background: #fff; padding: 20rpx; min-height: 240rpx; } .describe textarea { min-height: 200rpx; font-size: 28rpx; } .counter { position: absolute; right: 10rpx; bottom: 10rpx; } .counter-text { font-size: 26rpx; color: #999; } .actions-row { display: flex; align-items: center; margin-top: 24rpx; gap: 20rpx; flex-wrap: wrap; } .chip { height: 50rpx; padding: 0 24rpx; border-radius: 50rpx; background-color: #D89833; font-size: 26rpx; display: flex; align-items: center; justify-content: center; color: #fff; } .chip.ghost { background-color: transparent; border: 1rpx solid rgba(0, 0, 0, 0.12); color: #333; } .video-wrap { width: 220rpx; height: 300rpx; margin-top: 24rpx; border-radius: 15rpx; overflow: hidden; background: #000; } .upload-card { margin-top: 24rpx; padding: 20rpx; background-color: #ffffff; border-radius: 12rpx; } .upload-title { font-size: 28rpx; color: #111; display: flex; align-items: center; gap: 10rpx; margin-bottom: 12rpx; } .upload-sub { font-size: 24rpx; color: #999; } .upload-tip { margin-top: 10rpx; font-size: 24rpx; color: #666; } </style>

如果你把 “榜单接口真实字段/接口名”(比如返回字段叫 rankArray 还是 list,接口叫 rescueCircleRankList 还是别的)发我,我可以把 RescueChannel.vue 右侧榜单那部分的 fetchRank() 直接改成完全对齐你后端的真实结构(现在为了不阻塞你联调,我做了“接口不存在也能跑的占位兜底”,UI一定跟截图一致)。

分享这个问答