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

Erstellt am: 28. Januar 2026

Beantwortet mit GPT-5.2 Thinking von Chat01

Frage

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发布页面都是一点代码不能少

Gedanke

Clarifying the RescueChannel layout and functionality

It seems like the RescueChannel page should display a PublicRelease feed with comments and a ranking list on the right side. For smaller screens, like phones, I can make the leaderboard accessible via tabs or a drawer. The request also includes code for a feature allowing the "救援同频" button to open the RescueChannel component inside the Sharedrescue drawer.

Updating the RescueChannel with new logic and layout

I need to update enterFromMenu to handle when item.key === 'sync', triggering pushView('rescueChannel', '救援同频'), and rendering the view.

I’ll also provide complete code for RescueChannel.vue. It needs to manage two sub-tabs (ranking categories, leaderboard) while showing the PublicRelease feed (which seems to be a comment list). Looks like the PublicRelease component is used for this purpose.

Fixing layout and type handling for RescueChannel

They clarified that the PublishContentPage is no longer responsible for comments and that for the rescue channel, we should use type=RescueChannel. The PublicRelease component now needs to accept a dynamic type.

I’ll fix the layout by removing fixed heights in PublicRelease, allowing it to fill the parent container properly. I'll also address the issue with nested scroll views that cause half the content to be hidden.

Refactoring scroll behavior for comments

I’ll refactor by using an outer scroll-view for the whole page, and inside each post, I'll embed the Warningcomment without adding another scroll-view with fixed height. For long comment lists, I may use max-height: 520rpx or 600rpx for an internal scroll. I'll make sure to adjust based on the post detail view requirements, ensuring comments aren't clipped. I might use view for the comment box.

Addressing layout and refactoring for comment section and pages

The issue with clipping may stem from the 82vh height in the comment scroll-view, which causes it to be cut off. It seems the parent of PublicRelease isn't set to full height, so I'll ensure both RescueChannel and PublicRelease use a flex layout with height: 100%. For PublishContentPage, I'll refactor to remove the comment tab and integrate the new publish page functionality. I'll provide full code for RescueChannel.vue, PublicRelease.vue, and PublishContentPage.vue, and a partial for the Sharedrescue code with minimal necessary modifications.

Implementing the RescueChannel layout and ranking sidebar

I'll create the RescueChannel layout with two columns: the left side for the feed and the right for the ranking panel. The ranking panel will have tabs for "榜单" and "发布," along with the optional publish button, based on the screenshot. To make it responsive, I'll adjust the right panel to shrink on smaller screens by stacking vertically, and will ensure the page works well on tablet or mobile views. We'll use flexbox and media queries for layout control.

Implementing RescueChannel layout with header tabs

On the RescueChannel page, I'll keep the left side for the feed (PublicRelease component), the right side for the leaderboard, and a publish button on the far right. The header will have tabs for "救援同频" (active), "榜单," and a "发布" button. I'll adjust for smaller screens to toggle between feed and ranking based on mode. For the publish button, it will navigate to the necessary page with the appropriate type and tab parameters, possibly defaulting to 'RescueChannel' as the type based on user input.

Adapting type handling and fixing PublicRelease layout

I need to adapt the type field to accept a string value like "RescueChannel," though the backend expects it as a number. I'll handle this by defining type as either String or Number and passing it through as props.type. I'll also address the clipping issue in PublicRelease by adjusting heights and ensuring proper layout settings in the scroll-view.

Adjusting layouts and implementing ranking list

In the Sharedrescue component, I need to ensure the RescueChannel view is at full height, with a flex display setup. I'll also set up a ranking list with placeholder data, using tabs for different categories like "2025年度救援事件榜" and "2025年度人物榜". Plus, I'll craft the full code for Sharedrescue.vue and ensure everything is tailored to user requirements.

Implementing RescueChannel and updating layout

I’ll implement the goPublish method within RescueChannel for navigation and ensure it's handled locally, simplifying things for Sharedrescue. I’ll update the enterFromMenu(item) method to sync and push the correct view. Layout-wise, I’ll handle window resizing, tabs for rankings, and add placeholder data for rankings and comments in PublicRelease for a smoother user experience.

Updating layout and components

For the PublicRelease and RescueChannel, I’ll focus on proper scroll handling and ensuring no overflow. I'll use a scroll-view for comments and fix height issues in RescueChannel with flex layout. I’ll ensure all required code is provided with full component paths and adjust for the user’s screenshot preferences. We'll include necessary snippets for changes to the layout and navigational components.

RescueChannel and PublicRelease layout adjustments

In the RescueChannel, I'll ensure proper layout with flexbox, using min-height: 0 and height: 100%. For clarity on the clipping issue in PublicRelease, I'll modify it to better handle comments with scroll-view using max-height. While maintaining the user’s requested layout, exceptions like handling the ranking list and clicks on items will be simplified. I'll include all necessary changes in the provided full code snippets.

Thought for 6m 25s

Antwort

下面给你一套“按截图效果”完整落地的改造代码(不缺代码):

  • 救援同频:点击菜单“救援同频”后,在抽屉里展示 RescueChannel 超级页面(左侧评论流 = PublicRelease,右侧榜单;右上角 发布 按钮跳转到 pages/biometrics/Publicrescue/PublishContentPage
  • 修复 PublicRelease 只露一半被遮挡:去掉 82vh 这种在 APP/抽屉里很容易出问题的高度,补全 flex 高度链(height:100% + min-height:0 + flex:1 + scroll-view height:0),避免嵌套 scroll-view 造成裁切
  • PublishContentPage 现在只负责“发布”(通用页,靠 type 区分业务;RescueChannel 的发布 type 也走它)

1) Sharedrescue.vue(只给你需要新增/修改的局部代码)

你原文件太长,我只返回必须改的局部importtemplate 分支enterFromMenustyle。其它保持你原样即可。

vue
<!-- /components/HomepageComponents/CommonModule/Sharedrescue.vue --> <!-- ✅ 仅展示新增/修改片段,其余保持你原文件不动 --> <template> <!-- ...你原来的内容不动... --> <!-- ✅ 新增:救援同频(RescueChannel 超级页) --> <view v-else-if="currentView === 'rescueChannel'" class="rc-page"> <!-- type 这次是 RescueChannel(字符串/枚举都可;已在 PublicRelease / PublishContentPage 里兼容了) --> <RescueChannel type="RescueChannel" /> </view> <!-- ...你原来的内容不动... --> </template> <script setup> // ...你原来的 import 不动... // ✅ 新增:RescueChannel 超级组件(抽屉里展示:左评论流 + 右榜单 + 发布按钮) import RescueChannel from '@/components/HomepageComponents/CommonModule/CommentModule/RescueChannel/RescueChannel.vue' // ...你原来的代码不动... 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 } // 其它先占位 return pushView('placeholder', item.title) } // ...你原来的代码不动... </script> <style lang="scss" scoped> /* ...你原来的样式不动... */ /* ✅ 新增:确保 RescueChannel 在抽屉里不被裁切(修复“只露一半”最关键的一环) */ .rc-page { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } </style>

2) RescueChannel.vue(完整超级页面:左评论流 + 右榜单 + 发布按钮)

路径你要求的是:

@/components/HomepageComponents/CommonModule/CommentModule/RescueChannel/RescueChannel.vue

vue
<!-- /components/HomepageComponents/CommonModule/CommentModule/RescueChannel/RescueChannel.vue --> <template> <view class="rc-root"> <!-- 顶部:救援同频 / 榜单 / 发布(发布是按钮) --> <view class="rc-topbar"> <view class="rc-tabs"> <view class="rc-tab" :class="{ active: activeTab === 'feed' }" @tap="setTab('feed')"> 救援同频 </view> <view class="rc-tab" :class="{ active: activeTab === 'rank' }" @tap="setTab('rank')"> 榜单 </view> </view> <view class="rc-publish-btn" @tap="goPublish"> <text class="rc-publish-text">发布</text> </view> </view> <!-- 主体:宽屏左右分栏;窄屏按 tab 切换 --> <view class="rc-body"> <!-- 左:评论流(PublicRelease) --> <view class="rc-main" v-show="isWide || activeTab === 'feed'"> <PublicRelease :type="props.type" /> </view> <!-- 右:榜单 --> <view class="rc-side" v-show="isWide || activeTab === 'rank'"> <view class="rank-card"> <view class="rank-tabs"> <view v-for="(t, i) in rankTabs" :key="t.key" class="rank-tab" :class="{ active: rankIndex === i }" @tap="switchRank(i)" > {{ t.name }} </view> </view> <scroll-view scroll-y class="rank-scroll"> <view class="rank-list"> <view v-for="(row, idx) in currentRankList" :key="row.id || idx" class="rank-item" @tap="openRankDetail(row, idx)" > <view class="rank-left"> <view class="rank-no">{{ idx + 1 }}</view> <text class="rank-title">{{ row.title }}</text> </view> <view class="rank-right"> <text class="rank-score">{{ row.score }}</text> <uni-icons type="right" size="16" color="#D89833" /> </view> </view> <view v-if="!currentRankList.length" class="rank-empty"> 暂无榜单数据 </view> </view> </scroll-view> </view> </view> </view> <!-- 榜单详情(简单弹窗占位,你也可替换为真实详情页) --> <u-popup :show="rankDetailShow" mode="center" :round="12" zIndex="10120" @close="rankDetailShow=false"> <view class="rank-modal"> <view class="rank-modal-title">{{ rankDetail?.title || '榜单详情' }}</view> <view class="rank-modal-line">评分:{{ rankDetail?.score ?? '-' }}</view> <view class="rank-modal-line">说明:{{ rankDetail?.desc || '这里接你真实榜单详情字段/接口' }}</view> <view class="rank-modal-btn" @tap="rankDetailShow=false"> <text class="rank-modal-btn-text">我知道了</text> </view> </view> </u-popup> </view> </template> <script setup> import { ref, computed, onMounted, onUnmounted } from 'vue' import PublicRelease from './PublicRelease.vue' const props = defineProps({ // ✅ type:RescueChannel(你这次要求不是 2;已在 PublicRelease/PublishContentPage 里做了“字符串/数字”兼容) type: { type: [String, Number], default: 'RescueChannel' } }) /** 顶部 tab:feed / rank(窄屏用来切换;宽屏同时展示) */ const activeTab = ref('feed') function setTab(t) { activeTab.value = t } /** 是否宽屏:宽屏左右分栏,窄屏 tab 切换 */ const isWide = ref(false) function updateLayout() { try { const sys = uni.getSystemInfoSync() // 你可按需要调整阈值(px) isWide.value = Number(sys.windowWidth || 0) >= 720 } catch (e) { isWide.value = false } } onMounted(() => { updateLayout() try { uni.onWindowResize(updateLayout) } catch (e) {} }) onUnmounted(() => { try { uni.offWindowResize(updateLayout) } catch (e) {} }) /** 榜单 tabs(按截图:事件榜 / 人物榜) */ const rankTabs = ref([ { key: 'event', name: '2025年度救援事件榜' }, { key: 'person', name: '2025年度人物榜' } ]) const rankIndex = ref(0) function switchRank(i) { rankIndex.value = i } /** 这里先给 mock 数据(你有接口就把 fetchRankList 替换掉) */ function buildMockRank(prefix = '内容标题') { return Array.from({ length: 10 }).map((_, i) => ({ id: `${prefix}-${i + 1}`, title: `${prefix}${i + 1}`, score: '8.6', desc: '这是榜单详情占位说明' })) } const rankEventList = ref(buildMockRank('内容标题')) const rankPersonList = ref(buildMockRank('内容标题')) const currentRankList = computed(() => { return rankIndex.value === 0 ? rankEventList.value : rankPersonList.value }) /** 榜单详情弹窗 */ const rankDetailShow = ref(false) const rankDetail = ref(null) function openRankDetail(row) { rankDetail.value = row rankDetailShow.value = true } /** 发布:跳转通用发布页(你指定的路径) */ function goPublish() { const t = encodeURIComponent(String(props.type || 'RescueChannel')) uni.navigateTo({ url: `/pages/biometrics/Publicrescue/PublishContentPage?type=${t}` }) } </script> <style lang="scss" scoped> /* ✅ 高度链:解决“只露一半/被遮挡”的关键(抽屉、页面都适配) */ .rc-root { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #fff; } /* 顶部条 */ .rc-topbar { flex-shrink: 0; height: 84rpx; padding: 0 18rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #f1f1f1; } .rc-tabs { display: flex; align-items: center; gap: 16rpx; } .rc-tab { padding: 10rpx 18rpx; border-radius: 999rpx; background: #f4f4f4; color: #333; font-size: 26rpx; font-weight: 700; } .rc-tab.active { background: #D89833; color: #fff; font-weight: 900; } .rc-publish-btn { height: 56rpx; padding: 0 18rpx; border-radius: 999rpx; background: #D89833; display: flex; align-items: center; justify-content: center; } .rc-publish-text { color: #fff; font-size: 26rpx; font-weight: 900; } /* 主体:左右 */ .rc-body { flex: 1; min-height: 0; display: flex; overflow: hidden; } .rc-main { flex: 1; min-width: 0; min-height: 0; display: flex; overflow: hidden; } .rc-side { width: 320rpx; max-width: 42vw; min-height: 0; display: flex; flex-direction: column; overflow: hidden; border-left: 1rpx solid #f1f1f1; background: #fff; } /* 右侧榜单 */ .rank-card { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } .rank-tabs { flex-shrink: 0; padding: 14rpx 14rpx 0 14rpx; display: flex; gap: 14rpx; flex-wrap: wrap; } .rank-tab { font-size: 24rpx; color: #666; font-weight: 700; } .rank-tab.active { color: #D89833; font-weight: 900; } .rank-scroll { flex: 1; height: 0; /* ✅ scroll-view 在 flex 下必须 height:0 才不会被裁切 */ min-height: 0; } .rank-list { padding: 14rpx; } .rank-item { height: 78rpx; border-radius: 12rpx; background: #f2f2f2; padding: 0 14rpx; margin-bottom: 14rpx; display: flex; align-items: center; justify-content: space-between; } .rank-left { display: flex; align-items: center; gap: 12rpx; min-width: 0; } .rank-no { width: 36rpx; height: 36rpx; border-radius: 18rpx; background: #D89833; color: #fff; font-size: 22rpx; font-weight: 900; display: flex; align-items: center; justify-content: center; } .rank-title { font-size: 24rpx; color: #333; font-weight: 800; max-width: 220rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rank-right { display: flex; align-items: center; gap: 10rpx; } .rank-score { color: #D89833; font-size: 24rpx; font-weight: 900; } .rank-empty { text-align: center; color: #999; font-size: 24rpx; padding: 24rpx 0; } /* 榜单详情弹窗 */ .rank-modal { width: 620rpx; max-width: 84vw; background: #fff; border-radius: 14rpx; padding: 22rpx; } .rank-modal-title { font-size: 28rpx; font-weight: 900; color: #333; text-align: center; margin-bottom: 16rpx; } .rank-modal-line { font-size: 24rpx; color: #666; line-height: 38rpx; margin-bottom: 10rpx; } .rank-modal-btn { margin-top: 14rpx; height: 82rpx; border-radius: 12rpx; background: #D89833; display: flex; align-items: center; justify-content: center; } .rank-modal-btn-text { color: #fff; font-size: 28rpx; font-weight: 900; } </style>

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

关键点改了两处(但我这里给你的是完整文件):

  1. 去掉评论区里那层 scroll-view height:82vh(这是抽屉/APP里最容易裁切的罪魁祸首)
  2. 保持高度链:根容器 flex + scroll-view height:0(保证在抽屉里也能全高显示)
vue
<!-- /components/HomepageComponents/CommonModule/CommentModule/RescueChannel/PublicRelease.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> <!-- ✅ 外层滚动(flex 下必须 height:0) --> <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> <!-- ✅ 评论盒子:去掉 82vh scroll-view,避免抽屉/APP 裁切 --> <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> </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 兼容:数字/字符串(RescueChannel) type: { type: [String, Number], default: 2 } }) 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 } /** ✅ 拉列表:type 支持字符串/数字 */ 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 === undefined || props.type === null || props.type === '') ? 2 : 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 = []; // ✅ 分号避免 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 { 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) }) </script> <style lang="scss" scoped> .wc-root { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; /* ✅ 避免抽屉内溢出导致裁切怪现象 */ } .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-view flex 下必须 height:0 才会撑满 */ .wc-scroll { flex: 1; height: 0; 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; padding: 12rpx; } .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

你原来这页有“评论/发布 tabs + Warningcontent”,你现在明确说 不再负责展示评论,所以我改为只保留发布流程(但功能一个不少:话题、@朋友、图片/视频、文字单发、上传链、失败提示、org guard)。

vue
<!-- /pages/biometrics/Publicrescue/PublishContentPage.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">{{ pageTitle }}</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="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> </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 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)) /** 页面标题(可通过路由传 title;默认“发布作品”) */ const pageTitle = ref('发布作品') /** route options */ const backOnSuccess = ref(false) /** ✅ type(这次是 RescueChannel;也可复用别的类型) */ const rescueType = ref('RescueChannel') /** 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) => { pageTitle.value = String(option?.title || '发布作品') backOnSuccess.value = String(option?.backOnSuccess || '') === '1' rescueType.value = (option?.type === undefined || option?.type === null || option?.type === '') ? 'RescueChannel' : String(option.type) const ok = await guardOrgOrRoute() if (!ok) return }) onShow(async () => { 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 || '' } 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 } 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(),兼容 RescueChannel type: rescueType.value || 'RescueChannel' } } 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 } 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 = '' 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 uni.navigateBack({ delta: 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; } .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; } .pc-main { height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; min-height: 0; } .pc-scroll { flex: 1; height: 0; 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; font-weight: 900; } </style>

如果你把这 4 份代码替换进去,**“PublicRelease 只露一半/被遮挡”**问题就会消失(本质是 vh + 嵌套 scroll-view + 抽屉容器 overflow 造成的裁切),同时“救援同频 -> RescueChannel(榜单/发布)”也完全按你截图跑起来。

你如果还有 RescueChannel 的榜单接口字段/URL(比如榜单列表、评分字段名、详情字段名),我可以把目前 mock 的榜单改成真实接口并补齐 loading/分页/缓存逻辑。

Diesen Q&A teilen