Просмотр исходного кода

✨ feat(home): 新增省市区选择器组件并优化首页布局

- 新增 `AreaPicker` 弹出层组件,支持省市区三级联动选择
- 重构首页布局,从多行平铺改为紧凑的单行布局(出发地 + 交换按钮 + 目的地)
- 移除原有的 `AreaCascader` 和 `LocationSearch` 组件,简化首页交互
- 更新查询参数格式,使用 `startAreaIds`/`endAreaIds` 替代具体地点选择
- 在活动选择页面集成精确地点选择功能

♻️ refactor(activity): 调整活动选择页面数据流

- 将精确地点选择从首页移至活动选择页面
- 更新页面参数传递,使用省市区ID数组替代具体地点ID
- 添加地点选择验证,确保用户选择具体地点后才能查看活动

📝 docs(story): 更新开发文档和任务清单

- 添加省市区选择器优化实现细节
- 更新组件设计和布局优化说明
- 完善数据流调整和交互流程描述

🔧 chore(config): 优化构建配置和开发工具

- 添加 `pxtransform` 配置支持响应式单位转换
- 在 `package.json` 中添加 `typecheck` 脚本用于TypeScript类型检查
- 更新Claude配置,添加新的构建命令支持

🐛 fix(api): 修复省市区API类型定义和数据获取

- 修正查询参数Schema中的类型定义,使用泛型语法
- 集成真实的 `AreaService` 替代模拟数据
- 实现基于数据库的真实省市区数据查询
yourname 3 месяцев назад
Родитель
Сommit
50c0986c16

+ 2 - 1
.claude/settings.local.json

@@ -49,7 +49,8 @@
       "Bash(pnpm run db:seed)",
       "Bash(pnpm build:*)",
       "Bash(npx tsc:*)",
-      "Bash(pnpm build:weapp:*)"
+      "Bash(pnpm build:weapp:*)",
+      "Bash(pnpm run build:weapp:*)"
     ],
     "deny": [],
     "ask": []

+ 56 - 0
docs/stories/005.002.story.md

@@ -74,6 +74,34 @@ Ready for Review
     - [x] 首页查询 → 活动选择 → 班次列表完整流程
     - [x] 省市区三级联动功能测试
     - [x] 路线类型动态判断逻辑测试
+- [ ] 优化首页出发地目的地选择器布局 (UI/UX优化)
+  - [ ] 创建弹出层选择器组件 `AreaPicker.tsx`
+    - [ ] 组件位置:`mini/src/components/AreaPicker.tsx`
+    - [ ] 功能:省市区三级联动选择器,模仿mini-demo的 `picker mode="multiSelector"` 交互
+    - [ ] 集成现有省市区API:`/api/v1/areas/provinces`, `/api/v1/areas/cities`, `/api/v1/areas/districts`
+    - [ ] 支持弹出层显示/隐藏控制
+    - [ ] 支持省市区数据动态加载(省份 → 城市 → 区县)
+    - [ ] 支持选择确认和取消操作
+    - [ ] 显示已选择的省市区文本
+  - [ ] 更新首页布局和交互逻辑
+    - [ ] 移除现有的平铺 `AreaCascader` 和 `LocationSearch` 组件
+    - [ ] 添加出发地/目的地选择按钮,点击触发弹出层
+    - [ ] 布局优化:从当前的多行平铺改为一行紧凑布局(出发地 + 交换按钮 + 目的地)
+    - [ ] 显示已选择的省市区文本(如"北京市朝阳区")
+    - [ ] 添加交换出发地和目的地功能
+  - [ ] 简化首页查询逻辑
+    - [ ] 修改 `SearchParams` 接口:移除 `startLocation`/`endLocation`,添加 `startAreaIds`/`endAreaIds`
+    - [ ] 更新查询验证:只需验证省市区选择,不需要精确地点
+    - [ ] 修改导航参数:传递省市区ID数组而不是地点ID
+    - [ ] 更新活动选择页面:接收省市区参数,在该页面进行精确地点选择
+  - [ ] 更新相关页面和组件
+    - [ ] 更新 `ActivitySelectPage.tsx`:接收省市区参数,在该页面集成精确地点选择
+    - [ ] 更新 `ScheduleListPage.tsx`:适配新的查询参数格式
+    - [ ] 更新API调用:修改查询参数格式
+  - [ ] 编写组件单元测试和集成测试
+    - [ ] `AreaPicker` 组件单元测试:测试弹出层交互、数据加载、选择确认
+    - [ ] 首页集成测试:测试完整的省市区选择流程
+    - [ ] 更新E2E测试:验证新的交互流程
 
 ## Dev Notes
 
@@ -173,6 +201,34 @@ Ready for Review
 - **数据集成**: 将模拟数据替换为真实的后端API调用
 - **组件重构**: 保持原有UI体验,但使用项目标准的shadcn/ui组件库
 
+### 首页省市区选择器优化实现细节
+**组件设计**:
+- `AreaPicker.tsx` 组件采用弹出层模式,模仿小程序原生 `picker` 组件的交互体验
+- 支持省市区三级联动,数据从现有省市区API动态加载
+- 弹出层包含:标题栏、三级选择器(省份/城市/区县)、确认/取消按钮
+
+**布局优化**:
+- 当前布局:4行(出发地区 + 出发地点 + 目的地区 + 目的地点)
+- 优化后布局:1行(出发地选择按钮 + 交换按钮 + 目的地选择按钮)
+- 空间占用减少约75%,布局更加紧凑
+- 模仿mini-demo的 `.route-selector` 布局:`display: flex; align-items: center; gap: 16rpx;`
+
+**数据流调整**:
+- 首页只传递省市区ID数组:`startAreaIds`, `endAreaIds`
+- 精确地点选择移至活动选择页面,在该页面集成 `LocationSearch` 组件
+- 查询参数格式:`startAreaIds=[provinceId,cityId,districtId]`
+
+**交互流程**:
+1. 用户点击出发地/目的地按钮 → 弹出 `AreaPicker` 选择省市区
+2. 选择完成后显示省市区文本(如"北京市朝阳区")
+3. 点击查询按钮 → 导航到活动选择页面,传递省市区参数
+4. 在活动选择页面进行精确地点选择
+
+**API集成**:
+- 使用现有省市区API:`/api/v1/areas/provinces`, `/api/v1/areas/cities`, `/api/v1/areas/districts`
+- 数据加载策略:懒加载(省份 → 城市 → 区县)
+- 错误处理:网络错误、数据为空等场景
+
 ### mini-demo页面结构分析
 **首页 (home/home)**:
 - 轮播海报展示 [Source: mini-demo/pages/home/home.wxml#L4] - **MVP限制:使用固定的一张静态图片,不从API获取轮播图数组**

+ 16 - 1
mini/config/index.ts

@@ -15,7 +15,7 @@ const getEnv = () => {
   return process.env.NODE_ENV || 'development'
 }
 
-export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
+export default defineConfig<'webpack5'>(async (merge) => {
   // 动态生成输出目录:dist/平台/环境
   const platform = getPlatform()
   const env = getEnv()
@@ -106,6 +106,21 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
             namingPattern: 'module', // 转换模式,取值为 global/module
             generateScopedName: '[name]__[local]___[hash:base64:5]'
           }
+        },
+        pxtransform: {
+          enable: true,
+          config: {
+            // onePxTransform: true,
+            // unitPrecision: 5,
+            // propList: ['*'],
+            // selectorBlackList: [],
+            // replace: true,
+            // mediaQuery: false,
+            // minPixelValue: 0,
+            baseFontSize: 14,
+            maxRootSize: 14,
+            minRootSize: 14
+          }
         }
       },
       webpackChain(chain) {

+ 2 - 1
mini/package.json

@@ -35,7 +35,8 @@
     "test:watch": "jest --watch",
     "test:coverage": "jest --coverage",
     "test:components": "jest tests/components",
-    "test:pages": "jest tests/pages"
+    "test:pages": "jest tests/pages",
+    "typecheck": "tsc --noEmit --project ."
   },
   "browserslist": {
     "development": [

+ 272 - 0
mini/src/components/AreaPicker.tsx

@@ -0,0 +1,272 @@
+import React, { useState, useEffect } from 'react'
+import { View, Text, Picker, Button } from '@tarojs/components'
+import { useQuery } from '@tanstack/react-query'
+import { areaClient } from '../api'
+
+interface AreaInfo {
+  id: number
+  name: string
+  type: 'province' | 'city' | 'district'
+}
+
+interface AreaPickerProps {
+  visible: boolean
+  onClose: () => void
+  onConfirm: (areaIds: number[], areaInfos: AreaInfo[]) => void
+  value?: number[]
+  title?: string
+}
+
+export const AreaPicker: React.FC<AreaPickerProps> = ({
+  visible,
+  onClose,
+  onConfirm,
+  value = [],
+  title = '选择地区'
+}) => {
+  const [selectedProvince, setSelectedProvince] = useState<number | undefined>(value[0])
+  const [selectedCity, setSelectedCity] = useState<number | undefined>(value[1])
+  const [selectedDistrict, setSelectedDistrict] = useState<number | undefined>(value[2])
+
+  // 获取省份列表
+  const { data: provincesResponse } = useQuery({
+    queryKey: ['areas', 'provinces'],
+    queryFn: async () => {
+      const res = await areaClient.provinces.$get({
+        query: { page: 1, pageSize: 50 }
+      })
+      if (res.status !== 200) throw new Error('获取省份列表失败')
+      return await res.json()
+    }
+  })
+
+  // 获取城市列表
+  const { data: citiesResponse } = useQuery({
+    queryKey: ['areas', 'cities', selectedProvince],
+    queryFn: async () => {
+      if (!selectedProvince) return { success: true, data: { cities: [] }, message: '' }
+      const res = await areaClient.cities.$get({
+        query: { provinceId: selectedProvince, page: 1, pageSize: 50 }
+      })
+      if (res.status !== 200) throw new Error('获取城市列表失败')
+      return await res.json()
+    },
+    enabled: !!selectedProvince
+  })
+
+  // 获取区县列表
+  const { data: districtsResponse } = useQuery({
+    queryKey: ['areas', 'districts', selectedCity],
+    queryFn: async () => {
+      if (!selectedCity) return { success: true, data: { districts: [] }, message: '' }
+      const res = await areaClient.districts.$get({
+        query: { cityId: selectedCity, page: 1, pageSize: 50 }
+      })
+      if (res.status !== 200) throw new Error('获取区县列表失败')
+      return await res.json()
+    },
+    enabled: !!selectedCity
+  })
+
+  // 提取数据
+  const provinces = provincesResponse?.data?.provinces || []
+  const cities = citiesResponse?.data?.cities || []
+  const districts = districtsResponse?.data?.districts || []
+
+  // 初始化选择值 - 只在组件首次显示时初始化
+  const [hasInitialized, setHasInitialized] = useState(false)
+
+  useEffect(() => {
+    if (visible && !hasInitialized) {
+      setSelectedProvince(value[0])
+      setSelectedCity(value[1])
+      setSelectedDistrict(value[2])
+      setHasInitialized(true)
+    } else if (!visible) {
+      setHasInitialized(false)
+    }
+  }, [visible, value, hasInitialized])
+
+  // 处理省份选择
+  const handleProvinceChange = (e: any) => {
+    const provinceIndex = Number(e.detail.value)
+    const selectedProvinceObj = provinces[provinceIndex]
+    if (selectedProvinceObj) {
+      setSelectedProvince(selectedProvinceObj.id)
+      setSelectedCity(undefined)
+      setSelectedDistrict(undefined)
+    }
+  }
+
+  // 处理城市选择
+  const handleCityChange = (e: any) => {
+    const cityIndex = Number(e.detail.value)
+    const selectedCityObj = cities[cityIndex]
+    if (selectedCityObj) {
+      setSelectedCity(selectedCityObj.id)
+      setSelectedDistrict(undefined)
+    }
+  }
+
+  // 处理区县选择
+  const handleDistrictChange = (e: any) => {
+    const districtIndex = Number(e.detail.value)
+    const selectedDistrictObj = districts[districtIndex]
+    if (selectedDistrictObj) {
+      setSelectedDistrict(selectedDistrictObj.id)
+    }
+  }
+
+  // 确认选择
+  const handleConfirm = () => {
+    const areaIds: number[] = []
+    const areaInfos: AreaInfo[] = []
+
+    if (selectedProvince) {
+      const province = provinces.find(p => p.id === selectedProvince)
+      if (province) {
+        areaIds.push(selectedProvince)
+        areaInfos.push({ id: province.id, name: province.name, type: 'province' })
+      }
+    }
+
+    if (selectedCity) {
+      const city = cities.find(c => c.id === selectedCity)
+      if (city) {
+        areaIds.push(selectedCity)
+        areaInfos.push({ id: city.id, name: city.name, type: 'city' })
+      }
+    }
+
+    if (selectedDistrict) {
+      const district = districts.find(d => d.id === selectedDistrict)
+      if (district) {
+        areaIds.push(selectedDistrict)
+        areaInfos.push({ id: district.id, name: district.name, type: 'district' })
+      }
+    }
+
+    onConfirm(areaIds, areaInfos)
+    onClose()
+  }
+
+  // 取消选择
+  const handleCancel = () => {
+    onClose()
+  }
+
+  // 获取显示文本
+  const getDisplayText = () => {
+    if (!selectedProvince) return '请选择省市区'
+
+    const province = provinces.find(p => p.id === selectedProvince)
+    const city = cities.find(c => c.id === selectedCity)
+    const district = districts.find(d => d.id === selectedDistrict)
+
+    if (district && city && province) {
+      return `${province.name} ${city.name} ${district.name}`
+    } else if (city && province) {
+      return `${province.name} ${city.name}`
+    } else if (province) {
+      return province.name
+    }
+
+    return '请选择省市区'
+  }
+
+  if (!visible) return null
+
+  return (
+    <View className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+      <View className="bg-white rounded-lg w-4/5 max-w-md">
+        {/* 标题栏 */}
+        <View className="flex justify-between items-center p-4 border-b border-gray-200">
+          <Text className="text-lg font-bold text-gray-800">{title}</Text>
+        </View>
+
+        {/* 选择器区域 */}
+        <View className="p-4">
+          {/* 当前选择显示 */}
+          <View className="mb-4 p-3 bg-gray-50 rounded-lg">
+            <Text className="text-sm text-gray-600">已选择:</Text>
+            <Text className="text-sm font-medium text-blue-600">{getDisplayText()}</Text>
+          </View>
+
+          {/* 三级选择器 */}
+          <View className="flex space-x-2 mb-4">
+            {/* 省份选择器 */}
+            <View className="flex-1">
+              <Text className="text-sm text-gray-600 mb-1 block">省份</Text>
+              <Picker
+                mode="selector"
+                range={provinces}
+                rangeKey="name"
+                value={selectedProvince ? provinces.findIndex(p => p.id === selectedProvince) : -1}
+                onChange={handleProvinceChange}
+              >
+                <View className="border border-gray-300 rounded px-3 py-2 text-sm bg-white">
+                  {selectedProvince ? provinces.find(p => p.id === selectedProvince)?.name : '请选择省份'}
+                </View>
+              </Picker>
+            </View>
+
+            {/* 城市选择器 */}
+            <View className="flex-1">
+              <Text className="text-sm text-gray-600 mb-1 block">城市</Text>
+              <Picker
+                mode="selector"
+                range={cities}
+                rangeKey="name"
+                value={selectedCity ? cities.findIndex(c => c.id === selectedCity) : -1}
+                onChange={handleCityChange}
+                disabled={!selectedProvince}
+              >
+                <View className={`border border-gray-300 rounded px-3 py-2 text-sm ${
+                  !selectedProvince ? 'bg-gray-100 text-gray-400' : 'bg-white'
+                }`}>
+                  {selectedCity ? cities.find(c => c.id === selectedCity)?.name : '请选择城市'}
+                </View>
+              </Picker>
+            </View>
+
+            {/* 区县选择器 */}
+            <View className="flex-1">
+              <Text className="text-sm text-gray-600 mb-1 block">区县</Text>
+              <Picker
+                mode="selector"
+                range={districts}
+                rangeKey="name"
+                value={selectedDistrict ? districts.findIndex(d => d.id === selectedDistrict) : -1}
+                onChange={handleDistrictChange}
+                disabled={!selectedCity}
+              >
+                <View className={`border border-gray-300 rounded px-3 py-2 text-sm ${
+                  !selectedCity ? 'bg-gray-100 text-gray-400' : 'bg-white'
+                }`}>
+                  {selectedDistrict ? districts.find(d => d.id === selectedDistrict)?.name : '请选择区县'}
+                </View>
+              </Picker>
+            </View>
+          </View>
+        </View>
+
+        {/* 按钮区域 */}
+        <View className="flex border-t border-gray-200">
+          <Button
+            className="flex-1 py-3 text-gray-600 bg-white border-none rounded-none"
+            onClick={handleCancel}
+          >
+            取消
+          </Button>
+          <View className="w-px bg-gray-200"></View>
+          <Button
+            className="flex-1 py-3 text-blue-500 bg-white border-none rounded-none font-bold"
+            onClick={handleConfirm}
+          >
+            确定
+          </Button>
+        </View>
+      </View>
+    </View>
+  )
+}

+ 95 - 103
mini/src/pages/home/index.tsx

@@ -1,34 +1,32 @@
-import React, { useState } from 'react'
+import React, { useState, useEffect } from 'react'
 import { View, Text, Swiper, SwiperItem, Image, Button } from '@tarojs/components'
 import { navigateTo } from '@tarojs/taro'
 import { TabBarLayout } from '@/layouts/tab-bar-layout'
-import { AreaCascader } from '../../components/AreaCascader'
-import { LocationSearch } from '../../components/LocationSearch'
+import { AreaPicker } from '../../components/AreaPicker'
 import banner1 from '../../../images/banner1.jpg'
 
-interface Location {
-  id: number
-  name: string
-  province?: string
-  city?: string
-  district?: string
-  address?: string
-}
 
 interface SearchParams {
   startAreaIds?: number[]
   endAreaIds?: number[]
-  startLocation?: Location | null
-  endLocation?: Location | null
   date: string
   vehicleType: string
 }
 
+interface AreaInfo {
+  id: number
+  name: string
+  type: 'province' | 'city' | 'district'
+}
+
 const HomePage: React.FC = () => {
   const [searchParams, setSearchParams] = useState<SearchParams>({
     date: new Date().toISOString().split('T')[0],
     vehicleType: 'bus'
   })
+  const [areaPickerVisible, setAreaPickerVisible] = useState(false)
+  const [currentPickerType, setCurrentPickerType] = useState<'start' | 'end'>('start')
+  const [areaData, setAreaData] = useState<AreaInfo[]>([])
 
   // 出行方式选项
   const vehicleTypes = [
@@ -47,39 +45,17 @@ const HomePage: React.FC = () => {
     }
   ]
 
-  // 处理出发地区选择
-  const handleStartAreaChange = (areaIds: number[]) => {
-    setSearchParams(prev => ({
-      ...prev,
-      startAreaIds: areaIds,
-      startLocation: null // 重置具体地点
-    }))
-  }
-
-  // 处理目的地区选择
-  const handleEndAreaChange = (areaIds: number[]) => {
-    setSearchParams(prev => ({
-      ...prev,
-      endAreaIds: areaIds,
-      endLocation: null // 重置具体地点
-    }))
+  // 打开地区选择器
+  const openAreaPicker = (type: 'start' | 'end') => {
+    setCurrentPickerType(type)
+    setAreaPickerVisible(true)
   }
 
-  // 处理出发地点选择
-  const handleStartLocationChange = (location: Location | null) => {
-    setSearchParams(prev => ({
-      ...prev,
-      startLocation: location
-    }))
+  // 关闭地区选择器
+  const closeAreaPicker = () => {
+    setAreaPickerVisible(false)
   }
 
-  // 处理目的地点选择
-  const handleEndLocationChange = (location: Location | null) => {
-    setSearchParams(prev => ({
-      ...prev,
-      endLocation: location
-    }))
-  }
 
   // 处理日期选择
   const handleDateChange = (e: any) => {
@@ -102,31 +78,64 @@ const HomePage: React.FC = () => {
     setSearchParams(prev => ({
       ...prev,
       startAreaIds: prev.endAreaIds,
-      endAreaIds: prev.startAreaIds,
-      startLocation: prev.endLocation,
-      endLocation: prev.startLocation
+      endAreaIds: prev.startAreaIds
     }))
   }
 
   // 查询路线
   const handleSearch = () => {
     // 验证必填字段
-    if (!searchParams.startLocation?.id || !searchParams.endLocation?.id) {
+    if (!searchParams.startAreaIds?.length || !searchParams.endAreaIds?.length) {
       // 这里可以添加错误提示
-      console.log('请选择完整的出发地和目的地')
+      console.log('请选择出发地和目的地')
       return
     }
 
     // 导航到活动选择页面
     navigateTo({
       url: `/pages/select-activity/ActivitySelectPage?` +
-        `startLocationId=${searchParams.startLocation.id}&` +
-        `endLocationId=${searchParams.endLocation.id}&` +
+        `startAreaIds=${JSON.stringify(searchParams.startAreaIds)}&` +
+        `endAreaIds=${JSON.stringify(searchParams.endAreaIds)}&` +
         `date=${searchParams.date}&` +
         `vehicleType=${searchParams.vehicleType}`
     })
   }
 
+  // 获取地区显示文本
+  const getAreaDisplayText = (areaIds?: number[]) => {
+    if (!areaIds || areaIds.length === 0) return '请选择地区'
+
+    // 根据areaIds获取地区名称
+    const areaNames = areaIds.map(id => {
+      const area = areaData.find(a => a.id === id)
+      return area ? area.name : '未知地区'
+    })
+
+    return areaNames.join(' ') || '请选择地区'
+  }
+
+  // 当地区选择确认时,存储地区数据
+  const handleAreaConfirm = (areaIds: number[], areaInfos: AreaInfo[]) => {
+    // 更新地区数据
+    setAreaData(prev => {
+      const filtered = prev.filter(a => !areaIds.includes(a.id))
+      return [...filtered, ...areaInfos]
+    })
+
+    if (currentPickerType === 'start') {
+      setSearchParams(prev => ({
+        ...prev,
+        startAreaIds: areaIds
+      }))
+    } else {
+      setSearchParams(prev => ({
+        ...prev,
+        endAreaIds: areaIds
+      }))
+    }
+    closeAreaPicker()
+  }
+
   return (
     <TabBarLayout activeKey="home" className="bg-gradient-to-b from-blue-500 to-blue-600">
       {/* 顶部轮播图 */}
@@ -183,66 +192,40 @@ const HomePage: React.FC = () => {
 
       {/* 出行选择区域 */}
       <View className="mx-4 mt-4 bg-white/95 rounded-xl p-4 shadow-lg">
-        {/* 出发地和目的地选择 */}
-        <View className="space-y-4">
-          {/* 出发地区选择 */}
-          <View>
-            <Text className="text-sm text-gray-600 mb-2 block">出发地区</Text>
-            <AreaCascader
-              value={searchParams.startAreaIds}
-              onChange={handleStartAreaChange}
-              placeholder="请选择出发地区"
-            />
-          </View>
-
-          {/* 出发地点搜索 */}
-          <View>
-            <Text className="text-sm text-gray-600 mb-2 block">出发地点</Text>
-            <LocationSearch
-              value={searchParams.startLocation}
-              onChange={handleStartLocationChange}
-              placeholder="搜索出发地点"
-              areaFilter={{
-                provinceId: searchParams.startAreaIds?.[0],
-                cityId: searchParams.startAreaIds?.[1],
-                districtId: searchParams.startAreaIds?.[2]
-              }}
-            />
-          </View>
-
-          {/* 交换按钮 */}
-          <View className="flex justify-center">
+        {/* 出发地和目的地选择 - 紧凑布局 */}
+        <View className="flex items-center gap-4 mb-4">
+          {/* 出发地选择按钮 */}
+          <View className="flex-1">
             <Button
-              className="bg-blue-500 text-white rounded-full w-12 h-12 flex items-center justify-center shadow-md"
-              onClick={handleSwapLocations}
+              className="bg-gray-50 border border-gray-300 rounded-lg p-3 text-left w-full"
+              onClick={() => openAreaPicker('start')}
             >
-              <Text className="text-lg font-bold">⇄</Text>
+              <Text className="text-sm text-gray-600 block">出发地</Text>
+              <Text className="text-sm font-medium text-gray-800 block mt-1">
+                {getAreaDisplayText(searchParams.startAreaIds)}
+              </Text>
             </Button>
           </View>
 
-          {/* 目的地区选择 */}
-          <View>
-            <Text className="text-sm text-gray-600 mb-2 block">目的地区</Text>
-            <AreaCascader
-              value={searchParams.endAreaIds}
-              onChange={handleEndAreaChange}
-              placeholder="请选择目的地区"
-            />
-          </View>
+          {/* 交换按钮 */}
+          <Button
+            className="bg-blue-500 text-white rounded-full w-10 h-10 flex items-center justify-center shadow-md"
+            onClick={handleSwapLocations}
+          >
+            <Text className="text-lg font-bold">⇄</Text>
+          </Button>
 
-          {/* 目的地点搜索 */}
-          <View>
-            <Text className="text-sm text-gray-600 mb-2 block">目的地点</Text>
-            <LocationSearch
-              value={searchParams.endLocation}
-              onChange={handleEndLocationChange}
-              placeholder="搜索目的地点"
-              areaFilter={{
-                provinceId: searchParams.endAreaIds?.[0],
-                cityId: searchParams.endAreaIds?.[1],
-                districtId: searchParams.endAreaIds?.[2]
-              }}
-            />
+          {/* 目的地选择按钮 */}
+          <View className="flex-1">
+            <Button
+              className="bg-gray-50 border border-gray-300 rounded-lg p-3 text-left w-full"
+              onClick={() => openAreaPicker('end')}
+            >
+              <Text className="text-sm text-gray-600 block">目的地</Text>
+              <Text className="text-sm font-medium text-gray-800 block mt-1">
+                {getAreaDisplayText(searchParams.endAreaIds)}
+              </Text>
+            </Button>
           </View>
         </View>
 
@@ -277,6 +260,15 @@ const HomePage: React.FC = () => {
           更多功能正在开发中...
         </Text>
       </View>
+
+      {/* 地区选择器弹出层 */}
+      <AreaPicker
+        visible={areaPickerVisible}
+        onClose={closeAreaPicker}
+        onConfirm={handleAreaConfirm}
+        value={currentPickerType === 'start' ? searchParams.startAreaIds : searchParams.endAreaIds}
+        title={currentPickerType === 'start' ? '选择出发地' : '选择目的地'}
+      />
     </TabBarLayout>
   )
 }

+ 87 - 19
mini/src/pages/select-activity/ActivitySelectPage.tsx

@@ -3,6 +3,7 @@ import { View, Text, Image, ScrollView } from '@tarojs/components'
 import { useRouter, navigateTo } from '@tarojs/taro'
 import { useQuery } from '@tanstack/react-query'
 import { routeClient } from '../../api'
+import { LocationSearch } from '../../components/LocationSearch'
 
 interface Activity {
   id: number
@@ -38,26 +39,41 @@ interface Route {
   routeType: 'departure' | 'return'
 }
 
+interface Location {
+  id: number
+  name: string
+  province?: string
+  city?: string
+  district?: string
+  address?: string
+}
+
 export const ActivitySelectPage: React.FC = () => {
   const router = useRouter()
   const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null)
+  const [startLocation, setStartLocation] = useState<Location | null>(null)
+  const [endLocation, setEndLocation] = useState<Location | null>(null)
 
   // 从路由参数获取查询条件
   const searchParams = {
-    startLocationId: Number(router.params.startLocationId),
-    endLocationId: Number(router.params.endLocationId),
+    startAreaIds: router.params.startAreaIds ? JSON.parse(router.params.startAreaIds) as number[] : [],
+    endAreaIds: router.params.endAreaIds ? JSON.parse(router.params.endAreaIds) as number[] : [],
     date: router.params.date || new Date().toISOString().split('T')[0],
     vehicleType: router.params.vehicleType || 'bus'
   }
 
   // 查询路线和关联活动
   const { data: routes = [], isLoading } = useQuery({
-    queryKey: ['routes', 'search', searchParams],
+    queryKey: ['routes', 'search', searchParams, startLocation, endLocation],
     queryFn: async () => {
+      if (!startLocation?.id || !endLocation?.id) {
+        return []
+      }
+
       const res = await routeClient.search.$get({
         query: {
-          startLocationId: searchParams.startLocationId,
-          endLocationId: searchParams.endLocationId,
+          startLocationId: startLocation.id,
+          endLocationId: endLocation.id,
           date: searchParams.date,
           routeType: 'all',
           sortBy: 'departureTime',
@@ -67,7 +83,7 @@ export const ActivitySelectPage: React.FC = () => {
       if (res.status !== 200) throw new Error('查询路线失败')
       return await res.json()
     },
-    enabled: !!searchParams.startLocationId && !!searchParams.endLocationId
+    enabled: !!startLocation?.id && !!endLocation?.id
   })
 
   // 分离去程和返程活动
@@ -89,11 +105,16 @@ export const ActivitySelectPage: React.FC = () => {
   const handleSelectActivity = (activity: Activity, routeType: 'departure' | 'return') => {
     setSelectedActivity(activity)
 
+    if (!startLocation?.id || !endLocation?.id) {
+      console.log('请先选择出发地和目的地')
+      return
+    }
+
     // 导航到班次列表页面
     navigateTo({
       url: `/pages/schedule-list/ScheduleListPage?` +
-        `startLocationId=${searchParams.startLocationId}&` +
-        `endLocationId=${searchParams.endLocationId}&` +
+        `startLocationId=${startLocation.id}&` +
+        `endLocationId=${endLocation.id}&` +
         `date=${searchParams.date}&` +
         `vehicleType=${searchParams.vehicleType}&` +
         `activityId=${activity.id}&` +
@@ -118,12 +139,9 @@ export const ActivitySelectPage: React.FC = () => {
 
   // 获取路线信息显示
   const getRouteInfo = () => {
-    if (routes.length === 0) return { fromCity: '', toCity: '' }
-
-    const firstRoute = routes[0] as Route
     return {
-      fromCity: firstRoute.startLocation.name,
-      toCity: firstRoute.endLocation.name
+      fromCity: startLocation?.name || '出发地',
+      toCity: endLocation?.name || '目的地'
     }
   }
 
@@ -149,14 +167,62 @@ export const ActivitySelectPage: React.FC = () => {
         </Text>
       </View>
 
+      {/* 地点选择区域 */}
+      <View className="bg-white p-4 border-b border-gray-200">
+        <Text className="text-sm font-medium text-gray-700 mb-2 block">
+          请选择具体地点
+        </Text>
+        <View className="space-y-3">
+          {/* 出发地点选择 */}
+          <View>
+            <Text className="text-sm text-gray-600 mb-1 block">出发地点</Text>
+            <LocationSearch
+              value={startLocation}
+              onChange={setStartLocation}
+              placeholder="搜索出发地点"
+              areaFilter={{
+                provinceId: searchParams.startAreaIds?.[0],
+                cityId: searchParams.startAreaIds?.[1],
+                districtId: searchParams.startAreaIds?.[2]
+              }}
+            />
+          </View>
+
+          {/* 目的地点选择 */}
+          <View>
+            <Text className="text-sm text-gray-600 mb-1 block">目的地点</Text>
+            <LocationSearch
+              value={endLocation}
+              onChange={setEndLocation}
+              placeholder="搜索目的地点"
+              areaFilter={{
+                provinceId: searchParams.endAreaIds?.[0],
+                cityId: searchParams.endAreaIds?.[1],
+                districtId: searchParams.endAreaIds?.[2]
+              }}
+            />
+          </View>
+        </View>
+      </View>
+
       <ScrollView className="flex-1">
         <View className="p-4">
-          <Text className="text-lg font-bold text-gray-800 mb-2 block">
-            选择观看活动
-          </Text>
-          <Text className="text-sm text-gray-500 mb-4 block">
-            系统已为您自动匹配出发地和目的地的热门活动
-          </Text>
+          {!startLocation?.id || !endLocation?.id ? (
+            <View className="bg-white rounded-lg border border-gray-200 p-8 text-center">
+              <Text className="text-4xl mb-4">📍</Text>
+              <Text className="text-lg text-gray-600 block mb-2">请先选择出发地和目的地</Text>
+              <Text className="text-sm text-gray-500">
+                在上方搜索框中选择具体的出发地和目的地
+              </Text>
+            </View>
+          ) : (
+            <>
+              <Text className="text-lg font-bold text-gray-800 mb-2 block">
+                选择观看活动
+              </Text>
+              <Text className="text-sm text-gray-500 mb-4 block">
+                系统已为您自动匹配出发地和目的地的热门活动
+              </Text>
 
           {/* 去程活动区域 */}
           <View className="mb-6">
@@ -292,6 +358,8 @@ export const ActivitySelectPage: React.FC = () => {
               </Text>
             </View>
           )}
+            </>
+          )}
         </View>
       </ScrollView>
     </View>

+ 24 - 66
src/server/api/areas/index.ts

@@ -1,13 +1,15 @@
 import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
 import { z } from '@hono/zod-openapi';
+import { AreaService } from '@/server/modules/areas/area.service';
+import { AreaLevel } from '@/server/modules/areas/area.entity';
 
 // 省份查询参数Schema
 const getProvincesSchema = z.object({
-  page: z.coerce.number().int().min(1).default(1).openapi({
+  page: z.coerce.number<number>().int().min(1).default(1).openapi({
     example: 1,
     description: '页码'
   }),
-  pageSize: z.coerce.number().int().min(1).max(100).default(50).openapi({
+  pageSize: z.coerce.number<number>().int().min(1).max(100).default(50).openapi({
     example: 50,
     description: '每页数量'
   })
@@ -15,15 +17,15 @@ const getProvincesSchema = z.object({
 
 // 城市查询参数Schema
 const getCitiesSchema = z.object({
-  provinceId: z.coerce.number().int().positive('省份ID必须为正整数').openapi({
+  provinceId: z.coerce.number<number>().int().positive('省份ID必须为正整数').openapi({
     example: 1,
     description: '省份ID'
   }),
-  page: z.coerce.number().int().min(1).default(1).openapi({
+  page: z.coerce.number<number>().int().min(1).default(1).openapi({
     example: 1,
     description: '页码'
   }),
-  pageSize: z.coerce.number().int().min(1).max(100).default(50).openapi({
+  pageSize: z.coerce.number<number>().int().min(1).max(100).default(50).openapi({
     example: 50,
     description: '每页数量'
   })
@@ -31,15 +33,15 @@ const getCitiesSchema = z.object({
 
 // 区县查询参数Schema
 const getDistrictsSchema = z.object({
-  cityId: z.coerce.number().int().positive('城市ID必须为正整数').openapi({
+  cityId: z.coerce.number<number>().int().positive('城市ID必须为正整数').openapi({
     example: 34,
     description: '城市ID'
   }),
-  page: z.coerce.number().int().min(1).default(1).openapi({
+  page: z.coerce.number<number>().int().min(1).default(1).openapi({
     example: 1,
     description: '页码'
   }),
-  pageSize: z.coerce.number().int().min(1).max(100).default(50).openapi({
+  pageSize: z.coerce.number<number>().int().min(1).max(100).default(50).openapi({
     example: 50,
     description: '每页数量'
   })
@@ -184,25 +186,15 @@ const app = new OpenAPIHono()
   .openapi(getProvincesRoute, async (c) => {
     try {
       const { page, pageSize } = c.req.valid('query');
+      const areaService = new AreaService();
 
-      // 模拟省份数据
-      const mockProvinces = [
-        { id: 1, name: '北京市', code: '110000', level: 1, parentId: null },
-        { id: 2, name: '天津市', code: '120000', level: 1, parentId: null },
-        { id: 3, name: '河北省', code: '130000', level: 1, parentId: null },
-        { id: 4, name: '山西省', code: '140000', level: 1, parentId: null },
-        { id: 5, name: '内蒙古自治区', code: '150000', level: 1, parentId: null },
-        { id: 6, name: '辽宁省', code: '210000', level: 1, parentId: null },
-        { id: 7, name: '吉林省', code: '220000', level: 1, parentId: null },
-        { id: 8, name: '黑龙江省', code: '230000', level: 1, parentId: null },
-        { id: 9, name: '上海市', code: '310000', level: 1, parentId: null },
-        { id: 10, name: '江苏省', code: '320000', level: 1, parentId: null }
-      ];
+      // 获取所有省份数据
+      const provinces = await areaService.getAreaTreeByLevel(AreaLevel.PROVINCE);
 
       // 分页
       const startIndex = (page - 1) * pageSize;
       const endIndex = startIndex + pageSize;
-      const paginatedProvinces = mockProvinces.slice(startIndex, endIndex);
+      const paginatedProvinces = provinces.slice(startIndex, endIndex);
 
       return c.json({
         success: true,
@@ -211,8 +203,8 @@ const app = new OpenAPIHono()
           pagination: {
             page,
             pageSize,
-            total: mockProvinces.length,
-            totalPages: Math.ceil(mockProvinces.length / pageSize)
+            total: provinces.length,
+            totalPages: Math.ceil(provinces.length / pageSize)
           }
         },
         message: '获取省份列表成功'
@@ -228,23 +220,11 @@ const app = new OpenAPIHono()
   .openapi(getCitiesRoute, async (c) => {
     try {
       const { provinceId, page, pageSize } = c.req.valid('query');
+      const areaService = new AreaService();
 
-      // 模拟城市数据(基于省份ID)
-      const mockCities = {
-        1: [ // 北京市
-          { id: 11, name: '北京市', code: '110100', level: 2, parentId: 1 }
-        ],
-        9: [ // 上海市
-          { id: 31, name: '上海市', code: '310100', level: 2, parentId: 9 }
-        ],
-        10: [ // 江苏省
-          { id: 32, name: '南京市', code: '320100', level: 2, parentId: 10 },
-          { id: 33, name: '苏州市', code: '320500', level: 2, parentId: 10 },
-          { id: 34, name: '无锡市', code: '320200', level: 2, parentId: 10 }
-        ]
-      };
-
-      const cities = mockCities[provinceId] || [];
+      // 获取指定省份下的所有城市
+      const allCities = await areaService.getAreaTreeByLevel(AreaLevel.CITY);
+      const cities = allCities.filter(city => city.parentId === provinceId);
 
       // 分页
       const startIndex = (page - 1) * pageSize;
@@ -275,33 +255,11 @@ const app = new OpenAPIHono()
   .openapi(getDistrictsRoute, async (c) => {
     try {
       const { cityId, page, pageSize } = c.req.valid('query');
+      const areaService = new AreaService();
 
-      // 模拟区县数据(基于城市ID)
-      const mockDistricts = {
-        11: [ // 北京市
-          { id: 110101, name: '东城区', code: '110101', level: 3, parentId: 11 },
-          { id: 110102, name: '西城区', code: '110102', level: 3, parentId: 11 },
-          { id: 110105, name: '朝阳区', code: '110105', level: 3, parentId: 11 },
-          { id: 110106, name: '丰台区', code: '110106', level: 3, parentId: 11 },
-          { id: 110107, name: '石景山区', code: '110107', level: 3, parentId: 11 }
-        ],
-        31: [ // 上海市
-          { id: 310101, name: '黄浦区', code: '310101', level: 3, parentId: 31 },
-          { id: 310104, name: '徐汇区', code: '310104', level: 3, parentId: 31 },
-          { id: 310105, name: '长宁区', code: '310105', level: 3, parentId: 31 },
-          { id: 310106, name: '静安区', code: '310106', level: 3, parentId: 31 },
-          { id: 310107, name: '普陀区', code: '310107', level: 3, parentId: 31 }
-        ],
-        34: [ // 无锡市
-          { id: 320202, name: '梁溪区', code: '320202', level: 3, parentId: 34 },
-          { id: 320205, name: '锡山区', code: '320205', level: 3, parentId: 34 },
-          { id: 320206, name: '惠山区', code: '320206', level: 3, parentId: 34 },
-          { id: 320211, name: '滨湖区', code: '320211', level: 3, parentId: 34 },
-          { id: 320214, name: '新吴区', code: '320214', level: 3, parentId: 34 }
-        ]
-      };
-
-      const districts = mockDistricts[cityId] || [];
+      // 获取指定城市下的所有区县
+      const allDistricts = await areaService.getAreaTreeByLevel(AreaLevel.DISTRICT);
+      const districts = allDistricts.filter(district => district.parentId === cityId);
 
       // 分页
       const startIndex = (page - 1) * pageSize;