This commit is contained in:
2026-07-30 17:26:01 +08:00
parent 153da2dd9a
commit 3d5c0ec39d
14 changed files with 1170 additions and 151 deletions
+8
View File
@@ -15,6 +15,14 @@ func (c *poi) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dto.Searc
return service.PoiService.Search(ctx, req)
}
func (c *poi) Categories(ctx context.Context, _ *dto.CategoriesReq) (res *dto.CategoriesRes, err error) {
return service.CategoryService.GetCategories(ctx)
}
func (c *poi) Districts(ctx context.Context, req *dto.DistrictReq) (res *dto.DistrictRes, err error) {
return service.CategoryService.GetDistricts(ctx, req)
}
func (c *poi) Suggest(ctx context.Context, req *dto.SuggestPoiReq) (res *dto.SuggestPoiRes, err error) {
return service.PoiService.Suggest(ctx, req)
}
+379
View File
@@ -0,0 +1,379 @@
package data
import "pointly-tel/amap/model/dto"
// AllCategories 高德POI行业分类树(大类 + 中类)
var AllCategories = []*dto.Category{
{
Code: "010000", Name: "汽车服务",
Children: []*dto.Category{
{Code: "010100", Name: "加油站"},
{Code: "010200", Name: "加气站"},
{Code: "010300", Name: "充电站"},
{Code: "010400", Name: "停车场"},
{Code: "010500", Name: "洗车场"},
{Code: "010600", Name: "汽车养护"},
{Code: "010700", Name: "汽车租赁"},
{Code: "010800", Name: "汽车保险"},
{Code: "010900", Name: "汽车检测"},
{Code: "011000", Name: "汽车救援"},
{Code: "019900", Name: "其他汽车服务"},
},
},
{
Code: "020000", Name: "汽车销售",
Children: []*dto.Category{
{Code: "020100", Name: "汽车4S店"},
{Code: "020200", Name: "汽车综合店"},
{Code: "020300", Name: "二手车交易"},
{Code: "020400", Name: "汽车配件"},
{Code: "029900", Name: "其他汽车销售"},
},
},
{
Code: "030000", Name: "汽车维修",
Children: []*dto.Category{
{Code: "030100", Name: "综合维修"},
{Code: "030200", Name: "专项维修"},
{Code: "030300", Name: "快修保养"},
{Code: "039900", Name: "其他汽车维修"},
},
},
{
Code: "040000", Name: "摩托车服务",
Children: []*dto.Category{
{Code: "040100", Name: "摩托车销售"},
{Code: "040200", Name: "摩托车维修"},
{Code: "049900", Name: "其他摩托车服务"},
},
},
{
Code: "050000", Name: "餐饮服务",
Children: []*dto.Category{
{Code: "050100", Name: "中餐厅"},
{Code: "050200", Name: "外国餐厅"},
{Code: "050300", Name: "快餐厅"},
{Code: "050400", Name: "火锅店"},
{Code: "050500", Name: "烧烤店"},
{Code: "050600", Name: "海鲜餐厅"},
{Code: "050700", Name: "小吃快餐"},
{Code: "050800", Name: "蛋糕甜品"},
{Code: "050900", Name: "咖啡厅"},
{Code: "051000", Name: "茶艺馆"},
{Code: "051100", Name: "酒吧"},
{Code: "051200", Name: "食堂"},
{Code: "051300", Name: "美食城"},
{Code: "059900", Name: "其他餐饮"},
},
},
{
Code: "060000", Name: "购物服务",
Children: []*dto.Category{
{Code: "060100", Name: "购物中心"},
{Code: "060200", Name: "百货商场"},
{Code: "060300", Name: "超市"},
{Code: "060400", Name: "便利店"},
{Code: "060500", Name: "家居建材"},
{Code: "060600", Name: "数码家电"},
{Code: "060700", Name: "服装鞋帽"},
{Code: "060800", Name: "珠宝首饰"},
{Code: "060900", Name: "化妆品"},
{Code: "061000", Name: "书店"},
{Code: "061100", Name: "花店"},
{Code: "061200", Name: "母婴用品"},
{Code: "061300", Name: "药房"},
{Code: "061400", Name: "文化用品"},
{Code: "061500", Name: "体育用品"},
{Code: "061600", Name: "果蔬生鲜"},
{Code: "061700", Name: "食品烟酒"},
{Code: "061800", Name: "特产店"},
{Code: "061900", Name: "旧货市场"},
{Code: "069900", Name: "其他购物"},
},
},
{
Code: "070000", Name: "生活服务",
Children: []*dto.Category{
{Code: "070100", Name: "美容美发"},
{Code: "070200", Name: "美甲"},
{Code: "070300", Name: "洗浴按摩"},
{Code: "070400", Name: "足疗"},
{Code: "070500", Name: "SPA"},
{Code: "070600", Name: "KTV"},
{Code: "070700", Name: "电影院"},
{Code: "070800", Name: "剧院"},
{Code: "070900", Name: "网吧"},
{Code: "071000", Name: "游戏厅"},
{Code: "071100", Name: "照相馆"},
{Code: "071200", Name: "洗衣店"},
{Code: "071300", Name: "家政服务"},
{Code: "071400", Name: "婚庆服务"},
{Code: "071500", Name: "殡葬服务"},
{Code: "071600", Name: "宠物服务"},
{Code: "071700", Name: "物流快递"},
{Code: "071800", Name: "通讯营业厅"},
{Code: "071900", Name: "邮局"},
{Code: "072000", Name: "律师事务所"},
{Code: "072100", Name: "会计事务所"},
{Code: "072200", Name: "广告公司"},
{Code: "072300", Name: "地产中介"},
{Code: "072400", Name: "人才市场"},
{Code: "072500", Name: "旅行社"},
{Code: "079900", Name: "其他生活服务"},
},
},
{
Code: "080000", Name: "体育休闲服务",
Children: []*dto.Category{
{Code: "080100", Name: "健身中心"},
{Code: "080200", Name: "游泳馆"},
{Code: "080300", Name: "球场"},
{Code: "080400", Name: "滑雪场"},
{Code: "080500", Name: "溜冰场"},
{Code: "080600", Name: "瑜伽"},
{Code: "080700", Name: "台球馆"},
{Code: "080800", Name: "棋牌室"},
{Code: "080900", Name: "密室逃脱"},
{Code: "081000", Name: "游乐场"},
{Code: "081100", Name: "公园"},
{Code: "081200", Name: "钓鱼"},
{Code: "081300", Name: "射击"},
{Code: "081400", Name: "射击"},
{Code: "089900", Name: "其他体育休闲"},
},
},
{
Code: "090000", Name: "医疗保健服务",
Children: []*dto.Category{
{Code: "090100", Name: "综合医院"},
{Code: "090200", Name: "专科医院"},
{Code: "090300", Name: "诊所"},
{Code: "090400", Name: "社区卫生中心"},
{Code: "090500", Name: "药店"},
{Code: "090600", Name: "口腔医院"},
{Code: "090700", Name: "眼科"},
{Code: "090800", Name: "中医"},
{Code: "090900", Name: "宠物医院"},
{Code: "091000", Name: "体检中心"},
{Code: "091100", Name: "疾控中心"},
{Code: "099900", Name: "其他医疗保健"},
},
},
{
Code: "100000", Name: "住宿服务",
Children: []*dto.Category{
{Code: "100100", Name: "酒店"},
{Code: "100200", Name: "宾馆"},
{Code: "100300", Name: "民宿"},
{Code: "100400", Name: "青年旅社"},
{Code: "100500", Name: "公寓"},
{Code: "100600", Name: "招待所"},
{Code: "109900", Name: "其他住宿"},
},
},
{
Code: "110000", Name: "风景名胜",
Children: []*dto.Category{
{Code: "110100", Name: "公园广场"},
{Code: "110200", Name: "景点"},
{Code: "110300", Name: "博物馆"},
{Code: "110400", Name: "纪念馆"},
{Code: "110500", Name: "寺庙教堂"},
{Code: "110600", Name: "自然保护区"},
{Code: "110700", Name: "水上项目"},
{Code: "110800", Name: "海滩"},
{Code: "119900", Name: "其他风景名胜"},
},
},
{
Code: "120000", Name: "商务住宅",
Children: []*dto.Category{
{Code: "120100", Name: "商务大厦"},
{Code: "120200", Name: "写字楼"},
{Code: "120300", Name: "产业园区"},
{Code: "120400", Name: "住宅小区"},
{Code: "120500", Name: "别墅"},
{Code: "120600", Name: "社区中心"},
{Code: "129900", Name: "其他商务住宅"},
},
},
{
Code: "130000", Name: "政府机构及社会团体",
Children: []*dto.Category{
{Code: "130100", Name: "政府机关"},
{Code: "130200", Name: "行政中心"},
{Code: "130300", Name: "公安"},
{Code: "130400", Name: "法院"},
{Code: "130500", Name: "检察院"},
{Code: "130600", Name: "消防"},
{Code: "130700", Name: "工商局"},
{Code: "130800", Name: "税务局"},
{Code: "130900", Name: "海关"},
{Code: "131000", Name: "社会团体"},
{Code: "131100", Name: "街道办"},
{Code: "131200", Name: "居委会"},
{Code: "139900", Name: "其他政府"},
},
},
{
Code: "140000", Name: "科教文化服务",
Children: []*dto.Category{
{Code: "140100", Name: "大学"},
{Code: "140200", Name: "中学"},
{Code: "140300", Name: "小学"},
{Code: "140400", Name: "幼儿园"},
{Code: "140500", Name: "培训机构"},
{Code: "140600", Name: "图书馆"},
{Code: "140700", Name: "文化馆"},
{Code: "140800", Name: "科技馆"},
{Code: "140900", Name: "美术馆"},
{Code: "141000", Name: "展览馆"},
{Code: "141100", Name: "科研机构"},
{Code: "149900", Name: "其他科教文化"},
},
},
{
Code: "150000", Name: "交通设施服务",
Children: []*dto.Category{
{Code: "150100", Name: "机场"},
{Code: "150200", Name: "火车站"},
{Code: "150300", Name: "地铁站"},
{Code: "150400", Name: "公交站"},
{Code: "150500", Name: "长途汽车站"},
{Code: "150600", Name: "码头"},
{Code: "150700", Name: "服务区"},
{Code: "150800", Name: "收费站"},
{Code: "150900", Name: "停车场"},
{Code: "159900", Name: "其他交通设施"},
},
},
{
Code: "160000", Name: "金融保险服务",
Children: []*dto.Category{
{Code: "160100", Name: "银行"},
{Code: "160200", Name: "ATM"},
{Code: "160300", Name: "证券公司"},
{Code: "160400", Name: "保险公司"},
{Code: "160500", Name: "信托公司"},
{Code: "160600", Name: "典当行"},
{Code: "169900", Name: "其他金融"},
},
},
{
Code: "170000", Name: "公司企业",
Children: []*dto.Category{
{Code: "170100", Name: "工厂"},
{Code: "170200", Name: "公司"},
{Code: "170300", Name: "园区"},
{Code: "170400", Name: "农林牧渔基地"},
{Code: "179900", Name: "其他企业"},
},
},
{
Code: "180000", Name: "道路附属设施",
Children: []*dto.Category{
{Code: "180100", Name: "高速服务区"},
{Code: "180200", Name: "高速收费站"},
{Code: "180300", Name: "路侧标志"},
{Code: "180400", Name: "路侧设施"},
{Code: "189900", Name: "其他道路附属"},
},
},
{
Code: "190000", Name: "地名地址信息",
Children: []*dto.Category{
{Code: "190100", Name: "行政区划"},
{Code: "190200", Name: "街道"},
{Code: "190300", Name: "门牌号"},
{Code: "190400", Name: "地标"},
{Code: "190500", Name: "自然地名"},
{Code: "199900", Name: "其他地名地址"},
},
},
{
Code: "200000", Name: "公共设施",
Children: []*dto.Category{
{Code: "200100", Name: "公共厕所"},
{Code: "200200", Name: "公用电话"},
{Code: "200300", Name: "紧急避难所"},
{Code: "200400", Name: "报刊亭"},
{Code: "209900", Name: "其他公共设施"},
},
},
{
Code: "210000", Name: "事件活动",
Children: []*dto.Category{
{Code: "210100", Name: "展会"},
{Code: "210200", Name: "赛事"},
{Code: "210300", Name: "演出"},
{Code: "219900", Name: "其他事件活动"},
},
},
{
Code: "220000", Name: "室内设施",
Children: []*dto.Category{
{Code: "220100", Name: "室内车位"},
{Code: "220200", Name: "室内商铺"},
{Code: "229900", Name: "其他室内设施"},
},
},
{
Code: "230000", Name: "通行设施",
Children: []*dto.Category{
{Code: "230100", Name: "高速出入口"},
{Code: "230200", Name: "桥梁"},
{Code: "230300", Name: "隧道"},
{Code: "230400", Name: "环岛"},
{Code: "239900", Name: "其他通行设施"},
},
},
}
// GetChildren 获取指定分类的子分类列表
func GetChildren(code string) []*dto.Category {
for _, c := range AllCategories {
if c.Code == code {
return c.Children
}
}
return nil
}
// GetLeafCodes 获取指定分类下的所有叶子节点编码(中类)
func GetLeafCodes(code string) []string {
if code == "" {
// 返回所有中类编码
var codes []string
for _, c := range AllCategories {
for _, child := range c.Children {
codes = append(codes, child.Code)
}
}
return codes
}
// 查找匹配的大类
for _, c := range AllCategories {
if c.Code == code {
var codes []string
for _, child := range c.Children {
codes = append(codes, child.Code)
}
return codes
}
}
return nil
}
// IsLeafCode 判断是否为中类编码(叶子节点)
func IsLeafCode(code string) bool {
for _, c := range AllCategories {
for _, child := range c.Children {
if child.Code == code {
return true
}
}
}
return false
}
+33
View File
@@ -0,0 +1,33 @@
package dto
import "github.com/gogf/gf/v2/frame/g"
type Category struct {
Code string `json:"code" dc:"行业分类编码"`
Name string `json:"name" dc:"行业分类名称"`
Children []*Category `json:"children" dc:"子分类列表"`
}
type CategoriesReq struct {
g.Meta `path:"/categories" method:"get" tags:"POI检索" summary:"获取行业分类树"`
}
type CategoriesRes struct {
Categories []*Category `json:"categories" dc:"全部分类树"`
}
type DistrictReq struct {
g.Meta `path:"/districts" method:"get" tags:"POI检索" summary:"获取城市区县列表"`
City string `json:"city" dc:"城市名称"`
}
type DistrictItem struct {
Name string `json:"name" dc:"区县名称"`
Longitude string `json:"longitude" dc:"经度"`
Latitude string `json:"latitude" dc:"纬度"`
}
type DistrictRes struct {
City string `json:"city" dc:"城市名称"`
Districts []*DistrictItem `json:"districts" dc:"区县列表"`
}
+3 -2
View File
@@ -4,11 +4,12 @@ import "github.com/gogf/gf/v2/frame/g"
type SearchPoiReq struct {
g.Meta `path:"/search" method:"get" tags:"POI检索" summary:"POI搜索"`
Keywords string `json:"keywords" dc:"搜索关键词(周边搜索时选填"`
Keywords string `json:"keywords" dc:"搜索关键词(和行业分类二选一,或同时使用"`
Types string `json:"types" dc:"行业分类编码(如 050000=餐饮,多个用|分隔,高德详见分类表)"`
City string `json:"city" dc:"城市名称(文本搜索时使用)"`
Longitude float64 `json:"longitude" dc:"中心点经度(周边搜索时使用)"`
Latitude float64 `json:"latitude" dc:"中心点纬度(周边搜索时使用)"`
Radius int `d:"3000" json:"radius" dc:"搜索半径(米,默认3000,最大50000"`
Radius int `d:"1000" json:"radius" dc:"搜索半径(米,默认1000,最大50000"`
Page int `d:"1" json:"page" dc:"页码"`
}
+117
View File
@@ -0,0 +1,117 @@
package service
import (
"context"
"encoding/json"
"strings"
"pointly-tel/amap/data"
"pointly-tel/amap/model/dto"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
type categoryService struct{}
var CategoryService = new(categoryService)
func (s *categoryService) GetCategories(_ context.Context) (*dto.CategoriesRes, error) {
return &dto.CategoriesRes{
Categories: data.AllCategories,
}, nil
}
func (s *categoryService) GetDistricts(ctx context.Context, req *dto.DistrictReq) (res *dto.DistrictRes, err error) {
if req.City == "" {
return nil, gerror.New("城市名称不能为空")
}
apiKeyVar, err := g.Cfg().Get(ctx, "amap.api_key")
if err != nil || apiKeyVar.IsEmpty() {
return nil, gerror.New("请先配置高德地图API Key")
}
apiKey := apiKeyVar.String()
items, err := s.fetchDistricts(ctx, req.City, apiKey)
if err != nil {
return nil, err
}
return &dto.DistrictRes{
City: req.City,
Districts: items,
}, nil
}
// fetchDistricts 递归获取区县数据,处理直辖市虚拟区划(如"北京城区"、"重庆郊县"
func (s *categoryService) fetchDistricts(ctx context.Context, keywords, apiKey string) ([]*dto.DistrictItem, error) {
requestUrl := "https://restapi.amap.com/v3/config/district?keywords=" + keywords + "&subdistrict=1&key=" + apiKey + "&extensions=base"
response, err := g.Client().Get(ctx, requestUrl)
if err != nil {
return nil, gerror.New("高德API请求失败")
}
defer response.Close()
var amapResp struct {
Status string `json:"status"`
Info string `json:"info"`
Districts []struct {
Name string `json:"name"`
Center string `json:"center"`
Districts []struct {
Name string `json:"name"`
Center string `json:"center"`
} `json:"districts"`
} `json:"districts"`
}
if err := json.Unmarshal(response.ReadAll(), &amapResp); err != nil {
return nil, gerror.New("高德API响应解析失败")
}
if amapResp.Status != "1" || len(amapResp.Districts) == 0 {
return nil, gerror.New("未找到该城市信息")
}
city := amapResp.Districts[0]
// 检测所有子级区划是否均为虚拟分组(如"北京城区"、"重庆郊县"
allVirtual := len(city.Districts) > 0
for _, d := range city.Districts {
if !strings.HasSuffix(d.Name, "城区") && !strings.HasSuffix(d.Name, "郊县") {
allVirtual = false
break
}
}
// 如果是虚拟分组,递归查询每个分组获取真实区县
if allVirtual {
var items []*dto.DistrictItem
for _, vd := range city.Districts {
subItems, err := s.fetchDistricts(ctx, vd.Name, apiKey)
if err != nil {
continue
}
items = append(items, subItems...)
}
return items, nil
}
var items []*dto.DistrictItem
for _, d := range city.Districts {
parts := strings.Split(d.Center, ",")
if len(parts) != 2 {
continue
}
items = append(items, &dto.DistrictItem{
Name: d.Name,
Longitude: gconv.String(parts[0]),
Latitude: gconv.String(parts[1]),
})
}
if items == nil {
items = []*dto.DistrictItem{}
}
return items, nil
}
+96 -4
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"pointly-tel/amap/data"
"pointly-tel/amap/model/dto"
"github.com/gogf/gf/v2/errors/gerror"
@@ -69,7 +70,6 @@ func (s *poiService) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dt
}
apiKey := apiKeyVar.String()
// 每页条数固定使用高德API最大值(25条/页),不从客户端传入
offset := maxOffset
page := req.Page
if page < 1 {
@@ -78,6 +78,11 @@ func (s *poiService) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dt
page = maxPage
}
// 按行业分类搜索(拆分子分类以突破单次2000条上限)
if req.Types != "" {
return s.searchByTypes(ctx, apiKey, req, page, offset)
}
if req.Longitude != 0 && req.Latitude != 0 {
return s.searchAround(ctx, apiKey, req, page, offset)
}
@@ -165,6 +170,86 @@ func (s *poiService) searchAround(ctx context.Context, apiKey string, req *dto.S
return s.buildResult(all, page, offset), nil
}
// searchByTypes 按行业分类搜索(拆分子分类独立调用,合并去重)
func (s *poiService) searchByTypes(ctx context.Context, apiKey string, req *dto.SearchPoiReq, page, offset int) (*dto.SearchPoiRes, error) {
const baseUrl = "https://restapi.amap.com/v3/place/text"
// 解析分类编码,展开为中类(叶子节点)
typeCodes := s.expandTypeCodes(req.Types)
if len(typeCodes) == 0 {
return nil, gerror.New("无效的行业分类编码")
}
needTotal := page * offset
all := make([]*dto.PoiItem, 0, needTotal)
seen := map[string]bool{}
for _, tc := range typeCodes {
var noPhone []*amapPoi
// 遍历该子分类所有页面
for ap := 1; ap <= maxPage; ap++ {
if ap > 1 || len(noPhone) > 0 {
time.Sleep(50 * time.Millisecond)
}
pois, _ := s.fetchAmapPage(ctx, apiKey, baseUrl, req.Keywords, tc, req.City, ap, offset)
if len(pois) == 0 {
break
}
for _, p := range pois {
if seen[p.Id] {
continue
}
if string(p.Tel) == "" {
noPhone = append(noPhone, p)
continue
}
seen[p.Id] = true
all = append(all, s.toPoiItem(p))
}
if len(pois) < offset {
break
}
}
// 兜底补全电话
if len(noPhone) > 0 {
extra := s.fallbackPhone(ctx, apiKey, noPhone, req.City, "")
for _, item := range extra {
if !seen[item.PoiId] {
seen[item.PoiId] = true
all = append(all, item)
}
}
}
if len(all) >= needTotal {
break
}
}
return s.buildResult(all, page, offset), nil
}
// expandTypeCodes 将分类编码展开为中类编码列表
func (s *poiService) expandTypeCodes(types string) []string {
if types == "" {
return nil
}
codes := strings.Split(types, "|")
var result []string
for _, code := range codes {
code = strings.TrimSpace(code)
if code == "" {
continue
}
children := data.GetLeafCodes(code)
if len(children) > 0 {
result = append(result, children...)
} else {
result = append(result, code)
}
}
return result
}
// toPoiItem 将高德POI原始数据转换为输出DTO
func (s *poiService) toPoiItem(p *amapPoi) *dto.PoiItem {
lng, lat := parseLocation(p.Location)
@@ -324,7 +409,7 @@ func (s *poiService) Suggest(ctx context.Context, req *dto.SuggestPoiReq) (*dto.
}
func (s *poiService) fetchTextPage(ctx context.Context, apiKey, baseUrl, keywords, city string, page, offset int) ([]*amapPoi, int, error) {
pois, total := s.fetchAmapPage(ctx, apiKey, baseUrl, keywords, city, page, offset)
pois, total := s.fetchAmapPage(ctx, apiKey, baseUrl, keywords, "", city, page, offset)
return pois, total, nil
}
@@ -340,6 +425,7 @@ func (s *poiService) fetchAroundPage(ctx context.Context, apiKey, baseUrl, keywo
params.Set("extensions", "all")
requestUrl := baseUrl + "?" + params.Encode()
g.Log().Info(ctx, "高德POI周边搜索请求", "keywords", keywords, "location", location, "radius", radius, "page", page, "offset", offset)
response, err := g.Client().Get(ctx, requestUrl)
if err != nil {
@@ -365,18 +451,24 @@ func (s *poiService) fetchAroundPage(ctx context.Context, apiKey, baseUrl, keywo
}
// fetchAmapPage 调用高德文本搜索单页接口
func (s *poiService) fetchAmapPage(ctx context.Context, apiKey, baseUrl, keywords, city string, page, offset int) ([]*amapPoi, int) {
func (s *poiService) fetchAmapPage(ctx context.Context, apiKey, baseUrl, keywords, types, city string, page, offset int) ([]*amapPoi, int) {
params := url.Values{}
params.Set("key", apiKey)
params.Set("keywords", keywords)
params.Set("offset", gconv.String(offset))
params.Set("page", gconv.String(page))
params.Set("extensions", "all")
if keywords != "" {
params.Set("keywords", keywords)
}
if types != "" {
params.Set("types", types)
}
if city != "" {
params.Set("city", city)
}
requestUrl := baseUrl + "?" + params.Encode()
g.Log().Info(ctx, "高德POI请求", "keywords", keywords, "types", types, "city", city, "page", page, "offset", offset)
response, err := g.Client().Get(ctx, requestUrl)
if err != nil {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pointly-Tel - 高德POI商户查询</title>
<script type="module" crossorigin src="/assets/index-Pf7o_o9I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChpioidU.css">
<script type="module" crossorigin src="/assets/index-6-9Ls5rA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BERqyvQy.css">
</head>
<body>
<div id="app"></div>
+150 -98
View File
@@ -34,14 +34,8 @@
<span v-if="item.city" class="suggest-city">{{ item.city }}</span>
</li>
</ul>
<select v-model="province" class="select province-select" @change="provinceChanged">
<option value="">所有省份</option>
<option v-for="p in provinces" :key="p" :value="p">{{ p }}</option>
</select>
<select v-model="city" class="select city-select" :disabled="!province">
<option value="">{{ province ? '所有城市' : '请先选省份' }}</option>
<option v-for="c in cities" :key="c" :value="c">{{ c }}</option>
</select>
<CascaderSelect v-model="regionValues" :treeData="regionTree" placeholder="全部地区" :lazyLoad="loadDistricts" @change="onRegionChange" />
<CascaderSelect v-model="categoryValues" :treeData="categoryTree" placeholder="全部行业" />
<select v-model="radius" class="select radius-select">
<option v-for="r in radiusOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
@@ -108,13 +102,13 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { searchPoi, suggestPoi } from './api/poi.js'
import { ref, computed, watch, onMounted } from 'vue'
import { searchPoi, suggestPoi, fetchCategories, fetchDistricts } from './api/poi.js'
import PoiMap from './components/PoiMap.vue'
import CascaderSelect from './components/CascaderSelect.vue'
const mapRef = ref(null)
const keywords = ref('')
const city = ref('')
const radius = ref(1000)
const centerLat = ref(0)
const centerLng = ref(0)
@@ -130,7 +124,28 @@ const suggestions = ref([])
const suggestIndex = ref(-1)
let suggestTimer = null
const province = ref('')
// 三级地区选择(级联选择器)
const regionValues = ref([])
const regionTree = ref([])
// 行业分类(级联选择器)
const categories = ref([])
const categoryValues = ref([])
const categoryTree = computed(() => {
return (categories.value || []).map(cat => ({
value: cat.code,
label: cat.name,
children: (cat.children || []).map(sub => ({
value: sub.code,
label: sub.name,
}))
}))
})
const searchTypes = computed(() => {
const vals = categoryValues.value
if (vals.length === 0) return ""
return vals[vals.length - 1]
})
const provinces = [
'北京', '天津', '上海', '重庆',
'广东', '浙江', '江苏', '福建', '山东', '四川', '湖北', '湖南',
@@ -173,8 +188,6 @@ const regions = {
'海南': ['海口', '三亚', '儋州', '三沙', '琼海', '文昌', '万宁', '陵水', '五指山'],
}
const cities = computed(() => regions[province.value] || [])
const radiusOptions = [
{ value: 1000, label: '1km' },
]
@@ -281,34 +294,86 @@ const cityCoords = {
'五指山': [18.7750, 109.5170],
}
// 选中城市时地图跳转
watch(city, (val, oldVal) => {
if (!val || val === oldVal || !mapRef.value) return
const coord = cityCoords[val]
if (!coord) return
// 省份中心坐标(用于选中省份时地图跳转,使用省会城市坐标)
const provinceCoords = {
'北京': [39.9042, 116.4074], '天津': [39.1252, 117.1908], '上海': [31.2304, 121.4737], '重庆': [29.4316, 106.9123],
'广东': [23.1291, 113.2644], '浙江': [30.2741, 120.1551], '江苏': [32.0603, 118.7969],
'福建': [26.0745, 119.2965], '山东': [36.6512, 116.9972], '四川': [30.5728, 104.0668],
'湖北': [30.5928, 114.3055], '湖南': [28.2282, 112.9388], '河南': [34.7470, 113.6247],
'河北': [38.0423, 114.5145], '安徽': [31.8206, 117.2272], '江西': [28.6829, 115.8582],
'陕西': [34.3416, 108.9398], '山西': [37.8706, 112.5499], '辽宁': [41.8057, 123.4315],
'吉林': [43.8960, 125.3265], '黑龙江': [45.8038, 126.5350], '广西': [22.8170, 108.3665],
'内蒙古': [40.8422, 111.7498], '西藏': [29.6500, 91.1000], '新疆': [43.8256, 87.6168],
'宁夏': [38.4710, 106.2590], '青海': [36.6230, 101.7800], '甘肃': [36.0611, 103.8343],
'贵州': [26.6470, 106.6302], '云南': [25.0389, 102.7183], '海南': [20.0440, 110.3500],
}
// 三级地区选择:任一变动都触发地图跳转
// 构建地区树(省份 → 城市)
function buildRegionTree() {
regionTree.value = provinces.map(prov => ({
value: prov,
label: prov,
children: (regions[prov] || []).map(city => ({
value: city,
label: city,
}))
}))
}
// 懒加载区县数据
async function loadDistricts(node, path) {
if (path.length !== 2) return []
try {
const data = await fetchDistricts(path[1])
return (data || []).map(d => ({
value: d.name,
label: d.name,
latitude: d.latitude,
longitude: d.longitude,
}))
} catch {
return []
}
}
// 地区级联变化 → 地图跳转
function onRegionChange({ values }) {
const map = mapRef.value
if (!map || values.length === 0) return
centerLat.value = 0
centerLng.value = 0
mapRef.value.clearRadiusCircle()
mapRef.value.flyTo(coord[0], coord[1])
})
async function fetchSuggestions(kw) {
if (!kw.trim()) {
suggestions.value = []
return
map.clearRadiusCircle()
if (values.length === 1) {
const coord = provinceCoords[values[0]]
if (coord) map.flyTo(coord[0], coord[1], 11)
} else if (values.length === 2) {
const coord = cityCoords[values[1]]
if (coord) map.flyTo(coord[0], coord[1], 12)
} else if (values.length === 3) {
const pn = regionTree.value.find(p => p.value === values[0])
const cn = pn?.children?.find(c => c.value === values[1])
const dn = cn?.children?.find(d => d.value === values[2])
if (dn && dn.latitude && dn.longitude) {
map.flyTo(parseFloat(dn.latitude), parseFloat(dn.longitude), 13)
}
}
try {
const data = await suggestPoi(kw, city.value)
suggestions.value = (data.tips || []).slice(0, 8)
}
// 搜索建议:关键词输入防抖
function fetchSuggestions(kw) {
if (!kw.trim()) { suggestions.value = []; return }
suggestPoi(kw, regionValues.value[1] || '').then(list => {
suggestions.value = list || []
suggestIndex.value = -1
} catch {
suggestions.value = []
}
}).catch(() => { suggestions.value = []; suggestIndex.value = -1 })
}
function onKeywordInput() {
clearTimeout(suggestTimer)
suggestTimer = setTimeout(() => fetchSuggestions(keywords.value), 200)
const kw = keywords.value.trim()
if (!kw) { suggestions.value = []; return }
suggestTimer = setTimeout(() => fetchSuggestions(kw), 250)
}
function hideSuggestions() {
@@ -317,7 +382,7 @@ function hideSuggestions() {
}
function onSuggestBlur() {
setTimeout(hideSuggestions, 150)
setTimeout(hideSuggestions, 200)
}
function onSuggestArrow(dir) {
@@ -327,108 +392,87 @@ function onSuggestArrow(dir) {
}
function selectSuggestion(item) {
keywords.value = item.name
hideSuggestions()
// 联想位置有坐标则设为搜索中心点
if (item.longitude && item.latitude) {
const lat = parseFloat(item.latitude)
const lng = parseFloat(item.longitude)
if (!isNaN(lat) && !isNaN(lng)) {
centerLat.value = lat
centerLng.value = lng
}
keywords.value = item.name
// 设置中心点为建议 POI 坐标
if (item.latitude && item.longitude) {
centerLat.value = parseFloat(item.latitude)
centerLng.value = parseFloat(item.longitude)
mapRef.value?.setCenter(centerLat.value, centerLng.value, 16)
mapRef.value?.clearRadiusCircle()
}
doSearch()
}
// 主搜索
async function doSearch() {
error.value = ''
// 关键词和坐标必须至少有一个
const kw = keywords.value.trim()
const hasCoord = centerLng.value && centerLat.value
if (!kw && !hasCoord) {
error.value = '请输入关键词或点击地图选择搜索范围'
searched.value = true
return
}
// 搜索前跳转地图到目标区域
if (mapRef.value) {
if (hasCoord) {
mapRef.value.flyTo(centerLat.value, centerLng.value, 14)
mapRef.value.drawRadiusCircle(centerLat.value, centerLng.value, radius.value)
} else if (city.value && cityCoords[city.value]) {
const coord = cityCoords[city.value]
mapRef.value.flyTo(coord[0], coord[1], 12)
}
}
if (loading.value) return
loading.value = true
error.value = ''
searched.value = true
activePoi.value = null
try {
const data = await searchPoi(
kw,
city.value,
page.value,
centerLng.value,
centerLat.value,
radius.value,
)
const opts = {
keywords: keywords.value.trim(),
city: regionValues.value[1] || '',
page: page.value,
longitude: centerLng.value,
latitude: centerLat.value,
radius: radius.value,
types: searchTypes.value,
}
const data = await searchPoi(opts.keywords, opts.city, opts.page, opts.longitude, opts.latitude, opts.radius, opts.types)
results.value = data.list || []
total.value = data.total || 0
maxPage.value = data.maxPage || 1
page.value = data.page || 1
// 在地图上标注
if (mapRef.value && results.value.length > 0) {
mapRef.value.addMarkers(results.value)
maxPage.value = Math.max(1, Math.ceil(total.value / 20))
activePoi.value = null
mapRef.value?.clearMarkers()
mapRef.value?.addMarkers(results.value)
if (opts.latitude && opts.longitude) {
mapRef.value?.drawRadiusCircle(opts.latitude, opts.longitude, opts.radius)
}
} catch (e) {
error.value = e.response?.data?.message || e.message || '请求失败'
error.value = '搜索失败,请稍后重试'
results.value = []
total.value = 0
maxPage.value = 1
} finally {
loading.value = false
}
}
// 聚焦结果项(地图定位 + 高亮)
function focusItem(item) {
activePoi.value = item
if (mapRef.value) {
mapRef.value.focusMarker(item)
if (item.latitude && item.longitude) {
mapRef.value?.flyTo(parseFloat(item.latitude), parseFloat(item.longitude), 16)
}
}
// 标记点击
function onMarkerClick(poi) {
activePoi.value = poi
}
// 地图点击设为中心点
function onMapClick({ lat, lng }) {
centerLat.value = lat
centerLng.value = lng
// 切换为周边搜索时清除城市条件
province.value = ''
city.value = ''
// 地图上画圆圈
if (mapRef.value) {
mapRef.value.drawRadiusCircle(lat, lng, radius.value)
}
regionValues.value = []
mapRef.value?.drawRadiusCircle(lat, lng, radius.value)
}
// 清除中心点
function clearCenter() {
centerLat.value = 0
centerLng.value = 0
if (mapRef.value) {
mapRef.value.clearRadiusCircle()
}
mapRef.value?.clearRadiusCircle()
}
function provinceChanged() {
city.value = ''
}
// 初始化:构建地区树 + 加载行业分类
buildRegionTree()
fetchCategories().then(data => {
categories.value = data || []
}).catch(() => {})
</script>
<style>
@@ -568,6 +612,14 @@ html, body, #app {
max-width: 110px;
}
.cat-select {
max-width: 120px;
}
.subcat-select {
max-width: 130px;
}
.radius-select {
max-width: 90px;
}
+14 -2
View File
@@ -5,8 +5,10 @@ const api = axios.create({
timeout: 30000,
})
export async function searchPoi(keywords, city = '', page = 1, longitude = 0, latitude = 0, radius = 3000) {
const params = { keywords, page }
export async function searchPoi(keywords, city = '', page = 1, longitude = 0, latitude = 0, radius = 1000, types = '') {
const params = { page }
if (keywords) params.keywords = keywords
if (types) params.types = types
if (city) params.city = city
if (longitude) params.longitude = longitude
if (latitude) params.latitude = latitude
@@ -22,3 +24,13 @@ export async function suggestPoi(keywords, city = '') {
const res = await api.get('/poi/suggest', { params })
return res.data.data
}
export async function fetchCategories() {
const res = await api.get('/poi/categories')
return res.data.data.categories
}
export async function fetchDistricts(city) {
const res = await api.get('/poi/districts', { params: { city } })
return res.data.data.districts
}
+325
View File
@@ -0,0 +1,325 @@
<template>
<div class="cascader" ref="wrapper">
<div class="cascader-trigger" :class="{ open: visible }" @click="toggle">
<span class="cascader-label" :class="{ placeholder: !displayText }">{{ displayText || placeholder }}</span>
<span class="cascader-arrow"></span>
</div>
<Teleport to="body">
<div v-if="visible" class="cascader-panel" :style="panelStyle">
<div class="cascader-cols">
<div
v-for="(col, ci) in columns"
:key="ci"
class="cascader-col"
:class="{ active: ci === activeCol }"
>
<div class="cascader-opt all" @click="selectAll(ci)">
全部{{ ci === 0 ? '' : ' ' + (selectedPath[ci-1]?.label || '') }}
</div>
<div
v-for="item in col"
:key="item.value"
class="cascader-opt"
:class="{
selected: selectedPath[ci] && selectedPath[ci].value === item.value,
hasChildren: item.children
}"
@click="selectItem(item, ci)"
>
<span>{{ item.label }}</span>
<span v-if="item.children" class="opt-arrow"></span>
</div>
<div v-if="loadingCol === ci" class="cascader-status">加载中...</div>
<div v-if="!loadingCol && col.length === 0" class="cascader-status">暂无数据</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: '全部' },
treeData: { type: Array, default: () => [] },
lazyLoad: { type: Function, default: null },
})
const emit = defineEmits(['update:modelValue', 'change'])
const wrapper = ref(null)
const visible = ref(false)
const selectedPath = ref([])
const columns = ref([[]])
const activeCol = ref(0)
const loadingCol = ref(-1)
const panelStyle = ref({})
const displayText = computed(() => {
return selectedPath.value.map(n => n.label).join(' / ')
})
function rebuildColumns() {
const cols = []
let current = props.treeData
for (let i = 0; i <= selectedPath.value.length; i++) {
cols.push(current || [])
if (i < selectedPath.value.length) {
const sel = (current || []).find(n => n.value === selectedPath.value[i].value)
current = sel ? (sel.children || []) : []
}
}
columns.value = cols
activeCol.value = cols.length - 1
}
function syncFromValue() {
const val = props.modelValue || []
selectedPath.value = []
if (val.length === 0) {
rebuildColumns()
return
}
let current = props.treeData
let ok = true
for (const v of val) {
const node = (current || []).find(n => n.value === v)
if (!node) { ok = false; break }
selectedPath.value.push(node)
current = node.children
}
if (!ok) selectedPath.value = []
rebuildColumns()
}
watch(() => props.modelValue, syncFromValue, { deep: true })
watch(() => props.treeData, () => {
if (visible.value) {
loadingCol.value = -1
rebuildColumns()
}
}, { deep: true })
watch(visible, async (v) => {
if (v) {
syncFromValue()
await nextTick()
const rect = wrapper.value?.getBoundingClientRect()
if (rect) {
panelStyle.value = {
top: rect.bottom + 'px',
left: rect.left + 'px',
minWidth: Math.max(rect.width, 200) + 'px',
}
}
}
})
function toggle() {
visible.value = !visible.value
}
function hide() {
visible.value = false
loadingCol.value = -1
}
async function selectItem(item, colIdx) {
selectedPath.value = selectedPath.value.slice(0, colIdx)
selectedPath.value.push(item)
if (item.children && item.children.length > 0) {
rebuildColumns()
return
}
if (props.lazyLoad && item.loadable !== false) {
loadingCol.value = colIdx + 1
rebuildColumns()
try {
const children = await props.lazyLoad(item, selectedPath.value.map(n => n.value))
item.children = children || []
} catch {
item.children = []
}
loadingCol.value = -1
if (!visible.value) return
if (item.children.length > 0) {
rebuildColumns()
} else {
emitChange()
hide()
}
return
}
// Leaf node
emitChange()
hide()
}
function selectAll(colIdx) {
selectedPath.value = selectedPath.value.slice(0, colIdx)
emitChange()
hide()
}
function emitChange() {
const values = selectedPath.value.map(n => n.value)
const labels = selectedPath.value.map(n => n.label)
emit('update:modelValue', values)
emit('change', { values, labels })
}
function onClickOutside(e) {
if (!visible.value) return
if (wrapper.value && wrapper.value.contains(e.target)) return
if (e.target.closest('.cascader-panel')) return
hide()
}
onMounted(() => {
document.addEventListener('mousedown', onClickOutside, true)
syncFromValue()
})
onUnmounted(() => {
document.removeEventListener('mousedown', onClickOutside, true)
})
</script>
<style scoped>
.cascader {
position: relative;
display: inline-block;
}
.cascader-trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 9px 10px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 14px;
user-select: none;
transition: border-color 0.2s;
min-width: 100px;
}
.cascader-trigger:hover,
.cascader-trigger.open {
border-color: #1a73e8;
}
.cascader-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #333;
}
.cascader-label.placeholder {
color: #aaa;
}
.cascader-arrow {
font-size: 10px;
color: #999;
transition: transform 0.2s;
flex-shrink: 0;
}
.open .cascader-arrow {
transform: rotate(180deg);
}
</style>
<style>
/* Unscoped so Teleported panel gets these styles */
.cascader-panel {
position: fixed;
z-index: 99999;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.cascader-cols {
display: flex;
max-height: 280px;
}
.cascader-col {
min-width: 140px;
max-height: 280px;
overflow-y: auto;
border-right: 1px solid #f0f0f0;
padding: 4px 0;
}
.cascader-col:last-child {
border-right: none;
}
.cascader-col.active .cascader-opt.selected {
background: #e8f0fe;
color: #1a73e8;
font-weight: 600;
}
.cascader-opt {
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
white-space: nowrap;
transition: background 0.1s;
}
.cascader-opt:hover {
background: #f5f7fa;
}
.cascader-opt.selected {
background: #e8f0fe;
color: #1a73e8;
font-weight: 600;
}
.cascader-opt.all {
border-bottom: 1px solid #f0f0f0;
margin-bottom: 2px;
color: #999;
font-size: 12px;
}
.cascader-opt.all:hover {
color: #1a73e8;
}
.opt-arrow {
font-size: 16px;
color: #bbb;
flex-shrink: 0;
}
.cascader-status {
padding: 20px 14px;
text-align: center;
color: #999;
font-size: 12px;
}
</style>
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/poi': {
target: 'http://localhost:3000',
target: 'http://localhost:80',
changeOrigin: true,
},
},