文件
fenleixinxi/uniapp/pages/category/channel.vue
MiAn 14d02aedcd feat: 新增提审防审模式,隐藏发布及评论相关入口
本次新增由后台 virtualPayAudit 开关控制的审核模式,在该模式下:
1. 隐藏底部 Tab 栏的发布按钮
2. 隐藏空状态、分类页、个人页的发布入口
3. 隐藏帖子卡片、详情页、个人发布页的评论数及评价入口
4. 发布页在审核模式下直接拦截并跳转至首页
5. 详情页的用户评价及评价弹层在审核模式下隐藏,避免 UGC 判定
2026-11-04 16:26:25 +08:00

764 行
21 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="list fc-page">
<!-- 规范化顶部导航 -->
<fc-navbar :title="pageTitle || '分类信息'" theme="gradient" :show-back="true" />
<!-- 二级分类实拍图横滑筛选:全部按钮常驻左侧,细分品类横向居中滑动 -->
<view v-if="subCategories.length" class="list__subcats-bar">
<!-- 1. 全部按钮:固定在左侧 -->
<view class="list__subcats-fixed">
<view
class="list__subcat"
:class="{ 'list__subcat--active': !subCategoryId }"
@click="changeSubCategory(null)"
>
<view class="list__subcat-thumb">
<image v-if="currentParentCat" class="list__subcat-img" :src="catIcon(currentParentCat)" mode="aspectFit" />
</view>
<text class="list__subcat-name">全部</text>
</view>
<view class="list__subcats-divider"></view>
</view>
<!-- 2. 二级分类项:横向滚动,支持自动居中 -->
<scroll-view
class="list__subcats-scroll"
scroll-x
:scroll-left="subCatScrollLeft"
:scroll-with-animation="canAnimateScroll"
:show-scrollbar="false"
@scroll="onSubCatScroll"
>
<view class="list__subcats-inner">
<view
v-for="sub in subCategories"
:key="sub.id"
:id="'subcat-item-' + sub.id"
class="list__subcat"
:class="{ 'list__subcat--active': Number(subCategoryId) === Number(sub.id) }"
@click="changeSubCategory(sub)"
>
<view class="list__subcat-thumb">
<image class="list__subcat-img" :src="catIcon(sub)" mode="aspectFill" />
</view>
<text class="list__subcat-name">{{ sub.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 分类横滑:仅在未指定具体分类的全量列表场景下显示 -->
<scroll-view v-else-if="!isSpecificCategory" class="list__cats" scroll-x :show-scrollbar="false">
<view class="list__cats-inner">
<view v-for="c in categories" :key="c.id" class="list__cat"
:class="{ 'list__cat--active': categoryId === c.id }" @click="changeCategory(c)">
<image class="list__cat-icon" :src="catIcon(c)" mode="aspectFit" />
<text>{{ c.name }}</text>
</view>
</view>
</scroll-view>
<!-- 多维筛选 -->
<view class="list__filters">
<view v-for="s in sorts" :key="s.value" class="list__filter"
:class="{ 'list__filter--active': sort === s.value }" @click="changeSort(s.value)">
<text>{{ s.label }}</text>
<!-- 价格排序双三角:升/降序切换指示(主流交互) -->
<view v-if="s.value === 'price'" class="list__caret">
<view class="list__caret-up" :class="{ 'is-on': sort === 'price' && priceOrder === 'asc' }"></view>
<view class="list__caret-down" :class="{ 'is-on': sort === 'price' && priceOrder === 'desc' }"></view>
</view>
</view>
<view class="list__filter-area-btn" @click="openAreaPicker">
<image class="list__filter-area-icon" :src="iconUrl('home-location')" mode="aspectFit" />
<text class="list__filter-area-txt fc-ellipsis">{{ areaDisplay }}</text>
<text class="list__filter-area-arrow">▾</text>
</view>
</view>
<!-- 骨架屏加载 -->
<view v-if="loading && !list.length" class="list__loading">
<view v-for="i in 3" :key="i" class="fc-skeleton list__skeleton"></view>
</view>
<fc-empty v-else-if="!list.length" text="该分类下暂无信息" :btn-text="isAudit ? '' : '发布一条'" @action="goCreate" />
<view v-else>
<fc-post-card v-for="p in list" :key="p.id" :post="p" @click="goDetail" />
<view class="list__more">
<text v-if="loading">加载中…</text>
<text v-else-if="!hasMore">— 没有更多了 —</text>
<text v-else @click="loadMore">加载更多</text>
</view>
</view>
<view v-if="!isAudit" class="list__fab" @click="goCreate">
<text>+ 发布</text>
</view>
<!-- 城市/区域选择抽屉(省/全国 → 市 两列级联,与首页一致) -->
<fc-city-picker
:visible="showAreaPicker"
:areas="areaRows"
:province="selectedProvince"
:city="selectedCity"
@select="onAreaSelect"
@close="closeAreaPicker"
/>
</view>
</template>
<script>
import FcNavbar from '@/components/fc-navbar/fc-navbar.vue'
import FcPostCard from '@/components/fc-post-card/fc-post-card.vue'
import FcEmpty from '@/components/fc-empty/fc-empty.vue'
import FcCityPicker from '@/components/fc-city-picker/fc-city-picker.vue'
import homeApi from '@/common/api/home.js'
import { iconUrl, categoryIconUrl, catIcon, loadUiConfig, uiState } from '@/common/store/ui.js'
export default {
components: { FcNavbar, FcPostCard, FcEmpty, FcCityPicker },
data() {
return {
categories: [],
categoryId: 0,
subCategoryId: 0,
// 固定具体分类ID(由路由传入,用于区分是否是进入具体分类列表)
fixedCategoryId: 0,
pageTitle: '',
list: [],
page: 1,
size: 10,
hasMore: true,
loading: false,
// 请求序号:快速切换分类/排序/区域时丢弃过期响应
reqSeq: 0,
sort: 'new',
// 价格排序方向(asc 升序 / desc 降序),点击已选中的「价格」时切换
priceOrder: 'asc',
// 区域筛选:只到市级(全国/省/市),与首页口径一致
selectedProvince: '',
selectedCity: '',
showAreaPicker: false,
manualArea: false,
lat: null,
lng: null,
// 平铺服务区域(每条自带省/市/区县)
areaRows: [],
sorts: [
{ label: '最新', value: 'new' },
{ label: '最热', value: 'hot' },
{ label: '价格', value: 'price' },
],
// 二级分类横向滚动与居中控制
subCatScrollLeft: 0,
currentScrollLeft: 0,
canAnimateScroll: false,
}
},
computed: {
/**
* 是否处于提审防审模式(由后台 virtualPayAudit 开关统一控制)
* @returns {boolean} true=审核中(隐藏发布悬浮球与引导)
*/
isAudit() {
return uiState.virtualPayAudit !== false
},
/**
* 判断是否为具体分类展示模式
* @returns {boolean}
*/
isSpecificCategory() {
return !!this.fixedCategoryId
},
/**
* 当前大类对象
*/
currentParentCat() {
return (this.categories || []).find((c) => Number(c.id) === Number(this.categoryId)) || null
},
/**
* 当前大类下的二级分类列表
*/
subCategories() {
return (this.currentParentCat && Array.isArray(this.currentParentCat.children)) ? this.currentParentCat.children : []
},
/**
* 筛选栏区域文案(城市 > 省份 > 缺省「区域」)
*/
areaDisplay() {
if (this.selectedCity) return this.selectedCity
if (this.selectedProvince) return this.selectedProvince
return '区域'
},
},
/**
* 页面加载:读取分类入参并彻底防止 URL 编码乱码
* @param {Object} query 路由参数
*/
onLoad(query) {
this.categoryId = Number(query.categoryId || 0)
this.fixedCategoryId = Number(query.categoryId || 0)
if (query.subCategoryId) {
this.subCategoryId = Number(query.subCategoryId || 0)
}
if (query.name) {
const decoded = this.safeDecode(query.name)
this.pageTitle = decoded
uni.setNavigationBarTitle({ title: decoded })
}
loadUiConfig()
this.init()
},
onReachBottom() {
this.loadMore()
},
methods: {
iconUrl,
categoryIconUrl,
catIcon,
/**
* 彻底安全解码 URL 参数(防一层、二层及多层 URL 编码导致的 %E6%95%... 乱码)
* @param {string} str 待解码的字符串
* @returns {string} 解码后的文本
*/
safeDecode(str) {
let res = String(str || '').trim()
try {
while (/%[0-9a-fA-F]{2}/.test(res)) {
const next = decodeURIComponent(res)
if (next === res) break
res = next
}
} catch (e) {}
return res
},
/**
* 页面初始化:拉取分类与区域筛选项,并执行首次信息流拉取
*/
async init() {
try {
const cats = await homeApi.categories()
this.categories = Array.isArray(cats) ? cats : []
if (this.fixedCategoryId || this.categoryId) {
let current = this.categories.find((c) => Number(c.id) === Number(this.categoryId))
if (!current) {
for (const p of this.categories) {
const sub = (p.children || []).find((s) => Number(s.id) === Number(this.categoryId))
if (sub) {
current = p
this.categoryId = Number(p.id)
this.subCategoryId = Number(sub.id)
break
}
}
}
// 若指定了 subCategoryId 但与当前大类不匹配,反查所属父级
if (this.subCategoryId && (!current || !(current.children || []).some((s) => Number(s.id) === Number(this.subCategoryId)))) {
for (const p of this.categories) {
const sub = (p.children || []).find((s) => Number(s.id) === Number(this.subCategoryId))
if (sub) {
current = p
this.categoryId = Number(p.id)
break
}
}
}
if (current && (!this.pageTitle || /%[0-9a-fA-F]{2}/.test(this.pageTitle))) {
this.pageTitle = current.name
uni.setNavigationBarTitle({ title: current.name })
}
}
// 若存在已选中的二级分类,执行居中滚动定位
if (this.subCategoryId) {
this.scrollToActiveSubCategory(false)
}
} catch (e) {}
// 服务区域:默认开放全国省/市/区县
try {
const rows = await homeApi.areas()
this.areaRows = Array.isArray(rows) ? rows : []
} catch (e) {}
this.fetch(true)
// LBS:按定位匹配默认区域(失败/无权限静默,不阻塞列表)
this.fetchLocation()
},
/**
* 取用户坐标并匹配区域(gcj02)
*/
fetchLocation() {
if (typeof uni === 'undefined' || !uni.getLocation) return
uni.getLocation({
type: 'gcj02',
success: (res) => {
this.lat = res.latitude
this.lng = res.longitude
this.applyLocationArea().finally(() => {
this.page = 1
this.fetch(true)
})
},
fail: () => {},
})
},
/**
* 把定位坐标转换成区域筛选默认值(只匹配到市级,用户手动切换过则不覆盖)
*/
async applyLocationArea() {
if (this.manualArea || this.lat == null || this.lng == null) return
try {
const found = await homeApi.locate({ lat: this.lat, lng: this.lng })
if (found && found.areaId) {
this.selectedProvince = found.province || ''
this.selectedCity = found.city || ''
}
} catch (e) {}
},
/**
* 分页获取分类信息列表
* @param {boolean} reset 是否重置分页重新拉取第一页
*/
async fetch(reset = false) {
const seq = ++this.reqSeq
this.loading = true
try {
const res = await homeApi.feed({
page: this.page,
size: this.size,
categoryId: this.categoryId || undefined,
subCategoryId: this.subCategoryId || undefined,
// 价格排序带方向:price_asc / price_desc(后端按方向排序)
sort: this.sort === 'price' ? `price_${this.priceOrder}` : this.sort,
// 区域筛选只到市级:城市优先,其次省份
province: !this.selectedCity && this.selectedProvince ? this.selectedProvince : undefined,
city: this.selectedCity || undefined,
})
// 期间又切换了筛选条件:以最后一次请求为准
if (seq !== this.reqSeq) return
this.list = reset ? res.list : this.list.concat(res.list)
this.hasMore = res.hasMore
} catch (e) {
if (seq !== this.reqSeq) return
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
if (seq === this.reqSeq) this.loading = false
}
},
/**
* 滚动触底加载下一页数据
*/
loadMore() {
if (!this.hasMore || this.loading) return
this.page += 1
this.fetch()
},
/**
* 切换当前选中的分类(全量列表视角)
* @param {Object} c 选中的分类项
*/
changeCategory(c) {
this.categoryId = this.categoryId === c.id ? 0 : c.id
this.subCategoryId = 0
this.page = 1
this.fetch(true)
},
/**
* 监听二级分类栏横向滚动事件,保持当前滚动位移与组件状态同步
* @param {Object} e 滚动事件对象
*/
onSubCatScroll(e) {
if (e && e.detail) {
this.currentScrollLeft = e.detail.scrollLeft || 0
}
},
/**
* 滚动选中的二级分类项至视口中央(兼顾左右边界保护)
* @param {boolean} withAnimation 是否带平滑滚动动画
*/
scrollToActiveSubCategory(withAnimation = true) {
if (!this.subCategoryId) {
this.canAnimateScroll = withAnimation
this.subCatScrollLeft = 0
return
}
const executeScroll = (retryCount = 0) => {
const query = uni.createSelectorQuery().in(this)
query.select('.list__subcats-scroll').boundingClientRect()
query.select(`#subcat-item-${this.subCategoryId}`).boundingClientRect()
query.exec((res) => {
if (!res || !res[0] || !res[1]) {
if (retryCount < 5) {
setTimeout(() => executeScroll(retryCount + 1), 60)
}
return
}
const scrollRect = res[0]
const itemRect = res[1]
// 滚动容器可视宽度与左边距
const containerWidth = scrollRect.width
const containerLeft = scrollRect.left
// 选中项宽度与左边距
const itemWidth = itemRect.width
const itemLeft = itemRect.left
// 选中项相对于滚动容器可视左边界的实时距离
const relativeLeft = itemLeft - containerLeft
// 居中计算:item 中心对齐 container 可视中心
// 偏移增量 = relativeLeft + itemWidth / 2 - containerWidth / 2
// 新目标 scrollLeft = 当前已滚动位置 + 偏移增量
let targetScrollLeft = this.currentScrollLeft + relativeLeft + (itemWidth / 2) - (containerWidth / 2)
// 边界处理:左边界保护(若小于0则靠左展示)
if (targetScrollLeft < 0) {
targetScrollLeft = 0
}
this.canAnimateScroll = withAnimation
if (Math.abs(this.subCatScrollLeft - targetScrollLeft) < 1) {
this.subCatScrollLeft = targetScrollLeft + 0.1
} else {
this.subCatScrollLeft = targetScrollLeft
}
})
}
this.$nextTick(() => {
setTimeout(() => {
executeScroll()
}, 60)
})
},
/**
* 切换二级分类筛选项
* @param {Object|null} sub 二级分类
*/
changeSubCategory(sub) {
const nextId = sub ? Number(sub.id) : 0
if (this.subCategoryId === nextId) return
this.subCategoryId = nextId
this.page = 1
this.fetch(true)
this.scrollToActiveSubCategory(true)
},
/**
* 切换排序方式(价格项:首次点击升序,再次点击升降序来回切换)
* @param {string} v 排序字段(new/hot/price)
*/
changeSort(v) {
if (v === 'price') {
if (this.sort === 'price') {
this.priceOrder = this.priceOrder === 'asc' ? 'desc' : 'asc'
} else {
this.priceOrder = 'asc'
}
} else if (this.sort === v) {
return
}
this.sort = v
this.page = 1
this.fetch(true)
},
/**
* 打开城市选择抽屉
*/
openAreaPicker() {
this.showAreaPicker = true
},
/**
* 关闭城市选择抽屉
*/
closeAreaPicker() {
this.showAreaPicker = false
},
/**
* 城市选择抽屉回调(全国/全省/全市统一入口)
* @param {Object} area { province, city },空串表示全国/全省
*/
onAreaSelect(area) {
this.selectedProvince = (area && area.province) || ''
this.selectedCity = (area && area.city) || ''
this.manualArea = true
this.showAreaPicker = false
this.page = 1
this.fetch(true)
},
/**
* 跳转到信息详情页
* @param {Object} p 信息项
*/
goDetail(p) {
uni.navigateTo({ url: `/pages/detail/detail?id=${p.id}` })
},
/**
* 跳转到信息发布页(审核模式防穿透拦截,自动携带当前选中的大类与二级分类参数)
*/
goCreate() {
if (this.isAudit) return
const targetCatId = Number(this.categoryId || this.fixedCategoryId || 0)
const targetSubCatId = Number(this.subCategoryId || 0)
const params = []
if (targetCatId > 0) {
params.push(`categoryId=${targetCatId}`)
}
if (targetSubCatId > 0) {
params.push(`subCategoryId=${targetSubCatId}`)
}
const url = params.length > 0 ? `/pages/post/create?${params.join('&')}` : '/pages/post/create'
uni.navigateTo({ url })
},
},
}
</script>
<style lang="scss" scoped>
.list__cats {
background: #ffffff;
white-space: nowrap;
padding: 16rpx 0;
border-bottom: 2rpx solid $fc-border;
}
.list__subcats-bar {
display: flex;
align-items: center;
background: #ffffff;
padding: 18rpx 0 16rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
position: sticky;
top: 0;
z-index: 20;
box-sizing: border-box;
width: 100%;
}
.list__subcats-fixed {
flex-shrink: 0;
display: flex;
align-items: center;
padding-left: 20rpx;
padding-right: 18rpx;
position: relative;
z-index: 3;
background: #ffffff;
}
.list__subcats-divider {
position: absolute;
right: 0;
top: 15%;
height: 70%;
width: 2rpx;
background: #f1f5f9;
box-shadow: 2rpx 0 6rpx rgba(0, 0, 0, 0.04);
}
.list__subcats-scroll {
flex: 1;
width: 0;
white-space: nowrap;
overflow: hidden;
}
.list__subcats-inner {
display: inline-flex;
padding: 0 20rpx 0 16rpx;
gap: 20rpx;
}
.list__subcat {
display: inline-flex;
flex-direction: column;
align-items: center;
flex: 0 0 auto;
cursor: pointer;
&--active {
.list__subcat-thumb {
border-color: #00d26a;
background: #ecfdf5;
box-shadow: 0 4rpx 12rpx rgba(0, 210, 106, 0.22);
}
.list__subcat-name {
color: #00a854;
font-weight: 700;
}
}
}
.list__subcat-thumb {
width: 96rpx;
height: 96rpx;
border-radius: 24rpx;
background: #f8fafc;
border: 2rpx solid transparent;
display: flex;
align-items: center;
justify-content: center;
padding: 8rpx;
box-sizing: border-box;
transition: all 0.2s ease;
}
.list__subcat-img {
width: 100%;
height: 100%;
border-radius: 16rpx;
}
.list__subcat-name {
margin-top: 8rpx;
font-size: 23rpx;
color: #334155;
line-height: 1.2;
text-align: center;
max-width: 110rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list__cats-inner {
display: inline-flex;
padding: 0 16rpx;
}
.list__cat {
margin: 0 10rpx;
padding: 10rpx 24rpx;
border-radius: $fc-radius-pill;
background: $fc-bg;
font-size: $fc-font-sm;
color: $fc-text-regular;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
&--active {
background: $fc-gradient-primary;
color: $fc-primary-ink;
font-weight: 600;
box-shadow: 0 6rpx 16rpx rgba(4, 48, 30, 0.3);
}
}
.list__cat-icon {
width: 34rpx;
height: 34rpx;
margin-right: 8rpx;
}
.list {
min-height: 100vh;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
background: #f8fafc;
}
.list__cats {
background: #ffffff;
white-space: nowrap;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
position: sticky;
top: 0;
z-index: 20;
}
.list__cat {
cursor: pointer;
transition: all 0.15s ease;
&:active {
transform: scale(0.95);
}
}
.list__filters {
display: flex;
align-items: center;
padding: 18rpx 28rpx;
background: #ffffff;
margin-bottom: 20rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
}
.list__filter {
cursor: pointer;
transition: color 0.15s ease;
}
.list__filter {
margin-right: 28rpx;
font-size: $fc-font;
color: $fc-text-secondary;
display: inline-flex;
align-items: center;
&--active {
color: $fc-primary-text;
font-weight: 700;
}
}
/* 价格排序双三角指示器(未选中灰、选中主题绿) */
.list__caret {
display: inline-flex;
flex-direction: column;
align-items: center;
margin-left: 6rpx;
gap: 3rpx;
}
.list__caret-up {
width: 0;
height: 0;
border-left: 7rpx solid transparent;
border-right: 7rpx solid transparent;
border-bottom: 9rpx solid #cbd5e1;
&.is-on {
border-bottom-color: #00a854;
}
}
.list__caret-down {
width: 0;
height: 0;
border-left: 7rpx solid transparent;
border-right: 7rpx solid transparent;
border-top: 9rpx solid #cbd5e1;
&.is-on {
border-top-color: #00a854;
}
}
/* 筛选栏区域按钮(与首页定位胶囊同款:白底描边胶囊 + 主题绿字) */
.list__filter-area-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
padding: 8rpx 20rpx;
background: #ffffff;
border: 1rpx solid rgba(0, 168, 84, 0.22);
border-radius: 999rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 168, 84, 0.06);
}
.list__filter-area-icon {
width: 24rpx;
height: 24rpx;
margin-right: 6rpx;
flex-shrink: 0;
}
.list__filter-area-txt {
font-size: 24rpx;
color: #00a854;
font-weight: 600;
max-width: 180rpx;
}
.list__filter-area-arrow {
font-size: 20rpx;
color: #00a854;
margin-left: 6rpx;
}
.list__loading {
padding: 24rpx;
}
.list__skeleton {
height: 220rpx;
margin-bottom: 20rpx;
}
.list__more {
text-align: center;
padding: 24rpx 0 40rpx;
font-size: $fc-font-sm;
color: $fc-text-placeholder;
}
.list__fab {
position: fixed;
right: 32rpx;
bottom: 60rpx;
padding: 20rpx 36rpx;
border-radius: $fc-radius-pill;
background: $fc-gradient-primary;
color: $fc-primary-ink;
font-size: $fc-font;
font-weight: 700;
box-shadow: 0 10rpx 28rpx rgba(4, 48, 30, 0.4);
}
</style>