1
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"区县列表"`
|
||||
}
|
||||
@@ -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:"页码"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+41
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-41
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/poi': {
|
||||
target: 'http://localhost:3000',
|
||||
target: 'http://localhost:80',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user