commit 1bf109ca6d64fee7c903dae095184bf676e6ec55 Author: Ethanfly Date: Fri Jan 9 18:52:32 2026 +0800 first diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3885e1 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# 会面点 Meeting Point + +一个帮助多人寻找最佳聚会地点的地图应用。输入多个参与者的位置,自动计算几何中心,并在中心点附近搜索咖啡馆、餐厅、KTV 等聚会场所。 + +![Preview](preview.png) + +## ✨ 功能特点 + +- **📍 多点位置设置** + - 通过地址搜索添加位置 + - 直接在地图上点击添加位置 + - 支持输入提示和自动补全 + +- **🎯 智能中心计算** + - 使用球面几何算法计算多点中心 + - 准确计算地球曲面上的几何中心 + +- **🔍 周边搜索** + - 支持自定义搜索关键词 + - 可调节搜索半径(500米-10公里) + - 预设常用场所类型(咖啡馆、餐厅、KTV等) + +- **🗺️ 地图可视化** + - 深色主题地图 + - 清晰的标记和信息展示 + - 搜索范围可视化 + +## 🚀 快速开始 + +### 前置要求 + +- Go 1.21+ +- 高德地图开发者账号和 Web 服务 API Key + +### 获取高德地图 API Key + +1. 访问 [高德开放平台](https://lbs.amap.com/) +2. 注册/登录开发者账号 +3. 进入「控制台」→「应用管理」→「创建新应用」 +4. 添加 Key,选择「Web 服务」类型 +5. 复制生成的 Key + +### 安装步骤 + +1. 克隆项目 +```bash +git clone https://github.com/yourusername/meeting-point.git +cd meeting-point +``` + +2. 安装依赖 +```bash +go mod download +``` + +3. 配置 API Key + +复制配置文件模板并填入你的 Key: +```bash +cp config.example.json config.json +``` + +编辑 `config.json`: +```json +{ + "amap_key": "你的高德地图API_Key", + "port": "8080" +} +``` + +或者使用环境变量: +```bash +export AMAP_KEY="你的高德地图API_Key" +export PORT="8080" +``` + +4. 启动服务 +```bash +go run main.go +``` + +5. 访问应用 + +打开浏览器访问 http://localhost:8080 + +## 📖 使用说明 + +### 添加位置 + +有两种方式添加参与者位置: + +1. **搜索添加**:在左侧搜索框中输入地址或地点名称,从下拉列表中选择 +2. **点击添加**:直接在地图上点击想要添加的位置 + +### 搜索聚会地点 + +1. 添加至少 2 个位置点 +2. 在「搜索类型」中输入或选择想要查找的场所类型 +3. 调整搜索半径 +4. 点击「搜索最佳会面点」按钮 + +### 查看结果 + +- 地图上会显示计算出的中心点(金色星星标记) +- 搜索到的场所会以绿色标记显示 +- 左侧列表会显示详细信息,点击可在地图上定位 + +## 🏗️ 技术架构 + +### 后端 + +- **Go** - 高性能后端语言 +- **Gin** - Web 框架 +- **高德地图 Web API** - 地理编码、POI 搜索 + +### 前端 + +- **原生 JavaScript** - 无框架依赖 +- **高德地图 JS API** - 地图展示和交互 +- **CSS3** - 现代化 UI 设计 + +### API 端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | 主页面 | +| GET | `/api/config` | 获取配置 | +| POST | `/api/geocode` | 地址转坐标 | +| POST | `/api/center` | 计算中心点 | +| POST | `/api/search` | 搜索周边 POI | +| GET | `/api/tips` | 输入提示 | + +## 📁 项目结构 + +``` +meeting-point/ +├── main.go # 后端主程序 +├── go.mod # Go 模块文件 +├── go.sum # 依赖锁定文件 +├── config.json # 配置文件(需创建) +├── config.example.json # 配置文件模板 +├── README.md # 项目说明 +├── templates/ +│ └── index.html # 前端页面模板 +└── static/ + ├── css/ + │ └── style.css # 样式文件 + └── js/ + └── app.js # 前端逻辑 +``` + +## 🔧 配置说明 + +### config.json + +```json +{ + "amap_key": "高德地图API Key", + "port": "服务端口,默认8080" +} +``` + +### 环境变量 + +- `AMAP_KEY` - 高德地图 API Key(优先级高于配置文件) +- `PORT` - 服务端口 + +## 📝 开发计划 + +- [ ] 添加路线规划功能 +- [ ] 支持更多地图服务商 +- [ ] 添加位置分享功能 +- [ ] 移动端适配优化 +- [ ] 添加历史记录功能 + +## 📄 许可证 + +MIT License + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..29c036e --- /dev/null +++ b/config.example.json @@ -0,0 +1,6 @@ +{ + "amap_key": "YOUR_WEB_SERVICE_API_KEY", + "amap_js_key": "YOUR_JS_API_KEY", + "amap_js_secret": "YOUR_JS_API_SECURITY_CODE", + "port": "8080" +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..8c38458 --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "amap_key": "5b8a9b4a256b1975f986a300bc239db9", + "amap_js_key": "6fd88503bbdb3a57245ab9541d7f22ac", + "amap_js_secret": "3be4b8e3bde18519f59528e5555313a9", + "port": "9876" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8cd1f6c --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module meeting-point + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.9.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a77fa1 --- /dev/null +++ b/go.sum @@ -0,0 +1,86 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..13fea97 --- /dev/null +++ b/main.go @@ -0,0 +1,467 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "math" + "net/http" + "net/url" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +// Config 配置结构 +type Config struct { + AmapKey string `json:"amap_key"` // Web服务API Key + AmapJsKey string `json:"amap_js_key"` // JS API Key + AmapJsSecret string `json:"amap_js_secret"` // JS API 安全密钥 + Port string `json:"port"` +} + +// Coordinate 坐标结构 +type Coordinate struct { + Lng float64 `json:"lng"` + Lat float64 `json:"lat"` +} + +// Location 位置信息 +type Location struct { + Name string `json:"name"` + Address string `json:"address"` + Coordinate Coordinate `json:"coordinate"` +} + +// CenterRequest 计算中心点请求 +type CenterRequest struct { + Locations []Location `json:"locations"` +} + +// SearchRequest 搜索请求 +type SearchRequest struct { + Center Coordinate `json:"center"` + Keywords string `json:"keywords"` + Radius int `json:"radius"` // 搜索半径,单位米 +} + +// POI 兴趣点 +type POI struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Address string `json:"address"` + Location Coordinate `json:"location"` + Distance string `json:"distance"` + Tel string `json:"tel"` +} + +// AmapGeoResponse 高德地理编码响应 +type AmapGeoResponse struct { + Status string `json:"status"` + Info string `json:"info"` + Count string `json:"count"` + Geocodes []struct { + FormattedAddress string `json:"formatted_address"` + Location string `json:"location"` + Level string `json:"level"` + } `json:"geocodes"` +} + +// AmapPOI 高德POI项 +type AmapPOI struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Address interface{} `json:"address"` // 可能是字符串或空数组 + Location string `json:"location"` + Distance string `json:"distance"` + Tel interface{} `json:"tel"` // 可能是字符串或空数组 +} + +// AmapPOIResponse 高德POI搜索响应 +type AmapPOIResponse struct { + Status string `json:"status"` + Info string `json:"info"` + Count string `json:"count"` + Suggestion struct { + Keywords []interface{} `json:"keywords"` + } `json:"suggestion"` + Pois []AmapPOI `json:"pois"` +} + +// AmapTip 高德输入提示项 +type AmapTip struct { + ID interface{} `json:"id"` // 可能是字符串或空数组 + Name string `json:"name"` + District string `json:"district"` + Address interface{} `json:"address"` // 可能是字符串或空数组 + Location interface{} `json:"location"` // 可能是字符串或空数组 +} + +// AmapTipsResponse 高德输入提示响应 +type AmapTipsResponse struct { + Status string `json:"status"` + Info string `json:"info"` + Count string `json:"count"` + Tips []AmapTip `json:"tips"` +} + +var config Config + +func main() { + // 加载配置 + loadConfig() + + // 设置Gin模式 + gin.SetMode(gin.ReleaseMode) + + r := gin.Default() + + // 静态文件服务 + r.Static("/static", "./static") + r.LoadHTMLGlob("templates/*") + + // 路由 + r.GET("/", indexHandler) + r.GET("/api/config", getConfigHandler) + r.POST("/api/geocode", geocodeHandler) + r.POST("/api/center", calculateCenterHandler) + r.POST("/api/search", searchPOIHandler) + r.GET("/api/tips", getTipsHandler) + + port := config.Port + if port == "" { + port = "8080" + } + + log.Printf("服务启动在 http://localhost:%s", port) + r.Run(":" + port) +} + +func loadConfig() { + // 尝试从配置文件加载 + file, err := os.Open("config.json") + if err == nil { + defer file.Close() + decoder := json.NewDecoder(file) + err = decoder.Decode(&config) + if err != nil { + log.Printf("解析配置文件失败: %v", err) + } + } + + // 环境变量覆盖 + if key := os.Getenv("AMAP_KEY"); key != "" { + config.AmapKey = key + } + if port := os.Getenv("PORT"); port != "" { + config.Port = port + } + + if config.Port == "" { + config.Port = "8080" + } +} + +func indexHandler(c *gin.Context) { + c.HTML(http.StatusOK, "index.html", nil) +} + +func getConfigHandler(c *gin.Context) { + // 返回前端需要的配置 + jsKey := config.AmapJsKey + if jsKey == "" { + jsKey = config.AmapKey // 兼容:如果没有单独配置JS Key,使用Web服务Key + } + c.JSON(http.StatusOK, gin.H{ + "amap_key": config.AmapKey, + "amap_js_key": jsKey, + "amap_js_secret": config.AmapJsSecret, + }) +} + +// geocodeHandler 地理编码 - 地址转坐标 +func geocodeHandler(c *gin.Context) { + var req struct { + Address string `json:"address"` + City string `json:"city"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"}) + return + } + + if req.Address == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "地址不能为空"}) + return + } + + // 调用高德地理编码API + apiURL := fmt.Sprintf( + "https://restapi.amap.com/v3/geocode/geo?key=%s&address=%s&city=%s", + config.AmapKey, + url.QueryEscape(req.Address), + url.QueryEscape(req.City), + ) + + resp, err := http.Get(apiURL) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var amapResp AmapGeoResponse + if err := json.Unmarshal(body, &amapResp); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"}) + return + } + + if amapResp.Status != "1" || len(amapResp.Geocodes) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "未找到该地址"}) + return + } + + // 解析坐标 + location := amapResp.Geocodes[0].Location + coords := strings.Split(location, ",") + if len(coords) != 2 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "坐标格式错误"}) + return + } + + var lng, lat float64 + fmt.Sscanf(coords[0], "%f", &lng) + fmt.Sscanf(coords[1], "%f", &lat) + + c.JSON(http.StatusOK, gin.H{ + "location": Location{ + Name: req.Address, + Address: amapResp.Geocodes[0].FormattedAddress, + Coordinate: Coordinate{ + Lng: lng, + Lat: lat, + }, + }, + }) +} + +// calculateCenterHandler 计算多点中心 +func calculateCenterHandler(c *gin.Context) { + var req CenterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"}) + return + } + + if len(req.Locations) < 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "至少需要2个位置点"}) + return + } + + // 计算几何中心(加权平均) + center := calculateGeometricCenter(req.Locations) + + c.JSON(http.StatusOK, gin.H{ + "center": center, + }) +} + +// calculateGeometricCenter 计算几何中心点 +func calculateGeometricCenter(locations []Location) Coordinate { + // 使用球面坐标转换计算更准确的中心点 + var x, y, z float64 + + for _, loc := range locations { + // 转换为弧度 + latRad := loc.Coordinate.Lat * math.Pi / 180 + lngRad := loc.Coordinate.Lng * math.Pi / 180 + + // 转换为笛卡尔坐标 + x += math.Cos(latRad) * math.Cos(lngRad) + y += math.Cos(latRad) * math.Sin(lngRad) + z += math.Sin(latRad) + } + + // 平均值 + n := float64(len(locations)) + x /= n + y /= n + z /= n + + // 转换回经纬度 + lng := math.Atan2(y, x) * 180 / math.Pi + hyp := math.Sqrt(x*x + y*y) + lat := math.Atan2(z, hyp) * 180 / math.Pi + + return Coordinate{ + Lng: lng, + Lat: lat, + } +} + +// searchPOIHandler 搜索周边POI +func searchPOIHandler(c *gin.Context) { + var req SearchRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"}) + return + } + + if req.Keywords == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"}) + return + } + + if req.Radius <= 0 { + req.Radius = 3000 // 默认3公里 + } + + // 调用高德POI搜索API + location := fmt.Sprintf("%f,%f", req.Center.Lng, req.Center.Lat) + apiURL := fmt.Sprintf( + "https://restapi.amap.com/v3/place/around?key=%s&location=%s&keywords=%s&radius=%d&offset=20&page=1&extensions=all", + config.AmapKey, + location, + url.QueryEscape(req.Keywords), + req.Radius, + ) + + resp, err := http.Get(apiURL) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + log.Printf("高德POI搜索响应: %s", string(body)) + + var amapResp AmapPOIResponse + if err := json.Unmarshal(body, &amapResp); err != nil { + log.Printf("解析POI响应失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"}) + return + } + + if amapResp.Status != "1" { + log.Printf("高德POI搜索API返回错误: status=%s, info=%s", amapResp.Status, amapResp.Info) + c.JSON(http.StatusInternalServerError, gin.H{"error": "高德API返回错误: " + amapResp.Info}) + return + } + + // 转换POI数据 + pois := make([]POI, 0, len(amapResp.Pois)) + for _, p := range amapResp.Pois { + coords := strings.Split(p.Location, ",") + if len(coords) != 2 { + continue + } + var lng, lat float64 + fmt.Sscanf(coords[0], "%f", &lng) + fmt.Sscanf(coords[1], "%f", &lat) + + // 处理可能是数组或字符串的字段 + address, _ := p.Address.(string) + tel, _ := p.Tel.(string) + + pois = append(pois, POI{ + ID: p.ID, + Name: p.Name, + Type: p.Type, + Address: address, + Location: Coordinate{ + Lng: lng, + Lat: lat, + }, + Distance: p.Distance, + Tel: tel, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "pois": pois, + "count": len(pois), + }) +} + +// getTipsHandler 输入提示 +func getTipsHandler(c *gin.Context) { + keywords := c.Query("keywords") + city := c.Query("city") + + if keywords == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "关键词不能为空"}) + return + } + + apiURL := fmt.Sprintf( + "https://restapi.amap.com/v3/assistant/inputtips?key=%s&keywords=%s&city=%s&datatype=all", + config.AmapKey, + url.QueryEscape(keywords), + url.QueryEscape(city), + ) + + resp, err := http.Get(apiURL) + if err != nil { + log.Printf("请求高德API失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var amapResp AmapTipsResponse + if err := json.Unmarshal(body, &amapResp); err != nil { + log.Printf("解析响应失败: %v, body: %s", err, string(body)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"}) + return + } + + if amapResp.Status != "1" { + log.Printf("高德API返回错误: status=%s, info=%s", amapResp.Status, amapResp.Info) + c.JSON(http.StatusInternalServerError, gin.H{"error": "高德API返回错误: " + amapResp.Info}) + return + } + + // 过滤有效的提示 + tips := make([]map[string]interface{}, 0) + for _, tip := range amapResp.Tips { + // 解析 location 字段(可能是字符串或空数组) + locationStr, ok := tip.Location.(string) + if !ok || locationStr == "" { + continue // 跳过非字符串类型的 location + } + + coords := strings.Split(locationStr, ",") + if len(coords) != 2 { + continue + } + var lng, lat float64 + fmt.Sscanf(coords[0], "%f", &lng) + fmt.Sscanf(coords[1], "%f", &lat) + + // 解析 address 字段(可能是字符串或数组) + addressStr, _ := tip.Address.(string) + + // 解析 id 字段(可能是字符串或数组) + idStr, _ := tip.ID.(string) + + tips = append(tips, map[string]interface{}{ + "id": idStr, + "name": tip.Name, + "district": tip.District, + "address": addressStr, + "location": Coordinate{Lng: lng, Lat: lat}, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "tips": tips, + }) +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..0d1dde5 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1299 @@ +/* ======================================== + 会面点 - Meeting Point + 现代深色主题设计 + ======================================== */ + +:root { + /* 主色调 - 温暖的琥珀色调 */ + --primary: #f59e0b; + --primary-light: #fbbf24; + --primary-dark: #d97706; + --primary-glow: rgba(245, 158, 11, 0.3); + + /* 背景色系 - 深邃夜空 */ + --bg-dark: #0f0f1a; + --bg-darker: #0a0a12; + --bg-card: #1a1a2e; + --bg-card-hover: #252540; + --bg-elevated: #16213e; + + /* 文字色系 */ + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + /* 边框 */ + --border: #2d2d44; + --border-light: #3d3d5c; + + /* 状态色 */ + --success: #10b981; + --warning: #f59e0b; + --error: #ef4444; + --info: #3b82f6; + + /* 间距 */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* 圆角 */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 24px; + + /* 阴影 */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 20px var(--primary-glow); + + /* 过渡 */ + --transition-fast: 0.15s ease; + --transition-normal: 0.25s ease; + --transition-slow: 0.4s ease; + + /* 侧边栏宽度 */ + --sidebar-width: 420px; +} + +/* ======================================== + 基础样式重置 + ======================================== */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-dark); + color: var(--text-primary); + line-height: 1.6; + overflow: hidden; +} + +input, button, textarea { + font-family: inherit; +} + +ul { + list-style: none; +} + +/* ======================================== + 主布局 + ======================================== */ + +.app-container { + display: flex; + height: 100vh; + width: 100vw; +} + +/* ======================================== + 侧边栏 + ======================================== */ + +.sidebar { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-darker) 100%); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Logo */ +.logo { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-lg); + border-bottom: 1px solid var(--border); + background: var(--bg-darker); +} + +.logo-icon { + width: 48px; + height: 48px; + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-glow); +} + +.logo-icon svg { + width: 28px; + height: 28px; + color: white; +} + +.logo h1 { + font-size: 1.5rem; + font-weight: 700; + background: linear-gradient(135deg, var(--text-primary) 0%, var(--primary-light) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* 侧边栏内容 */ +.sidebar-content { + flex: 1; + overflow-y: auto; + padding: var(--spacing-lg); + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.sidebar-content::-webkit-scrollbar { + width: 6px; +} + +.sidebar-content::-webkit-scrollbar-track { + background: transparent; +} + +.sidebar-content::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.sidebar-content::-webkit-scrollbar-thumb:hover { + background: var(--border-light); +} + +/* ======================================== + 区域样式 + ======================================== */ + +.section { + background: var(--bg-elevated); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + border: 1px solid var(--border); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-md); +} + +.section-header h2 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.badge { + background: var(--primary); + color: var(--bg-dark); + font-size: 0.75rem; + font-weight: 700; + padding: 2px 10px; + border-radius: 20px; + min-width: 24px; + text-align: center; +} + +/* ======================================== + 搜索框 + ======================================== */ + +.search-box { + position: relative; + margin-bottom: var(--spacing-md); +} + +.search-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: var(--spacing-md); + width: 18px; + height: 18px; + color: var(--text-muted); + pointer-events: none; +} + +.search-box input { + width: 100%; + padding: var(--spacing-md) var(--spacing-md) var(--spacing-md) 44px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.95rem; + transition: var(--transition-fast); +} + +.search-box input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-glow); +} + +.search-box input::placeholder { + color: var(--text-muted); +} + +.clear-btn { + position: absolute; + right: var(--spacing-sm); + width: 28px; + height: 28px; + background: var(--bg-card-hover); + border: none; + border-radius: 50%; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + transition: var(--transition-fast); +} + +.clear-btn:hover { + background: var(--border); + color: var(--text-primary); +} + +/* 搜索提示下拉 */ +.search-tips { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + max-height: 300px; + overflow-y: auto; + z-index: 100; + display: none; + box-shadow: var(--shadow-lg); +} + +.search-tips.active { + display: block; +} + +.search-tip-item { + padding: var(--spacing-md); + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: var(--transition-fast); +} + +.search-tip-item:last-child { + border-bottom: none; +} + +.search-tip-item:hover { + background: var(--bg-card-hover); +} + +.search-tip-item .name { + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; +} + +.search-tip-item .address { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* ======================================== + 帮助文本 + ======================================== */ + +.help-text { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + background: rgba(245, 158, 11, 0.1); + border-radius: var(--radius-sm); + border-left: 3px solid var(--primary); +} + +/* ======================================== + 位置列表 + ======================================== */ + +.location-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + max-height: 240px; + overflow-y: auto; +} + +.location-list::-webkit-scrollbar { + width: 4px; +} + +.location-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.location-item { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--bg-card); + border-radius: var(--radius-md); + border: 1px solid var(--border); + transition: var(--transition-fast); + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.location-item:hover { + background: var(--bg-card-hover); + border-color: var(--border-light); +} + +.location-marker { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.85rem; + flex-shrink: 0; +} + +.location-info { + flex: 1; + min-width: 0; +} + +.location-name { + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.location-address { + font-size: 0.8rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.location-remove { + width: 28px; + height: 28px; + background: transparent; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + transition: var(--transition-fast); + flex-shrink: 0; +} + +.location-remove:hover { + background: var(--error); + border-color: var(--error); + color: white; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + color: var(--text-muted); + text-align: center; + gap: var(--spacing-sm); +} + +.empty-icon { + font-size: 2.5rem; + opacity: 0.5; +} + +.empty-hint { + font-size: 0.8rem; + color: var(--text-muted); + opacity: 0.7; +} + +/* ======================================== + 表单组件 + ======================================== */ + +.form-group { + margin-bottom: var(--spacing-lg); +} + +.form-group label { + display: block; + font-size: 0.9rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); +} + +.keywords-input input { + width: 100%; + padding: var(--spacing-md); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.95rem; + transition: var(--transition-fast); +} + +.keywords-input input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-glow); +} + +.keywords-input input::placeholder { + color: var(--text-muted); +} + +/* 快捷标签 */ +.quick-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin-top: var(--spacing-md); +} + +.tag { + padding: var(--spacing-xs) var(--spacing-md); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text-secondary); + font-size: 0.85rem; + cursor: pointer; + transition: var(--transition-fast); +} + +.tag:hover { + background: var(--bg-card-hover); + border-color: var(--primary); + color: var(--primary); +} + +.tag.active { + background: var(--primary); + border-color: var(--primary); + color: var(--bg-dark); +} + +/* 滑块 */ +.radius-slider { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.radius-slider input[type="range"] { + flex: 1; + -webkit-appearance: none; + height: 6px; + background: var(--bg-card); + border-radius: 3px; + outline: none; +} + +.radius-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + background: var(--primary); + border-radius: 50%; + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: var(--transition-fast); +} + +.radius-slider input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.1); + box-shadow: var(--shadow-glow); +} + +.radius-value { + font-size: 0.9rem; + font-weight: 600; + color: var(--primary); + min-width: 60px; + text-align: right; + font-family: 'JetBrains Mono', monospace; +} + +/* ======================================== + 按钮 + ======================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-md) var(--spacing-lg); + border: none; + border-radius: var(--radius-md); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition-fast); +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + color: var(--bg-dark); + box-shadow: var(--shadow-sm); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-glow); +} + +.btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-search { + width: 100%; +} + +.btn-search svg { + width: 18px; + height: 18px; +} + +/* ======================================== + 搜索结果 + ======================================== */ + +.results-section { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.result-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + max-height: 300px; + overflow-y: auto; +} + +.result-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--bg-card); + border-radius: var(--radius-md); + border: 1px solid var(--border); + cursor: pointer; + transition: var(--transition-fast); +} + +.result-item:hover { + background: var(--bg-card-hover); + border-color: var(--primary); + transform: translateX(4px); +} + +.result-item.active { + border-color: var(--primary); + background: rgba(245, 158, 11, 0.1); +} + +.result-rank { + width: 28px; + height: 28px; + background: var(--bg-elevated); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 700; + color: var(--text-muted); + flex-shrink: 0; +} + +.result-item:nth-child(1) .result-rank { + background: linear-gradient(135deg, #ffd700 0%, #ffb800 100%); + color: var(--bg-dark); +} + +.result-item:nth-child(2) .result-rank { + background: linear-gradient(135deg, #c0c0c0 0%, #a0a0a0 100%); + color: var(--bg-dark); +} + +.result-item:nth-child(3) .result-rank { + background: linear-gradient(135deg, #cd7f32 0%, #b87333 100%); + color: var(--bg-dark); +} + +.result-info { + flex: 1; + min-width: 0; +} + +.result-name { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.result-meta { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + font-size: 0.8rem; + color: var(--text-muted); +} + +.result-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +.result-distance { + color: var(--success) !important; + font-weight: 500; +} + +.result-tel { + color: var(--info) !important; +} + +/* ======================================== + 地图区域 + ======================================== */ + +.map-area { + flex: 1; + position: relative; + background: var(--bg-darker); +} + +#mapContainer { + width: 100%; + height: 100%; +} + +/* 中心点信息 */ +.center-info { + position: absolute; + top: var(--spacing-lg); + left: 50%; + transform: translateX(-50%); + z-index: 10; +} + +.center-badge { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--bg-card); + border: 1px solid var(--primary); + border-radius: 30px; + box-shadow: var(--shadow-lg); + animation: bounceIn 0.5s ease; +} + +@keyframes bounceIn { + 0% { + opacity: 0; + transform: translateX(-50%) scale(0.5); + } + 60% { + transform: translateX(-50%) scale(1.1); + } + 100% { + opacity: 1; + transform: translateX(-50%) scale(1); + } +} + +.center-icon { + font-size: 1.2rem; +} + +.center-badge span:last-child { + font-weight: 500; + color: var(--primary); +} + +/* 加载遮罩 */ +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 15, 26, 0.8); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + z-index: 100; + backdrop-filter: blur(4px); +} + +.loading-spinner { + width: 48px; + height: 48px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-overlay span { + color: var(--text-secondary); + font-weight: 500; +} + +/* ======================================== + 地图自定义标记样式 + ======================================== */ + +.custom-marker { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; + color: white; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + border: 3px solid white; + cursor: pointer; + transition: transform 0.2s ease; +} + +.custom-marker:hover { + transform: scale(1.15); +} + +.center-marker { + width: 48px; + height: 48px; + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + box-shadow: 0 0 20px rgba(245, 158, 11, 0.5); + border: 3px solid white; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { + box-shadow: 0 0 20px rgba(245, 158, 11, 0.5); + } + 50% { + box-shadow: 0 0 40px rgba(245, 158, 11, 0.8); + } +} + +.poi-marker { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); + border: 2px solid white; + cursor: pointer; +} + +.poi-marker:hover { + transform: scale(1.2); +} + +/* ======================================== + 信息窗口样式 + ======================================== */ + +.amap-info-content { + padding: 0 !important; + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +.amap-info-sharp { + display: none !important; +} + +.info-window { + padding: var(--spacing-md) var(--spacing-lg); + min-width: 220px; + max-width: 300px; + background: rgba(22, 33, 62, 0.92); + backdrop-filter: blur(12px); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: var(--radius-md); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 20px rgba(245, 158, 11, 0.1); +} + +.info-window h3 { + font-size: 1rem; + font-weight: 600; + color: var(--primary-light); + margin-bottom: var(--spacing-sm); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--border); +} + +.info-window p { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 6px; + line-height: 1.5; +} + +.info-window p:last-child { + margin-bottom: 0; +} + +.info-window .distance { + color: var(--success); + font-weight: 600; +} + +.info-window .tel { + color: var(--info); +} + +.info-window .tel a { + color: var(--info); + text-decoration: none; +} + +.info-window .tel a:hover { + text-decoration: underline; +} + +.info-window .nav-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + color: var(--bg-dark); + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; + border-radius: var(--radius-sm); + transition: var(--transition-fast); +} + +.info-window .nav-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--primary-glow); +} + +.info-window .nav-btn svg { + width: 16px; + height: 16px; +} + +/* ======================================== + 响应式设计 + ======================================== */ + +@media (max-width: 1024px) { + :root { + --sidebar-width: 360px; + } +} + +@media (max-width: 768px) { + .app-container { + flex-direction: column; + } + + .sidebar { + width: 100%; + min-width: 100%; + max-height: 50vh; + } + + .map-area { + height: 50vh; + } + + .location-list { + max-height: 120px; + } + + .floating-results { + width: calc(100% - 32px); + max-height: 40vh; + top: auto; + bottom: var(--spacing-md); + right: var(--spacing-md); + left: var(--spacing-md); + } +} + +/* ======================================== + 浮动搜索结果面板 + ======================================== */ + +.floating-results { + position: absolute; + top: var(--spacing-lg); + right: var(--spacing-lg); + width: 380px; + max-height: calc(100vh - 100px); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 50; + display: flex; + flex-direction: column; + animation: slideInRight 0.3s ease; + backdrop-filter: blur(10px); + background: rgba(26, 26, 46, 0.95); +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.floating-results-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border); + background: var(--bg-elevated); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +.floating-results-header h3 { + flex: 1; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.floating-results-header .result-count { + background: var(--primary); + color: var(--bg-dark); + font-size: 0.75rem; + font-weight: 700; + padding: 2px 10px; + border-radius: 20px; + min-width: 24px; + text-align: center; +} + +.floating-results-header .close-btn { + width: 28px; + height: 28px; + background: transparent; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + transition: var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; +} + +.floating-results-header .close-btn:hover { + background: var(--error); + border-color: var(--error); + color: white; +} + +.floating-results-filter { + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: 1px solid var(--border); +} + +.floating-results-filter input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-darker); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.85rem; + transition: var(--transition-fast); +} + +.floating-results-filter input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-glow); +} + +.floating-results-filter input::placeholder { + color: var(--text-muted); +} + +.floating-result-list { + flex: 1; + overflow-y: auto; + padding: var(--spacing-sm); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.floating-result-list::-webkit-scrollbar { + width: 6px; +} + +.floating-result-list::-webkit-scrollbar-track { + background: transparent; +} + +.floating-result-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.floating-result-item { + display: flex; + align-items: stretch; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--bg-darker); + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: var(--transition-fast); +} + +.floating-result-item:hover { + background: var(--bg-card-hover); + border-color: var(--primary); +} + +.floating-result-item.active { + border-color: var(--primary); + background: rgba(245, 158, 11, 0.15); + box-shadow: 0 0 12px var(--primary-glow); +} + +.floating-result-item.hidden { + display: none; +} + +.floating-result-item .rank { + width: 28px; + height: 28px; + background: var(--bg-elevated); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 700; + color: var(--text-muted); + flex-shrink: 0; + margin-top: 2px; +} + +.floating-result-item:nth-child(1) .rank { + background: linear-gradient(135deg, #ffd700 0%, #ffb800 100%); + color: var(--bg-dark); +} + +.floating-result-item:nth-child(2) .rank { + background: linear-gradient(135deg, #c0c0c0 0%, #a0a0a0 100%); + color: var(--bg-dark); +} + +.floating-result-item:nth-child(3) .rank { + background: linear-gradient(135deg, #cd7f32 0%, #b87333 100%); + color: var(--bg-dark); +} + +.floating-result-item .info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.floating-result-item .name { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary); + line-height: 1.3; + word-break: break-word; +} + +.floating-result-item .address { + font-size: 0.8rem; + color: var(--text-muted); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.floating-result-item .tel { + font-size: 0.8rem; + color: var(--info); + display: flex; + align-items: center; + gap: 4px; +} + +.floating-result-item .tel a { + color: var(--info); + text-decoration: none; +} + +.floating-result-item .tel a:hover { + text-decoration: underline; +} + +.floating-result-item .distance-badge { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 52px; + padding: var(--spacing-sm); + background: rgba(16, 185, 129, 0.12); + border-radius: var(--radius-sm); + border: 1px solid rgba(16, 185, 129, 0.25); +} + +.floating-result-item .distance-badge .num { + font-size: 1rem; + font-weight: 700; + color: var(--success); + font-family: 'JetBrains Mono', monospace; +} + +.floating-result-item .distance-badge .unit { + font-size: 0.7rem; + color: var(--success); + opacity: 0.8; +} + +.floating-result-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + color: var(--text-muted); + text-align: center; + gap: var(--spacing-sm); +} + +.floating-result-empty .icon { + font-size: 2.5rem; + opacity: 0.5; +} + +/* ======================================== + 高德地图样式覆盖 + ======================================== */ + +.amap-logo, +.amap-copyright { + opacity: 0.5; +} + +.amap-info-close { + font-size: 16px !important; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..ade3a56 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,692 @@ +/** + * 会面点 - Meeting Point + * 前端应用主逻辑 + */ + +// ======================================== +// 全局状态 +// ======================================== + +const state = { + map: null, + amapKey: '', // Web服务API Key + amapJsKey: '', // JS API Key + amapJsSecret: '', // JS API 安全密钥 + locations: [], // 参与者位置列表 + markers: [], // 位置标记 + centerMarker: null, // 中心点标记 + poiMarkers: [], // POI标记 + center: null, // 计算出的中心点 + searchCircle: null, // 搜索范围圆 + infoWindow: null, // 信息窗口 + currentPOIs: [], // 当前搜索结果 + colors: [ + '#3b82f6', '#ef4444', '#10b981', '#8b5cf6', + '#f59e0b', '#ec4899', '#06b6d4', '#84cc16' + ] +}; + +// ======================================== +// 初始化 +// ======================================== + +document.addEventListener('DOMContentLoaded', async () => { + await loadConfig(); + initMap(); + bindEvents(); +}); + +async function loadConfig() { + try { + const response = await fetch('/api/config'); + const data = await response.json(); + state.amapKey = data.amap_key; + state.amapJsKey = data.amap_js_key || data.amap_key; + state.amapJsSecret = data.amap_js_secret; + + // 配置安全密钥(必须在加载JS API之前设置) + if (state.amapJsSecret) { + window._AMapSecurityConfig = { + securityJsCode: state.amapJsSecret + }; + } + + // 动态加载高德地图JS API + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = `https://webapi.amap.com/maps?v=2.0&key=${state.amapJsKey}&plugin=AMap.PlaceSearch,AMap.Geocoder,AMap.AutoComplete`; + script.onload = resolve; + script.onerror = () => reject(new Error('加载高德地图JS API失败')); + document.head.appendChild(script); + }); + } catch (error) { + console.error('加载配置失败:', error); + alert('加载配置失败,请检查后端服务是否启动'); + } +} + +function initMap() { + // 初始化地图 + state.map = new AMap.Map('mapContainer', { + zoom: 12, + center: [116.397428, 39.90923], // 默认北京 + mapStyle: 'amap://styles/dark', // 深色主题 + viewMode: '2D' + }); + + // 初始化信息窗口 + state.infoWindow = new AMap.InfoWindow({ + isCustom: true, + autoMove: true, + offset: new AMap.Pixel(0, -40) + }); + + // 地图点击事件 + state.map.on('click', handleMapClick); + + // 尝试定位到用户位置 + AMap.plugin('AMap.Geolocation', () => { + const geolocation = new AMap.Geolocation({ + enableHighAccuracy: true, + timeout: 10000 + }); + + geolocation.getCurrentPosition((status, result) => { + if (status === 'complete') { + state.map.setCenter([result.position.lng, result.position.lat]); + } + }); + }); +} + +// ======================================== +// 事件绑定 +// ======================================== + +function bindEvents() { + // 搜索输入 + const searchInput = document.getElementById('searchInput'); + const clearSearch = document.getElementById('clearSearch'); + const searchTips = document.getElementById('searchTips'); + + let searchTimeout; + searchInput.addEventListener('input', (e) => { + const value = e.target.value.trim(); + clearSearch.style.display = value ? 'block' : 'none'; + + clearTimeout(searchTimeout); + if (value.length >= 2) { + searchTimeout = setTimeout(() => fetchSearchTips(value), 300); + } else { + searchTips.classList.remove('active'); + } + }); + + clearSearch.addEventListener('click', () => { + searchInput.value = ''; + clearSearch.style.display = 'none'; + searchTips.classList.remove('active'); + }); + + // 点击外部关闭搜索提示 + document.addEventListener('click', (e) => { + if (!e.target.closest('.search-box')) { + searchTips.classList.remove('active'); + } + }); + + // 快捷标签 + document.querySelectorAll('.tag').forEach(tag => { + tag.addEventListener('click', () => { + document.querySelectorAll('.tag').forEach(t => t.classList.remove('active')); + tag.classList.add('active'); + document.getElementById('poiKeywords').value = tag.dataset.keyword; + }); + }); + + // 搜索半径滑块 + const radiusSlider = document.getElementById('searchRadius'); + const radiusValue = document.getElementById('radiusValue'); + + radiusSlider.addEventListener('input', () => { + const value = parseInt(radiusSlider.value); + radiusValue.textContent = value >= 1000 ? `${value / 1000} 公里` : `${value} 米`; + }); + + // 搜索按钮 + document.getElementById('searchBtn').addEventListener('click', handleSearch); + + // POI关键词输入 + document.getElementById('poiKeywords').addEventListener('input', () => { + document.querySelectorAll('.tag').forEach(t => t.classList.remove('active')); + }); +} + +// ======================================== +// 搜索提示 +// ======================================== + +async function fetchSearchTips(keywords) { + const searchTips = document.getElementById('searchTips'); + + try { + const response = await fetch(`/api/tips?keywords=${encodeURIComponent(keywords)}&city=`); + const data = await response.json(); + + if (data.tips && data.tips.length > 0) { + renderSearchTips(data.tips); + } else { + searchTips.classList.remove('active'); + } + } catch (error) { + console.error('获取搜索提示失败:', error); + } +} + +function renderSearchTips(tips) { + const searchTips = document.getElementById('searchTips'); + + searchTips.innerHTML = tips.map(tip => ` +
+
${tip.name}
+
${tip.district}${tip.address}
+
+ `).join(''); + + // 绑定点击事件 + searchTips.querySelectorAll('.search-tip-item').forEach(item => { + item.addEventListener('click', () => { + const location = { + name: item.dataset.name, + address: item.dataset.address, + coordinate: { + lng: parseFloat(item.dataset.lng), + lat: parseFloat(item.dataset.lat) + } + }; + addLocation(location); + + // 清理搜索框 + document.getElementById('searchInput').value = ''; + document.getElementById('clearSearch').style.display = 'none'; + searchTips.classList.remove('active'); + }); + }); + + searchTips.classList.add('active'); +} + +// ======================================== +// 地图点击添加位置 +// ======================================== + +function handleMapClick(e) { + const lnglat = e.lnglat; + + // 逆地理编码获取地址 + AMap.plugin('AMap.Geocoder', () => { + const geocoder = new AMap.Geocoder(); + + geocoder.getAddress([lnglat.lng, lnglat.lat], (status, result) => { + if (status === 'complete' && result.info === 'OK') { + const address = result.regeocode.formattedAddress; + const location = { + name: `位置 ${state.locations.length + 1}`, + address: address, + coordinate: { + lng: lnglat.lng, + lat: lnglat.lat + } + }; + addLocation(location); + } + }); + }); +} + +// ======================================== +// 位置管理 +// ======================================== + +function addLocation(location) { + state.locations.push(location); + + // 添加地图标记 + const index = state.locations.length - 1; + const color = state.colors[index % state.colors.length]; + + const marker = new AMap.Marker({ + position: [location.coordinate.lng, location.coordinate.lat], + content: `
${index + 1}
`, + offset: new AMap.Pixel(-18, -18) + }); + + marker.on('click', () => { + showInfoWindow(marker, location); + }); + + state.markers.push(marker); + state.map.add(marker); + + // 调整视野 + if (state.locations.length > 1) { + state.map.setFitView(state.markers); + } else { + state.map.setCenter([location.coordinate.lng, location.coordinate.lat]); + state.map.setZoom(14); + } + + // 更新UI + updateLocationList(); + updateSearchButton(); + + // 清除之前的搜索结果 + clearSearchResults(); +} + +function removeLocation(index) { + // 移除标记 + state.map.remove(state.markers[index]); + state.markers.splice(index, 1); + state.locations.splice(index, 1); + + // 更新剩余标记的编号和颜色 + state.markers.forEach((marker, i) => { + const color = state.colors[i % state.colors.length]; + marker.setContent(`
${i + 1}
`); + }); + + // 更新UI + updateLocationList(); + updateSearchButton(); + + // 调整视野 + if (state.markers.length > 0) { + state.map.setFitView(state.markers); + } + + // 清除之前的搜索结果 + clearSearchResults(); +} + +function updateLocationList() { + const listEl = document.getElementById('locationList'); + const countEl = document.getElementById('locationCount'); + + countEl.textContent = state.locations.length; + + if (state.locations.length === 0) { + listEl.innerHTML = ` +
  • + 🗺️ + 还没有添加位置 + 搜索地址或点击地图开始 +
  • + `; + return; + } + + listEl.innerHTML = state.locations.map((loc, index) => { + const color = state.colors[index % state.colors.length]; + return ` +
  • +
    ${index + 1}
    +
    +
    ${loc.name}
    +
    ${loc.address || '暂无地址'}
    +
    + +
  • + `; + }).join(''); +} + +function updateSearchButton() { + const btn = document.getElementById('searchBtn'); + btn.disabled = state.locations.length < 2; +} + +// ======================================== +// 搜索功能 +// ======================================== + +async function handleSearch() { + const keywords = document.getElementById('poiKeywords').value.trim(); + const radius = parseInt(document.getElementById('searchRadius').value); + + if (!keywords) { + alert('请输入搜索关键词'); + return; + } + + if (state.locations.length < 2) { + alert('请至少添加2个位置'); + return; + } + + showLoading(true); + + try { + // 1. 计算中心点 + const centerResponse = await fetch('/api/center', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locations: state.locations }) + }); + + const centerData = await centerResponse.json(); + state.center = centerData.center; + + // 显示中心点标记 + showCenterMarker(); + + // 2. 搜索周边POI + const searchResponse = await fetch('/api/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + center: state.center, + keywords: keywords, + radius: radius + }) + }); + + const searchData = await searchResponse.json(); + + // 显示搜索结果 + showSearchResults(searchData.pois, radius); + + } catch (error) { + console.error('搜索失败:', error); + alert('搜索失败,请稍后重试'); + } finally { + showLoading(false); + } +} + +function showCenterMarker() { + // 移除旧的中心点标记 + if (state.centerMarker) { + state.map.remove(state.centerMarker); + } + + // 创建新的中心点标记 + state.centerMarker = new AMap.Marker({ + position: [state.center.lng, state.center.lat], + content: '
    ', + offset: new AMap.Pixel(-24, -24), + zIndex: 200 + }); + + state.map.add(state.centerMarker); + + // 显示中心点信息 + document.getElementById('centerInfo').style.display = 'block'; +} + +function showSearchResults(pois, radius) { + // 清除旧的POI标记 + clearPOIMarkers(); + + // 显示搜索范围圆 + if (state.searchCircle) { + state.map.remove(state.searchCircle); + } + + state.searchCircle = new AMap.Circle({ + center: [state.center.lng, state.center.lat], + radius: radius, + fillColor: '#f59e0b', + fillOpacity: 0.1, + strokeColor: '#f59e0b', + strokeWeight: 2, + strokeOpacity: 0.6 + }); + + state.map.add(state.searchCircle); + + // 添加POI标记 + pois.forEach((poi, index) => { + const marker = new AMap.Marker({ + position: [poi.location.lng, poi.location.lat], + content: `
    ${index + 1}
    `, + offset: new AMap.Pixel(-16, -16), + zIndex: 100 + }); + + marker.on('click', () => { + showPOIInfoWindow(marker, poi); + highlightResult(index); + }); + + state.poiMarkers.push(marker); + state.map.add(marker); + }); + + // 渲染结果列表 + renderResultList(pois); + + // 调整视野(右侧留出空间给浮动面板) + const allMarkers = [...state.markers, state.centerMarker, ...state.poiMarkers]; + state.map.setFitView(allMarkers, false, [80, 420, 80, 80]); +} + +function renderResultList(pois) { + const panel = document.getElementById('floatingResults'); + const list = document.getElementById('floatingResultList'); + const count = document.getElementById('floatingResultCount'); + const filterInput = document.getElementById('resultFilter'); + + // 保存POI数据供筛选使用 + state.currentPOIs = pois; + + count.textContent = pois.length; + + if (pois.length === 0) { + list.innerHTML = ` +
  • + 😕 + 未找到相关地点 + 尝试增大搜索半径或更换关键词 +
  • + `; + } else { + list.innerHTML = pois.map((poi, index) => { + const dist = parseDistance(poi.distance); + return ` +
  • +
    ${index + 1}
    +
    +
    ${poi.name}
    +
    ${poi.address || poi.type}
    + ${poi.tel ? `` : ''} +
    +
    + ${dist.value} + ${dist.unit} +
    +
  • + `}).join(''); + + // 绑定点击事件 + list.querySelectorAll('.floating-result-item').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const index = parseInt(item.dataset.index); + focusPOI(index); + }); + }); + } + + panel.style.display = 'flex'; + filterInput.value = ''; + + // 绑定关闭按钮事件 + document.getElementById('closeFloatingResults').onclick = () => { + panel.style.display = 'none'; + }; + + // 绑定筛选事件 + filterInput.oninput = (e) => { + const keyword = e.target.value.trim().toLowerCase(); + filterResults(keyword); + }; +} + +// 解析距离为数值和单位 +function parseDistance(distance) { + const d = parseInt(distance); + if (d >= 1000) { + return { value: (d / 1000).toFixed(1), unit: '公里' }; + } + return { value: d, unit: '米' }; +} + +// 筛选结果 +function filterResults(keyword) { + const items = document.querySelectorAll('.floating-result-item'); + let visibleCount = 0; + + items.forEach(item => { + const name = item.dataset.name?.toLowerCase() || ''; + const address = item.querySelector('.address')?.textContent.toLowerCase() || ''; + + if (!keyword || name.includes(keyword) || address.includes(keyword)) { + item.classList.remove('hidden'); + visibleCount++; + } else { + item.classList.add('hidden'); + } + }); + + // 更新显示数量 + document.getElementById('floatingResultCount').textContent = visibleCount; +} + +function formatDistance(distance) { + const d = parseInt(distance); + if (d >= 1000) { + return (d / 1000).toFixed(1) + ' 公里'; + } + return d + ' 米'; +} + +function focusPOI(index) { + const marker = state.poiMarkers[index]; + const poi = state.currentPOIs[index]; + + if (marker && poi) { + // 先高亮结果 + highlightResult(index); + + // 关闭当前信息窗口 + state.infoWindow.close(); + + // 使用 panTo 平滑移动到目标位置 + const position = marker.getPosition(); + state.map.panTo(position); + + // 延迟设置缩放和打开信息窗口,确保地图移动完成 + setTimeout(() => { + state.map.setZoom(16); + showPOIInfoWindow(marker, poi); + }, 300); + } +} + +function highlightResult(index) { + document.querySelectorAll('.floating-result-item').forEach((item, i) => { + item.classList.toggle('active', i === index); + }); + + // 滚动到对应项 + const activeItem = document.querySelector(`.floating-result-item[data-index="${index}"]`); + if (activeItem) { + activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } +} + +// ======================================== +// 信息窗口 +// ======================================== + +function showInfoWindow(marker, location) { + const content = ` +
    +

    ${location.name}

    +

    ${location.address || '暂无地址'}

    +
    + `; + + state.infoWindow.setContent(content); + state.infoWindow.open(state.map, marker.getPosition()); +} + +function showPOIInfoWindow(marker, poi) { + const distText = formatDistance(poi.distance); + const navUrl = `https://uri.amap.com/navigation?to=${poi.location.lng},${poi.location.lat},${encodeURIComponent(poi.name)}&mode=car&coordinate=gaode`; + + const content = ` +
    +

    ${poi.name}

    +

    🎯 距中心点: ${distText}

    + ${poi.address ? `

    📍 ${poi.address}

    ` : ''} + ${poi.tel ? `

    📞 ${poi.tel}

    ` : ''} + + + + + 导航前往 + +
    + `; + + state.infoWindow.setContent(content); + state.infoWindow.open(state.map, marker.getPosition()); +} + +// ======================================== +// 工具函数 +// ======================================== + +function clearSearchResults() { + // 隐藏结果区域 + document.getElementById('floatingResults').style.display = 'none'; + document.getElementById('centerInfo').style.display = 'none'; + document.getElementById('resultFilter').value = ''; + + // 清除标记 + clearPOIMarkers(); + + if (state.centerMarker) { + state.map.remove(state.centerMarker); + state.centerMarker = null; + } + + if (state.searchCircle) { + state.map.remove(state.searchCircle); + state.searchCircle = null; + } + + state.center = null; + state.currentPOIs = []; +} + +function clearPOIMarkers() { + state.poiMarkers.forEach(marker => { + state.map.remove(marker); + }); + state.poiMarkers = []; +} + +function showLoading(show) { + document.getElementById('loadingOverlay').style.display = show ? 'flex' : 'none'; +} + +// ======================================== +// 暴露全局函数 +// ======================================== + +window.removeLocation = removeLocation; +window.focusPOI = focusPOI; diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f6b1ac8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,137 @@ + + + + + + 会面点 - 寻找最佳聚会地点 + + + + + + +
    + + + + +
    +
    + + + + + + + + + +
    +
    + + + + +