Hi,我是前端人类学(之前叫布兰妮甜)!
在前两篇文章中,我们完成了小程序基础环境的搭建和首页天气展示功能的实现。本文将继续深入开发天气预报小程序的第三个核心功能模块——城市选择页面。
城市选择功能是天气应用中不可或缺的部分,它直接影响用户体验的流畅度和便捷性。一个优秀的城市选择页面应该具备快速搜索、热门城市推荐、定位功能以及清晰的城市列表展示等特性。
通过本文的学习,您将掌握小程序中复杂列表页面的开发技巧,包括:城市搜索功能实现、城市定位、热门城市展示以及历史记录展示和删除历史记录等重要技能。



一、城市选择功能规划与设计

1. 核心功能

  1. 城市搜索功能:支持实时搜索和模糊匹配
  2. 热门城市展示:常用城市快速选择
  3. 定位功能:获取当前所在城市
  4. 选择历史记录:记忆用户最近选择的城市
  5. 清除历史记录:清除历史选择记录

2. 页面布局设计

城市选择页面布局:
- 搜索框
- 当前城市定位
- 热门城市展示
- 历史选择以及清除历史

3. 实现细节与代码示例

页面结构(citySelect.wxml)

<view class="city-select">
	<!-- 搜索框 -->
	<view class="search-bar">
		<input placeholder="输入中文城市名" value="{{searchValue}}" bindinput="handleInputChange" class="search-input" />
		<text wx:if="{{searchValue}}" bindtap="clearSearch" class="clear-btn">×</text>
	</view>
	<!-- 搜索结果 -->
	<view wx:if="{{showResult}}" class="search-result">
		<view wx:for="{{searchResult}}" wx:key="id" bindtap="selectCity" data-city="{{item.name}}" data-id="{{item.id}}" data-adm2="{{item.adm2}}" class="result-item">
			<view class="city-name">{{item.name}}</view>
			<view class="city-adm2">{{item.fullAdm}}</view>
		</view>
		<view wx:if="{{searchResult.length === 0 && !isLoading}}" class="no-result">
			<image src="/assets/images/no-data.webp" mode="aspectFit"></image>
			<view><text>未找到匹配的城市</text></view>
		</view>
	</view>
	<!-- 城市定位 -->
	<view wx:if="{{!showResult && currentCity}}" class="section">
		<view class="section-title">当前定位</view>
		<view class="city-list">
			<view bindtap="selectCurrentCity" class="city-item current-city">
				<text class="icon-location">📍</text>
				<view class="city-info">
					<view wx:if="{{currentCity.adm2}}" class="city-adm2">{{currentCity.adm2}}</view>
					<view class="city-point">·</view>
					<view class="city-name">{{currentCity.name}}</view>
				</view>
			</view>
		</view>
	</view>
	<!-- 热门城市 -->
	<view wx:if="{{!showResult}}" class="section">
		<view class="section-title">热门城市</view>
		<view class="city-list">
			<view wx:for="{{hotCities}}" wx:key="id" bindtap="selectCity" data-city="{{item.name}}" data-adm2="{{item.adm2}}" data-id="{{item.id}}" class="city-item">
				{{item.name}}
			</view>
		</view>
	</view>
	<view wx:if="{{isLoading}}" class="loading">
		<image src="/assets/images/loading.webp" mode="aspectFit"></image>
		<text>搜索中...</text>
	</view>
	<!-- 历史记录 -->
	<view wx:if="{{!showResult && historyCities.length > 0}}" class="section">
		<view class="section-header">
			<view class="section-title">历史选择</view>
			<view bindtap="showClearConfirm" class="clear-history-btn">清除历史</view>
		</view>
		<view class="city-list">
			<view wx:for="{{historyCities}}" wx:key="*this" bindtap="selectCity" data-city="{{item.name}}" data-id="{{item.id}}" class="city-item">
				<view class="city-name">{{item.name}}</view>
				<view wx:if="{{item.adm2}}" class="city-adm2">{{item.adm2}}</view>
			</view>
		</view>
	</view>
	<!-- 清除确认对话框 -->
	<view wx:if="{{showClearDialog}}" class="dialog-mask">
		<view class="clear-dialog">
			<view class="dialog-title">确认清除所有历史记录?</view>
			<view class="dialog-btns">
				<view bindtap="cancelClearHistory" class="dialog-btn cancel-btn">取消</view>
				<view bindtap="confirmClearHistory" class="dialog-btn confirm-btn">确定</view>
			</view>
		</view>
	</view>
</view>

页面样式(citySelect.wxss)

.city-select {
  padding: 20rpx;
}

/* 搜索框 */
.search-bar {
  position: relative;
  margin-bottom: 30rpx;
}
.search-input {
  height: 80rpx;
  background: #f5f5f5;
  border-radius: 40rpx;
  padding: 0 80rpx 0 30rpx;
  font-size: 28rpx;
}
.clear-btn {
  position: absolute;
  right: 30rpx;
  top: 50%;
  transform: translateY(-50%);
  font-size: 40rpx;
  color: #999;
}

/* 搜索结果 */
.search-result {
  background: #fff;
  border-radius: 10rpx;
  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}
.result-item {
  padding: 25rpx 30rpx;
  border-bottom: 1rpx solid #f5f5f5;
}
.no-result {
  padding: 30rpx;
  text-align: center;
  color: #999;
}

.section {
  margin-top: 40rpx;
}
.section-title {
  font-size: 28rpx;
  color: #666;
  margin-bottom: 20rpx;
  padding-left: 10rpx;
}
.city-list {
  display: flex;
  flex-wrap: wrap;
}
.city-item {
  background: #f5f5f5;
  padding: 15rpx 30rpx;
  border-radius: 40rpx;
  margin: 0 20rpx 20rpx 0;
  font-size: 28rpx;
}

/* 城市定位 */
.current-city {
  display: flex;
  align-items: center;
}
.icon-location {
  margin-right: 15rpx;
  font-size: 36rpx;
}
.city-info {
  display: flex;
  flex-direction: row;
  align-items: center;
}
.city-info .city-name {
  margin-right: 15rpx;
}
.city-info .city-point {
  margin: -10rpx 10rpx 0;
  font-size: 50rpx;
}

/* 加载状态 */
.loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 40rpx;
}
.loading image {
  width: 60rpx;
  height: 60rpx;
  margin-bottom: 20rpx;
}

/* 历史记录 */
.section-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20rpx;
}
.clear-history-btn {
  color: #999;
  font-size: 24rpx;
  padding: 8rpx 16rpx;
  border: 1rpx solid #eee;
  border-radius: 30rpx;
}

/* 清除对话框样式 */
.dialog-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 999;
}
.clear-dialog {
  width: 80%;
  background: #fff;
  border-radius: 16rpx;
  overflow: hidden;
  animation: fadeIn 0.3s;
}
.dialog-title {
  padding: 40rpx 30rpx;
  text-align: center;
  font-size: 32rpx;
}
.dialog-btns {
  display: flex;
  border-top: 1rpx solid #f5f5f5;
}
.dialog-btn {
  flex: 1;
  text-align: center;
  padding: 25rpx 0;
  font-size: 30rpx;
}
.cancel-btn {
  border-right: 1rpx solid #f5f5f5;
  color: #666;
}
.confirm-btn {
  color: #07c160;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: scale(0.8);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

业务逻辑(citySelect.js)

const app = getApp()

Page({
	data: {
		searchValue: '', // 搜索框内容
		hotCities: [  // 热门城市列表
			{ name: '北京', adm2: '北京', id: '101010100' },
			{ name: '上海', adm2: '上海', id: '101020100' },
			{ name: '广州', adm2: '广东', id: '101280101' },
			{ name: '深圳', adm2: '广东', id: '101280601' },
			{ name: '杭州', adm2: '浙江', id: '101210101' },
			{ name: '成都', adm2: '四川', id: '101270101' },
			{ name: '武汉', adm2: '湖北', id: '101200101' },
			{ name: '南京', adm2: '江苏', id: '101190101' }
		],
		searchResult: [], // 搜索结果
		historyCities: [], // 历史选择城市
		showResult: false, // 是否显示搜索结果
		showClearDialog: false, // 控制清除确认对话框显示
		isLoading: false, // 加载状态
		currentCity: null, // 当前定位城市
		searchTip: '' // 搜索提示信息
	},
	onLoad() {
		// 从缓存加载历史记录
		this.loadHistoryCities()
		// 权限检查
		this.checkLocationPermission()
		// 获取定位城市
		this.getLocationCity()
	},
	// 加载历史选择城市
	loadHistoryCities() {
		const history = wx.getStorageSync('cityHistory') || []
		// 确保读取的是对象数组
		this.setData({
			historyCities: Array.isArray(history[0]) ? history : history.map(item => {
				if (typeof item === 'string') {
					const match = item.match(/(.+?) \((.+)\)/)
					return match ? { name: match[1], adm2: match[2] } : { name: item }
				}
				return item
			})
		})
	},
	// 检查定位权限
	checkLocationPermission() {
		const that = this
		wx.getSetting({
			success(res) {
				if (!res.authSetting['scope.userLocation']) {
					// 未授权,显示授权提示
					that.showLocationAuthDialog()
				} else {
					// 已授权,开始定位
					that.getLocationCity()
				}
			}
		})
	},
	// 显示定位授权对话框
	showLocationAuthDialog() {
		wx.showModal({
			title: '位置信息权限',
			content: '需要获取您的位置信息来提供当地天气服务',
			confirmText: '去授权',
			cancelText: '手动选择',
			success(res) {
				if (res.confirm) {
					// 打开设置页面
					wx.openSetting({
						success(settingRes) {
							if (settingRes.authSetting['scope.userLocation']) {
								that.getLocationCity()
							}
						}
					})
				}
			}
		})
	},
	// 获取定位城市
	getLocationCity() {
		const that = this
		wx.showLoading({
			title: '定位中...',
		})
		wx.getLocation({
			type: 'gcj02',
			success: function (res) {
				const { longitude, latitude } = res
				that.reverseGeocoder(longitude, latitude)
			},
			fail: function (error) {
				wx.hideLoading()
				console.error('获取位置失败:', error)
				wx.showToast({
					title: '定位失败,请手动选择城市',
					icon: 'none',
					duration: 2000
				})
				that.setData({
					currentCity: null
				})
			}
		})
	},
	// 逆地理编码
	reverseGeocoder(lng, lat) {
		const that = this
		wx.request({
			url: 'https://n657rjvy3a.re.qweatherapi.com/geo/v2/city/lookup',
			data: {
				location: `${lng},${lat}`,
				key: app.globalData.weatherApiKey,
				number: 1,
				lang: 'zh'
			},
			success: function (res) {
				if (res.data.code == 200 && res.data.location && res.data.location.length > 0) {
					const city = res.data.location[0]
					that.setData({
						currentCity: {
							name: city.name,
							adm2: city.adm2,
							id: city.id
						}
					})
					wx.showToast({
						title: '定位成功:' + city.name,
						icon: 'success',
						duration: 1500
					})
				} else {
					wx.showToast({
						title: '定位解析失败',
						icon: 'none'
					})
				}
			},
			fail: function (error) {
				wx.hideLoading()
				console.error('逆地理编码失败:', error)
				wx.showToast({
					title: '定位服务异常',
					icon: 'none'
				})
			}
		})
	},
	// 输入框内容变化(添加防抖)
	handleInputChange: debounce(function (e) {
		const value = e.detail.value.trim()
		this.setData({
			searchValue: value,
			showResult: value.length > 0,
			searchTip: ''
		})
		if (value) {
			this.searchCity(value)
		} else {
			this.setData({ searchResult: [] })
		}
	}, 300),
	// 搜索城市
	searchCity(keyword) {
		this.setData({ isLoading: true })
		// 首先检查是否是省份搜索
		this.handleNormalSearch(keyword)
	},
	// 处理普通搜索
	handleNormalSearch(keyword) {
		const that = this
		wx.request({
			url: 'https://n657rjvy3a.re.qweatherapi.com/geo/v2/city/lookup',
			data: {
				location: keyword,
				key: app.globalData.weatherApiKey,
				number: 20,
				lang: 'zh'
			},
			success: res => {
				console.log(res);
				if (res.data.code == 200 && res.data.location) {
					const processedResults = that.processNormalResults(res.data.location, keyword)
					that.setData({
						searchResult: processedResults,
						isLoading: false
					})
				} else {
					that.setData({
						searchResult: [],
						isLoading: false
					})
					wx.showToast({
						title: '搜索失败,请重试',
						icon: 'none'
					})
				}
			},
			fail: () => {
				that.setData({
					searchResult: [],
					isLoading: false
				})
				wx.showToast({
					title: '网络错误,请检查连接',
					icon: 'none'
				})
			}
		})
	},
	// 处理普通搜索结果
	processNormalResults(locations, keyword) {
		if (!locations || !Array.isArray(locations)) return []
		return locations.filter(item => /[\u4e00-\u9fa5]/.test(item.name)).map(item => ({
			name: item.name,
			id: item.id,
			adm1: item.adm1 || item.adm,
			displayName: item.name // 直接显示城市名称
		})).filter((item, index, self) =>
			index === self.findIndex(i => i.name === item.name)
		).sort((a, b) => {
			const aExactMatch = a.name === keyword
			const bExactMatch = b.name === keyword
			if (aExactMatch && !bExactMatch) return -1
			if (!aExactMatch && bExactMatch) return 1
			return 0
		})
	},
	// 选择城市
	selectCity(e) {
		const cityInfo = {
			name: e.currentTarget.dataset.city,
			adm1: e.currentTarget.dataset.adm1 || '',
			id: e.currentTarget.dataset.id || ''
		}
		// 更新全局当前城市
		app.globalData.currentCity = cityInfo.name
		// 更新历史记录
		this.updateHistory(cityInfo)
		// 返回上一页
		wx.navigateBack()
	},
	// 选择定位城市
	selectCurrentCity() {
		if (this.data.currentCity) {
			this.selectCity({
				currentTarget: {
					dataset: this.data.currentCity
				}
			})
		} else {
			wx.showToast({
				title: '定位失败,请手动选择',
				icon: 'none'
			})
		}
	},
	// 更新历史记录
	updateHistory(cityInfo) {
		let history = wx.getStorageSync('cityHistory') || []
		// 去重(比较城市名)
		history = history.filter(item => item.name !== cityInfo.name)
		// 添加到开头
		history.unshift({
			name: cityInfo.name,
			adm2: cityInfo.adm2,
			id: cityInfo.id
		})
		// 保留最近5个
		history = history.slice(0, 5)
		wx.setStorageSync('cityHistory', history)
		this.setData({ historyCities: history })
	},
	// 显示清除确认对话框
	showClearConfirm() {
		if (this.data.historyCities.length === 0) {
			wx.showToast({
				title: '没有历史记录可清除',
				icon: 'none'
			})
			return
		}
		this.setData({ showClearDialog: true })
	},
	// 确认清除历史记录
	confirmClearHistory() {
		wx.removeStorageSync('cityHistory')
		this.setData({
			historyCities: [],
			showClearDialog: false
		})
		wx.showToast({
			title: '历史记录已清除',
			icon: 'success'
		})
	},
	// 取消清除
	cancelClearHistory() {
		this.setData({ showClearDialog: false })
	},
	// 清空搜索
	clearSearch() {
		this.setData({
			searchValue: '',
			showResult: false,
			searchResult: [],
			searchTip: ''
		})
	}
})
// 防抖函数
function debounce(fn, delay) {
	let timer = null
	return function () {
		const context = this
		const args = arguments
		clearTimeout(timer)
		timer = setTimeout(() => {
			fn.apply(context, args)
		}, delay)
	}
}

二、性能优化策略

  1. 防抖处理:减少搜索API请求频率
  2. 数据缓存:使用本地存储减少重复请求
  3. 结果过滤:对API返回数据进行去重和排序
  4. 条件渲染:只在需要时显示搜索结果列表

三、用户体验优化

  1. 权限引导:友好的定位权限申请流程
  2. 加载状态:搜索时显示加载反馈
  3. 空状态处理:无搜索结果时的提示信息
  4. 历史记录:保存用户选择记录方便再次使用
  5. 错误处理:网络异常或API错误的用户提示

四、关键技术点

  1. 组件通信:使用全局变量(app.globalData)传递城市信息
  2. 页面导航:使用wx.navigateBack()返回上一页
  3. 数据持久化:使用wx.setStorageSync进行本地存储
  4. 权限管理:使用wx.getSetting和wx.openSetting处理权限

五、总结与下一步计划

通过本文,我们完成了天气预报小程序的城市选择开发,实现了:

  • 城市搜索:展示搜索城市
  • 热门城市:常用城市的展示
  • 自动定位:定位到当前所在城市
  • 历史选择:将用户选择过的城市展示
  • 清除历史记录:将用户选择过的城市清除

开发小贴士:

  • 经常使用Ctrl/Cmd + S保存文件
  • 关注控制台的错误提示
  • 使用模拟器的调试功能排查问题

下篇预告: 在下一篇文章中,我将详细介绍10天天气预报页面(forecast)的开发,实现:

  • 预警时间和预警信息的展示
  • 多日天气预报的展示
  • 天气指数分析

如果你觉得文章有用,请点赞👍 + 收藏⭐ + 关注👀 你的支持是我持续创作的最大动力!
📚 系列文章目录
本系列文章将带你从零开始开发一个完整的天气预报小程序,以下是各章节的详细内容:

  • 使用开发者工具创建天气预报项目

  • 首页实现与和风天气API整合

  • 城市选择页面开发实战 (本文)

  • 10天天气预报详情页面实现

  • 设置页面与个性化功能

🔗 相关推荐
有关小程序的更多内容,请看:

更多推荐