diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 5412232..bd87293 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(); + } +} 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