From dfd912bf07b378082773bb136e0c483e05021fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=96=87=E6=98=8A?= Date: Thu, 16 Apr 2026 11:05:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(security):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=AB=98=E5=BE=B7=E9=80=86=E5=9C=B0=E7=90=86=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=B9=B6=E9=85=8D=E7=BD=AERestTemplate?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 2 + .../framework/config/RestTemplateConfig.java | 9 +- .../ruoyi/oa/controller/OaAmapController.java | 46 +++++++ .../ruoyi/oa/domain/vo/AmapCityNameVo.java | 23 ++++ .../oa/service/IOaAmapGeocodeService.java | 18 +++ .../impl/OaAmapGeocodeServiceImpl.java | 118 ++++++++++++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAmapController.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/AmapCityNameVo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAmapGeocodeService.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAmapGeocodeServiceImpl.java diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index a62f541..c89b963 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -170,6 +170,8 @@ security: - /oa/attendanceRecord/** - /oa/oaWarehouse/** - /oa/oaWarehouseMaster/** + # 高德逆地理(经纬度转城市等,供前端/H5 调用) + - /oa/amap/** # MyBatisPlus配置 # https://baomidou.com/config/ diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RestTemplateConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RestTemplateConfig.java index ac067dd..2cd0935 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RestTemplateConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RestTemplateConfig.java @@ -2,13 +2,20 @@ package com.ruoyi.framework.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; @Configuration public class RestTemplateConfig { + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_MS = 15_000; + @Bean public RestTemplate restTemplate() { - return new RestTemplate(); + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + return new RestTemplate(factory); } } \ No newline at end of file diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAmapController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAmapController.java new file mode 100644 index 0000000..7ecec8d --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAmapController.java @@ -0,0 +1,46 @@ +package com.ruoyi.oa.controller; + +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.oa.domain.vo.AmapCityNameVo; +import com.ruoyi.oa.service.IOaAmapGeocodeService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotNull; + +/** + * 高德地图:经纬度逆地理编码(城市名等) + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/oa/amap") +public class OaAmapController extends BaseController { + + private final IOaAmapGeocodeService oaAmapGeocodeService; + + /** + * 根据经纬度获取城市名称(高德逆地理编码) + */ + @GetMapping("/city") + public R cityByLocation( + @NotNull(message = "经度不能为空") + @DecimalMin(value = "-180.0", message = "经度范围无效") + @DecimalMax(value = "180.0", message = "经度范围无效") + @RequestParam Double longitude, + @NotNull(message = "纬度不能为空") + @DecimalMin(value = "-90.0", message = "纬度范围无效") + @DecimalMax(value = "90.0", message = "纬度范围无效") + @RequestParam Double latitude + ) { + AmapCityNameVo vo = oaAmapGeocodeService.reverseGeocodeCity(longitude, latitude); + return R.ok(vo); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/AmapCityNameVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/AmapCityNameVo.java new file mode 100644 index 0000000..970b1f6 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/AmapCityNameVo.java @@ -0,0 +1,23 @@ +package com.ruoyi.oa.domain.vo; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 高德逆地理编码:城市名称(及可选行政区信息,便于展示/调试) + */ +@Data +public class AmapCityNameVo implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 城市名(直辖市、省直辖等场景可能为省名或区名,与高德 addressComponent 一致) */ + private String cityName; + + /** 省 */ + private String province; + + /** 区/县 */ + private String district; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAmapGeocodeService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAmapGeocodeService.java new file mode 100644 index 0000000..8e094bd --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAmapGeocodeService.java @@ -0,0 +1,18 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.oa.domain.vo.AmapCityNameVo; + +/** + * 高德地图逆地理编码(经纬度 → 城市等) + */ +public interface IOaAmapGeocodeService { + + /** + * 根据经纬度解析城市名称等信息 + * + * @param longitude 经度 + * @param latitude 纬度 + * @return 非 null;解析失败时 cityName 等可能为空 + */ + AmapCityNameVo reverseGeocodeCity(Double longitude, Double latitude); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAmapGeocodeServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAmapGeocodeServiceImpl.java new file mode 100644 index 0000000..974322e --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAmapGeocodeServiceImpl.java @@ -0,0 +1,118 @@ +package com.ruoyi.oa.service.impl; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.oa.domain.vo.AmapCityNameVo; +import com.ruoyi.oa.service.IOaAmapGeocodeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 高德逆地理编码:restapi.amap.com/v3/geocode/regeo + *

+ * 配置项:{@code fad.amap.key}(与 {@code application.yml} 中一致) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OaAmapGeocodeServiceImpl implements IOaAmapGeocodeService { + + private static final String AMAP_REGEO_URL = "https://restapi.amap.com/v3/geocode/regeo"; + + private final RestTemplate restTemplate; + + @Value("${fad.amap.key:}") + private String amapKey; + + @Override + public AmapCityNameVo reverseGeocodeCity(Double longitude, Double latitude) { + AmapCityNameVo vo = new AmapCityNameVo(); + if (longitude == null || latitude == null) { + return vo; + } + if (!StringUtils.hasText(amapKey)) { + log.warn("fad.amap.key 未配置,无法调用高德逆地理编码"); + return vo; + } + + try { + // 高德要求:location = 经度,纬度 + String location = longitude + "," + latitude; + String url = UriComponentsBuilder.fromHttpUrl(AMAP_REGEO_URL) + .queryParam("key", amapKey) + .queryParam("location", location) + .queryParam("extensions", "base") + .queryParam("batch", "false") + .queryParam("output", "JSON") + .build(true) + .toUriString(); + + String body = restTemplate.getForObject(url, String.class); + if (!StringUtils.hasText(body)) { + log.warn("高德逆地理编码响应为空, location={}", location); + return vo; + } + JSONObject response = JSONObject.parseObject(body); + if (response == null) { + log.warn("高德逆地理编码 JSON 解析失败, location={}", location); + return vo; + } + if (!"1".equals(response.getString("status"))) { + String info = response.getString("info"); + log.warn("高德逆地理编码失败: status={}, info={}, infocode={}", + response.getString("status"), info, response.getString("infocode")); + return vo; + } + + JSONObject regeocode = response.getJSONObject("regeocode"); + if (regeocode == null) { + return vo; + } + JSONObject addressComponent = regeocode.getJSONObject("addressComponent"); + if (addressComponent == null) { + return vo; + } + + String province = nullToEmpty(addressComponent.getString("province")); + String district = nullToEmpty(addressComponent.getString("district")); + vo.setProvince(province); + vo.setDistrict(district); + + String cityName = resolveCityName(addressComponent, province); + vo.setCityName(cityName); + return vo; + } catch (Exception e) { + log.warn("高德逆地理编码异常, longitude={}, latitude={}, err={}", + longitude, latitude, e.getMessage(), e); + return vo; + } + } + + /** + * 城市字段:普通城市为字符串;部分省直辖为 [];直辖市可能用省名表示 + */ + private static String resolveCityName(JSONObject addressComponent, String province) { + Object cityRaw = addressComponent.get("city"); + if (cityRaw instanceof JSONArray) { + JSONArray arr = (JSONArray) cityRaw; + if (!arr.isEmpty()) { + return nullToEmpty(arr.getString(0)); + } + return province; + } + String city = addressComponent.getString("city"); + if (StringUtils.hasText(city) && !"[]".equals(city)) { + return city.trim(); + } + return province; + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s.trim(); + } +} From 04003983615010dcc44b1fe7e9769f850503d0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=96=87=E6=98=8A?= Date: Fri, 17 Apr 2026 13:57:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(geolocation):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=9C=B0=E7=82=B9=E8=8E=B7=E5=8F=96=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增高德地图逆地理编码接口,支持根据浏览器定位自动获取工作地点。 - 在项目报工页面中实现工作地点的自动更新,用户可通过按钮重新获取定位。 - 添加加载状态和错误提示,提升用户体验。 - 优化相关方法以处理地理位置获取的异步操作。 --- ruoyi-ui/.env.development | 2 +- ruoyi-ui/src/api/oa/amap.js | 15 +++ ruoyi-ui/src/utils/geolocationWorkPlace.js | 98 ++++++++++++++++ ruoyi-ui/src/views/oa/project/report/my.vue | 119 +++++++++++++++++--- 4 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 ruoyi-ui/src/api/oa/amap.js create mode 100644 ruoyi-ui/src/utils/geolocationWorkPlace.js diff --git a/ruoyi-ui/.env.development b/ruoyi-ui/.env.development index 6868459..032b0c6 100644 --- a/ruoyi-ui/.env.development +++ b/ruoyi-ui/.env.development @@ -5,7 +5,7 @@ VUE_APP_TITLE = 福安德综合办公系统 ENV = 'development' # 若依管理系统/开发环境 - VUE_APP_BASE_API = '/dev-api' +VUE_APP_BASE_API = '/dev-api' # VUE_APP_BASE_API = 'http://110.41.139.73:8080' # 应用访问路径 例如使用前缀 /admin/ diff --git a/ruoyi-ui/src/api/oa/amap.js b/ruoyi-ui/src/api/oa/amap.js new file mode 100644 index 0000000..ca57be1 --- /dev/null +++ b/ruoyi-ui/src/api/oa/amap.js @@ -0,0 +1,15 @@ +import request from '@/utils/request' + +/** + * 根据经纬度逆地理编码获取城市等信息(后端转发高德) + * @param {number} longitude 经度 + * @param {number} latitude 纬度 + */ +export function getCityByLocation (longitude, latitude) { + return request({ + url: '/oa/amap/city', + method: 'get', + params: { longitude, latitude }, + timeout: 15000 + }) +} diff --git a/ruoyi-ui/src/utils/geolocationWorkPlace.js b/ruoyi-ui/src/utils/geolocationWorkPlace.js new file mode 100644 index 0000000..f298cd3 --- /dev/null +++ b/ruoyi-ui/src/utils/geolocationWorkPlace.js @@ -0,0 +1,98 @@ +import { getCityByLocation } from '@/api/oa/amap' + +const GEO_OPTIONS = { + enableHighAccuracy: false, + timeout: 20000, + maximumAge: 120000 +} + +export const EMPTY_GEOCODE = 'EMPTY_GEOCODE' + +/** + * 将 AmapCityNameVo 格式化为展示用工作地点字符串 + */ +export function formatWorkPlaceFromAmap (vo) { + if (!vo) { + return '' + } + const province = (vo.province || '').trim() + const cityName = (vo.cityName || '').trim() + const district = (vo.district || '').trim() + const parts = [] + if (province) { + parts.push(province) + } + if (cityName && cityName !== province) { + parts.push(cityName) + } + if (district) { + parts.push(district) + } + const joined = parts.join(' ').trim() + return joined || cityName || province || '' +} + +function getCurrentPositionAsync () { + return new Promise((resolve, reject) => { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + reject(new Error('BROWSER_UNSUPPORTED')) + return + } + navigator.geolocation.getCurrentPosition( + position => resolve(position), + err => reject(err), + GEO_OPTIONS + ) + }) +} + +/** + * 浏览器定位 + 后端逆地理 → 工作地点字符串 + * @returns {Promise} + */ +export async function resolveWorkPlaceFromBrowser () { + const position = await getCurrentPositionAsync() + const { longitude, latitude } = position.coords + const res = await getCityByLocation(longitude, latitude) + const vo = res && res.data + const text = formatWorkPlaceFromAmap(vo) + if (!text) { + const err = new Error(EMPTY_GEOCODE) + console.warn('[workPlace] 逆地理结果为空', { longitude, latitude, vo }) + throw err + } + return text +} + +/** + * 将定位/逆地理错误转换为用户可读文案 + */ +export function geolocationUserMessage (err) { + if (!err) { + return '获取工作地点失败' + } + if (err.message === 'BROWSER_UNSUPPORTED') { + return '当前浏览器不支持定位,请更换浏览器或使用 HTTPS 访问后重试' + } + if (err.message === EMPTY_GEOCODE) { + return '无法根据当前位置解析城市,请稍后重试;若持续失败请联系管理员检查高德地图配置(fad.amap.key)' + } + const code = err.code + if (code === 1) { + return '您已拒绝定位权限,请在浏览器设置中允许本站点定位后点击「重新获取定位」' + } + if (code === 2) { + return '暂时无法获取位置信息,请到信号较好处点击「重新获取定位」重试' + } + if (code === 3) { + return '定位请求超时,请检查网络后点击「重新获取定位」重试' + } + const msg = err.message || '' + if (msg.includes('timeout') || msg.includes('超时')) { + return '接口请求超时,请稍后点击「重新获取定位」重试' + } + if (msg.includes('Network Error') || msg.includes('网络')) { + return '网络异常,请检查连接后点击「重新获取定位」重试' + } + return '获取工作地点失败,请点击「重新获取定位」重试' +} diff --git a/ruoyi-ui/src/views/oa/project/report/my.vue b/ruoyi-ui/src/views/oa/project/report/my.vue index 10a39d1..76546d7 100644 --- a/ruoyi-ui/src/views/oa/project/report/my.vue +++ b/ruoyi-ui/src/views/oa/project/report/my.vue @@ -87,7 +87,19 @@ - + + 重新获取定位 + +

{{ workPlaceLocateError }}
@@ -148,12 +160,19 @@ import { listDept } from "@/api/system/dept"; import { listUser } from "@/api/system/user"; import ProjectSelect from "@/components/fad-service/ProjectSelect"; import ProjectReportDetail from "@/views/oa/project/report/components/ProjectReportDetail.vue"; +import { + EMPTY_GEOCODE, + geolocationUserMessage, + resolveWorkPlaceFromBrowser +} from "@/utils/geolocationWorkPlace"; export default { name: "ProjectReport", components: { ProjectReportDetail, ProjectSelect }, data () { return { + workPlaceLoading: false, + workPlaceLocateError: "", // 按钮loading buttonLoading: false, detailVisible: false, @@ -230,6 +249,50 @@ export default { this.getUserList(); }, methods: { + /** + * @param {{ silent?: boolean, force?: boolean }} options + */ + async syncWorkPlaceFromGeolocation (options = {}) { + const silent = !!options.silent; + const force = !!options.force; + if (!force && this.form && this.form.reportId) { + return; + } + this.workPlaceLoading = true; + this.workPlaceLocateError = ""; + try { + const text = await resolveWorkPlaceFromBrowser(); + this.$set(this.form, "workPlace", text); + if (!silent) { + this.$modal.msgSuccess("已根据定位更新工作地点"); + } + } catch (e) { + console.error("[projectReport] 工作地点定位失败", e); + const msg = geolocationUserMessage(e); + this.workPlaceLocateError = msg; + this.$set(this.form, "workPlace", undefined); + const isBrowserGeoError = + e && + (e.code === 1 || + e.code === 2 || + e.code === 3 || + e.message === "BROWSER_UNSUPPORTED" || + e.message === EMPTY_GEOCODE); + if (isBrowserGeoError) { + this.$modal.msgWarning(msg); + } + } finally { + this.workPlaceLoading = false; + this.$nextTick(() => { + if (this.$refs.form) { + this.$refs.form.validateField("workPlace"); + } + }); + } + }, + refreshWorkPlace () { + this.syncWorkPlaceFromGeolocation({ silent: false, force: true }); + }, /** 格式化日期为 yyyy-MM-dd 格式 */ formatDate (date) { const year = date.getFullYear(); @@ -258,26 +321,31 @@ export default { this.reset(); this.open = true; this.title = "补录项目报工"; + this.$nextTick(() => { + this.syncWorkPlaceFromGeolocation({ silent: true, force: false }); + }); }, - /** 检查今日报工 */ + /** 检查今日报工(须返回 Promise,供新增时 .finally 打开弹窗) */ checkTodayReport () { - getTodayProjectReport().then(response => { - if (response.data && response.data.reportId) { - this.hasTodayReport = true; - this.todayReportId = response.data.reportId; - this.form = { - ...this.form, - ...response.data - }; - } else { + return getTodayProjectReport() + .then(response => { + if (response.data && response.data.reportId) { + this.hasTodayReport = true; + this.todayReportId = response.data.reportId; + this.form = { + ...this.form, + ...response.data + }; + } else { + this.hasTodayReport = false; + this.todayReportId = null; + } + }) + .catch(() => { this.hasTodayReport = false; this.todayReportId = null; - } - }).catch(() => { - this.hasTodayReport = false; - this.todayReportId = null; - }); + }); }, getDeptList () { @@ -313,6 +381,8 @@ export default { }, // 表单重置 reset () { + this.workPlaceLoading = false; + this.workPlaceLocateError = ""; this.form = { reportId: undefined, handler: undefined, @@ -359,9 +429,15 @@ export default { handleAdd () { this.reset(); this.suppVisible = false; - this.checkTodayReport(); - this.open = true; this.title = "添加项目报工"; + this.checkTodayReport().finally(() => { + this.open = true; + this.$nextTick(() => { + if (!this.form.reportId) { + this.syncWorkPlaceFromGeolocation({ silent: true, force: false }); + } + }); + }); }, /** 修改按钮操作 */ handleUpdate (row) { @@ -473,4 +549,11 @@ export default { display: block; margin-top: 2px; } + +.work-place-error { + color: #f56c6c; + font-size: 12px; + line-height: 1.5; + margin-top: 4px; +} \ No newline at end of file