10 Commits

Author SHA1 Message Date
Apcallover
4c8f79555d 修复筛选字典数据加载竞态 2026-07-23 21:29:45 +08:00
francis-fh
2c29c2a792 搜索页面头部导航判断环境 2026-07-16 13:51:35 +08:00
francis-fh
5d08291ef6 Merge branch 'feature/filter-improvements' 2026-07-16 13:34:53 +08:00
francis-fh
9ce22a7949 一体机和小程序样式错乱问题修复。 2026-07-16 12:36:56 +08:00
francis-fh
9a5ac849a0 搜索页面头部返回 2026-07-16 00:58:13 +08:00
francis-fh
d7b7f8d111 筛选条件功能开发 2026-07-16 00:54:56 +08:00
francis-fh
3980aecd56 11 2026-07-15 23:55:48 +08:00
francis-fh
805122c23f 地区跳转bug修复 2026-07-15 23:39:11 +08:00
francis-fh
ba9e1d279d 排除文件 2026-07-15 22:58:42 +08:00
francis-fh
4ea7b8abb4 feat: 筛选页面重构为侧边栏布局,地区改为多选,零工市场改为零工岗位
- 筛选页重构header和body布局,左侧tabs+右侧content
- 地区筛选从单选改为多选(checkbox-group)
- 零工市场文案改为零工岗位
- 新增area多选参数处理逻辑
2026-07-15 22:52:14 +08:00
6 changed files with 292 additions and 124 deletions

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ pnpm-debug.log*
/hs_err_pid* /hs_err_pid*
*.local *.local
yarn.lock yarn.lock
.codegraph

View File

@@ -1,5 +1,14 @@
<template> <template>
<view v-if="show" class="filter-container"> <view v-if="show" class="filter-container">
<!-- 头部 -->
<view class="filter-header">
<view class="header-title">筛选</view>
<view class="header-close" @click="handleClose">
<view class="close-icon"></view>
</view>
</view>
<!-- 主体内容 -->
<view class="filter-body">
<!-- 左侧标签页 --> <!-- 左侧标签页 -->
<view class="filter-tabs"> <view class="filter-tabs">
<view <view
@@ -17,6 +26,24 @@
<view class="filter-right"> <view class="filter-right">
<!-- 内容区域 --> <!-- 内容区域 -->
<view class="filter-content"> <view class="filter-content">
<!-- 薪资 -->
<view v-if="activeTab === 'salary'" class="content-section">
<radio-group @change="(e) => handleSelect('salary', e)">
<label
v-for="option in salaryOptions"
:key="option.value"
class="radio-item"
:class="{ checked: selectedValues['salary'] === String(option.value) }"
>
<radio
:value="String(option.value)"
:checked="selectedValues['salary'] === String(option.value)"
/>
<text class="option-label">{{ option.label }}</text>
</label>
</radio-group>
</view>
<!-- 学历要求 --> <!-- 学历要求 -->
<view v-if="activeTab === 'education'" class="content-section"> <view v-if="activeTab === 'education'" class="content-section">
<radio-group @change="(e) => handleSelect('education', e)"> <radio-group @change="(e) => handleSelect('education', e)">
@@ -53,24 +80,6 @@
</radio-group> </radio-group>
</view> </view>
<!-- 薪资 -->
<view v-if="activeTab === 'salary'" class="content-section">
<radio-group @change="(e) => handleSelect('salary', e)">
<label
v-for="option in salaryOptions"
:key="option.value"
class="radio-item"
:class="{ checked: selectedValues['salary'] === String(option.value) }"
>
<radio
:value="String(option.value)"
:checked="selectedValues['salary'] === String(option.value)"
/>
<text class="option-label">{{ option.label }}</text>
</label>
</radio-group>
</view>
<!-- 公司规模 --> <!-- 公司规模 -->
<view v-if="activeTab === 'scale'" class="content-section"> <view v-if="activeTab === 'scale'" class="content-section">
<radio-group @change="(e) => handleSelect('scale', e)"> <radio-group @change="(e) => handleSelect('scale', e)">
@@ -89,22 +98,22 @@
</radio-group> </radio-group>
</view> </view>
<!-- 地区 --> <!-- 地区多选 -->
<view v-if="activeTab === 'area'" class="content-section"> <view v-if="activeTab === 'area'" class="content-section">
<radio-group @change="(e) => handleSelect('area', e)"> <checkbox-group @change="(e) => handleCheckboxSelect('area', e)">
<label <label
v-for="option in areaOptions" v-for="option in areaOptions"
:key="option.value" :key="option.value"
class="radio-item" class="radio-item"
:class="{ checked: selectedValues['area'] === String(option.value) }" :class="{ checked: isAreaSelected(String(option.value)) }"
> >
<radio <checkbox
:value="String(option.value)" :value="String(option.value)"
:checked="selectedValues['area'] === String(option.value)" :checked="isAreaSelected(String(option.value))"
/> />
<text class="option-label">{{ option.label }}</text> <text class="option-label">{{ option.label }}</text>
</label> </label>
</radio-group> </checkbox-group>
</view> </view>
<!-- 岗位类型 --> <!-- 岗位类型 -->
@@ -132,6 +141,7 @@
<button class="footer-btn confirm-btn" @click="handleConfirm">确认</button> <button class="footer-btn confirm-btn" @click="handleConfirm">确认</button>
</view> </view>
</view> </view>
</view><!-- end filter-body -->
</view> </view>
</template> </template>
@@ -159,16 +169,16 @@ const getJobTypeData = () => {
// 标签页数据 // 标签页数据
const tabs = [ const tabs = [
{ key: 'salary', label: '薪资' },
{ key: 'education', label: '学历要求' }, { key: 'education', label: '学历要求' },
{ key: 'experience', label: '工作经验' }, { key: 'experience', label: '工作经验' },
{ key: 'salary', label: '薪资' },
{ key: 'scale', label: '公司规模' }, { key: 'scale', label: '公司规模' },
{ key: 'jobType', label: '岗位类型' }, { key: 'jobType', label: '岗位类型' },
{ key: 'area', label: '地区' } { key: 'area', label: '地区' }
]; ];
// 当前激活的标签 // 当前激活的标签
const activeTab = ref('education'); const activeTab = ref('salary');
// 存储已选中的值 // 存储已选中的值
const selectedValues = reactive({ const selectedValues = reactive({
@@ -176,7 +186,7 @@ const selectedValues = reactive({
experience: '', experience: '',
salary: '', salary: '',
scale: '', scale: '',
area: '', area: [],
jobType: '' jobType: ''
}); });
@@ -218,21 +228,60 @@ onBeforeMount(async () => {
} }
}); });
// 处理选项选择 // 处理单选选项选择
const handleSelect = (key, e) => { const handleSelect = (key, e) => {
selectedValues[key] = e.detail.value; selectedValues[key] = e.detail.value;
}; };
// 处理多选选项选择(地区)
// 兼容不同平台H5 可能返回逗号分隔字符串,小程序返回数组
const handleCheckboxSelect = (key, e) => {
let val = e.detail.value;
if (typeof val === 'string') {
selectedValues[key] = val ? val.split(',').map((s) => s.trim()) : [];
} else if (Array.isArray(val)) {
selectedValues[key] = val.map((v) => String(v));
} else {
selectedValues[key] = [];
}
};
// 判断地区选项是否被选中(兼容数组和字符串)
const isAreaSelected = (value) => {
const areaVal = selectedValues.area;
if (Array.isArray(areaVal)) {
return areaVal.includes(value);
}
if (typeof areaVal === 'string' && areaVal) {
return areaVal.split(',').includes(value);
}
return false;
};
// 清除所有选择 // 清除所有选择
const handleClear = () => { const handleClear = () => {
Object.keys(selectedValues).forEach((key) => { Object.keys(selectedValues).forEach((key) => {
if (key === 'area') {
selectedValues[key] = [];
} else {
selectedValues[key] = ''; selectedValues[key] = '';
}
}); });
}; };
// 确认筛选 // 确认筛选
const handleConfirm = () => { const handleConfirm = () => {
const payload = { ...selectedValues }; const payload = { ...selectedValues };
// 将多选的地区数组转为逗号分隔字符串(兼容各种格式)
if (Array.isArray(payload.area) && payload.area.length > 0) {
payload.area = payload.area.map((v) => String(v)).join(',');
} else if (typeof payload.area === 'string' && payload.area) {
// 如果已经是逗号分隔字符串,保持原样
payload.area = payload.area;
} else {
payload.area = '';
}
console.log('[new-filter-page] area value to emit:', payload.area);
// 将选中的薪资区间转换为 minSalary / maxSalary 参数 // 将选中的薪资区间转换为 minSalary / maxSalary 参数
// 始终带上 minSalary / maxSalary含空值便于父组件在清除时移除旧值 // 始终带上 minSalary / maxSalary含空值便于父组件在清除时移除旧值
let minSalary = ''; let minSalary = '';
@@ -269,22 +318,33 @@ const handleClose = () => {
background-color: #fff; background-color: #fff;
z-index: 9999; z-index: 9999;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
} }
.filter-header { .filter-header {
height: 96rpx; height: 96rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
padding: 0 32rpx; padding: 0 32rpx;
border-bottom: 1rpx solid #eee; border-bottom: 1rpx solid #eee;
background-color: #fff; background-color: #fff;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05); flex-shrink: 0;
position: relative;
.back-btn { .header-title {
font-size: 36rpx; font-size: 34rpx;
font-weight: 600;
color: #333;
}
.header-close {
position: absolute;
right: 32rpx;
top: 50%;
transform: translateY(-50%);
width: 48rpx; width: 48rpx;
height: 48rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -295,13 +355,40 @@ const handleClose = () => {
&:active { &:active {
background-color: rgba(37, 107, 250, 0.1); background-color: rgba(37, 107, 250, 0.1);
} }
.close-icon {
position: relative;
width: 28rpx;
height: 28rpx;
&::before,
&::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 4rpx;
height: 28rpx;
border-radius: 2rpx;
background: #5A5A68;
} }
.filter-title { &::before {
font-size: 34rpx; transform: translate(-50%, -50%) rotate(45deg);
font-weight: 600;
color: #333;
} }
&::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
}
}
.filter-body {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
} }
.filter-tabs { .filter-tabs {

View File

@@ -21,14 +21,8 @@
</view> </view>
<view class="card-bottom"> <view class="card-bottom">
<view>{{ job.postingDate }}</view> <view>{{ job.postingDate }}</view>
<view> <view class="card-bottom-right">
<convert-distance <text v-if="job.regionName">地区{{ job.regionName }}</text>
:alat="job.latitude"
:along="job.longitude"
:blat="latitude"
:blong="longitude"
></convert-distance>
<dict-Label class="mar_le10" dictType="area" :value="job.jobLocationAreaCode"></dict-Label>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -40,7 +40,7 @@
<view class="nav-hidden hidden-animation" :class="{ 'hidden-height': shouldHideTop }"> <view class="nav-hidden hidden-animation" :class="{ 'hidden-height': shouldHideTop }">
<view class="container-search" v-if="shouldShowJobSeekerContent"> <view class="container-search" v-if="shouldShowJobSeekerContent">
<view class="search-input button-click" @click="navTo('/pages/search/search')"> <view class="search-input button-click" @click="navToSearch">
<uni-icons class="iconsearch" color="#666666" type="search" size="18"></uni-icons> <uni-icons class="iconsearch" color="#666666" type="search" size="18"></uni-icons>
<text class="inpute">职位名称薪资要求等</text> <text class="inpute">职位名称薪资要求等</text>
</view> </view>
@@ -263,12 +263,17 @@
{{ item.text }} {{ item.text }}
</view> </view>
</view> </view>
<view class="btm-right-wrap">
<view class="button-click clear-btn" v-if="hasFilterConditions" @click="clearFilter">
清除筛选
</view>
<view class="btm-right button-click" @click="openFilter"> <view class="btm-right button-click" @click="openFilter">
筛选 筛选
<image class="right-sx" :class="{ active: showFilter }" src="@/static/icon/shaixun.png"></image> <image class="right-sx" :class="{ active: showFilter }" src="@/static/icon/shaixun.png"></image>
</view> </view>
</view> </view>
</view> </view>
</view>
<view class="table-list"> <view class="table-list">
<scroll-view :scroll-y="true" class="falls-scroll" @scroll="handleScroll" @scrolltolower="scrollBottom" <scroll-view :scroll-y="true" class="falls-scroll" @scroll="handleScroll" @scrolltolower="scrollBottom"
:enable-back-to-top="false" :scroll-with-animation="false"> :enable-back-to-top="false" :scroll-with-animation="false">
@@ -564,6 +569,12 @@ const shouldShowCompanyContent = computed(() => {
return userType === 0; return userType === 0;
}); });
// 判断是否有筛选条件(用于显示/隐藏清除按钮)
const hasFilterConditions = computed(() => {
const searchKeys = Object.keys(pageState.search).filter((k) => k !== 'order' && pageState.search[k]);
return searchKeys.length > 0 || Object.keys(conditionSearch.value).length > 0 || Boolean(selectedCity.value.code);
});
// 判断当前用户是否为招聘者(企业用户) // 判断当前用户是否为招聘者(企业用户)
const isRecruiter = computed(() => { const isRecruiter = computed(() => {
if (!hasLogin.value) { if (!hasLogin.value) {
@@ -836,6 +847,28 @@ onUnmounted(() => {
uni.$off('citySelected'); uni.$off('citySelected');
}); });
const isFourLevelLinkagePurview = ref(false);
const getIsFourLevelLinkagePurview = () => {
let userInfo = uni.getStorageSync('userInfo');
if (userInfo) {
$api.myRequest(
'/auth/login2/ks',
{ userid: userInfo.dwUserid, idcardno: userInfo.idCard },
'POST',
9100,
{}
).then((res) => {
if (res.code == 200) {
uni.setStorageSync('fourLevelLinkage-token', res.data.access_token);
let roleIdList = ['103', '106', '107'];
if (res.data.roleIdList.some((item) => roleIdList.includes(item))) {
isFourLevelLinkagePurview.value = true;
}
}
});
}
};
onShow(() => { onShow(() => {
// 页面显示时获取最新的企业信息 // 页面显示时获取最新的企业信息
getCompanyInfo(); getCompanyInfo();
@@ -1067,6 +1100,38 @@ function openFilter() {
emits('onShowTabbar', false); emits('onShowTabbar', false);
} }
// 跳转到搜索页,并携带筛选条件
function navToSearch() {
const query = {};
// 携带筛选弹窗中选择的地区(确保是逗号分隔字符串)
const areaVal = pageState.search.area;
if (areaVal && String(areaVal)) {
query.area = String(areaVal);
}
// 携带城市选择器中选择的regionCode
if (conditionSearch.value.regionCode) {
query.regionCode = String(conditionSearch.value.regionCode);
}
console.log('[navToSearch] query params:', query);
navTo('/pages/search/search', { query });
}
// 清除所有筛选条件
function clearFilter() {
// 保留 order 排序方式
const currentOrder = pageState.search.order;
pageState.search = { order: currentOrder };
// 清除城市选择
conditionSearch.value = {};
selectedCity.value = { code: '', name: '' };
// 刷新列表
if (state.tabIndex === 'all') {
getJobRecommend('refresh');
} else {
getJobList('refresh');
}
}
function handleNewFilterConfirm(values) { function handleNewFilterConfirm(values) {
pageState.search = { pageState.search = {
...pageState.search, ...pageState.search,
@@ -1415,27 +1480,6 @@ const toggleJobStatus = (job) => {
$api.msg(isCurrentlyUp ? '下架失败,请重试' : '上架失败,请重试'); $api.msg(isCurrentlyUp ? '下架失败,请重试' : '上架失败,请重试');
}); });
}; };
const isFourLevelLinkagePurview = ref(false);
const getIsFourLevelLinkagePurview = () => {
let userInfo = uni.getStorageSync('userInfo');
if (userInfo) {
$api.myRequest(
'/auth/login2/ks',
{ userid: userInfo.dwUserid, idcardno: userInfo.idCard },
'POST',
9100,
{}
).then((res) => {
if (res.code == 200) {
uni.setStorageSync('fourLevelLinkage-token', res.data.access_token);
let roleIdList = ['103', '106', '107'];
if (res.data.roleIdList.some((item) => roleIdList.includes(item))) {
isFourLevelLinkagePurview.value = true;
}
}
});
}
};
function dataToImg(data) { function dataToImg(data) {
const result = data.map((item) => ({ const result = data.map((item) => ({
...item, ...item,
@@ -1993,6 +2037,16 @@ defineExpose({ loadData });
font-weight: 500; font-weight: 500;
font-size: 32rpx; font-size: 32rpx;
color: #256BFA; color: #256BFA;
.btm-right-wrap
display: flex
align-items: center
.clear-btn
font-family: 'PingFangSC-Regular', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 400;
font-size: 28rpx;
color: #999;
margin-right: 20rpx;
white-space: nowrap;
.btm-right .btm-right
font-family: 'PingFangSC-Regular', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'PingFangSC-Regular', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 400; font-weight: 400;

View File

@@ -1,9 +1,16 @@
<template> <template>
<view class="container safe-area-top"> <view class="container safe-area-top">
<!-- 自定义头部搜索框 --> <!-- 自定义头部搜索框 -->
<!-- #ifdef H5 -->
<view class="custom-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="custom-header"> <view class="custom-header">
<!-- #endif -->
<view class="top"> <view class="top">
<!-- <image class="btnback button-click" src="@/static/icon/back.png" @click="navBack"></image> --> <!-- #ifdef H5 -->
<image class="btnback button-click" src="@/static/icon/back.png" @click="navBack"></image>
<!-- #endif -->
<view class="search-box"> <view class="search-box">
<uni-icons <uni-icons
class="iconsearch" class="iconsearch"
@@ -55,6 +62,11 @@ import { storeToRefs } from 'pinia';
import { useColumnCount } from '@/hook/useColumnCount'; import { useColumnCount } from '@/hook/useColumnCount';
import img from '@/static/icon/filter.png'; import img from '@/static/icon/filter.png';
const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore()); const { longitudeVal, latitudeVal } = storeToRefs(useLocationStore());
const statusBarHeight = ref(0);
// #ifdef H5
const sysInfo = uni.getSystemInfoSync();
statusBarHeight.value = sysInfo.statusBarHeight || 0;
// #endif
const searchValue = ref(''); const searchValue = ref('');
const historyList = ref([]); const historyList = ref([]);
const searchParams = ref({}); const searchParams = ref({});
@@ -79,7 +91,16 @@ onLoad((query) => {
if (arr) { if (arr) {
historyList.value = uni.getStorageSync('searchList'); historyList.value = uni.getStorageSync('searchList');
} }
const {job} = query; const {job, area, regionCode} = query;
// 从首页筛选条件传入的地区和区域编码
console.log('[search-page] onLoad query:', JSON.stringify(query));
if (area) {
pageState.search.area = String(area);
console.log('[search-page] set area:', pageState.search.area);
}
if (regionCode) {
pageState.search.regionCode = String(regionCode);
}
if (job) { if (job) {
searchValue.value = job; searchValue.value = job;
searchBtn(); searchBtn();
@@ -161,6 +182,7 @@ function getJobList(type = 'add') {
...pageState.search, ...pageState.search,
jobTitle: searchValue.value, jobTitle: searchValue.value,
}; };
console.log('[search-page] getJobList params:', JSON.stringify(params));
$api.createRequest('/app/job/list', params, 'GET', true).then((resData) => { $api.createRequest('/app/job/list', params, 'GET', true).then((resData) => {
const { rows, total } = resData; const { rows, total } = resData;

View File

@@ -11,6 +11,8 @@ import {
// 静态树 O(1) 超快查询!!!!! // 静态树 O(1) 超快查询!!!!!
let IndustryMap = null let IndustryMap = null
// 复用同一批字典请求,避免 App 启动和页面组件同时初始化时产生竞态。
let dictDataPromise = null
// 构建索引 // 构建索引
function buildIndex(tree) { function buildIndex(tree) {
const map = new Map(); const map = new Map();
@@ -50,9 +52,13 @@ const useDictStore = defineStore("dict", () => {
return data return data
}) })
} }
if (complete.value) return if (complete.value) return Promise.resolve()
if (dictLoading.value) return // App.vue 会在启动时加载字典,页面组件也可能同时加载。
// 不能在请求进行中直接 return否则调用方会把当时的空 state 复制下来。
if (dictDataPromise) return dictDataPromise
dictLoading.value = true dictLoading.value = true
dictDataPromise = (async () => {
try { try {
const [education, experience, area, scale, sex, affiliation, nature, noticeType] = const [education, experience, area, scale, sex, affiliation, nature, noticeType] =
await Promise.all([ await Promise.all([
@@ -89,7 +95,11 @@ const useDictStore = defineStore("dict", () => {
state.noticeType = []; state.noticeType = [];
} finally { } finally {
dictLoading.value = false dictLoading.value = false
dictDataPromise = null
} }
})()
return dictDataPromise
}; };
async function getIndustryDict() { async function getIndustryDict() {