Compare commits
9 Commits
ebb57f4f26
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0400398361 | |||
| edca68136c | |||
| 1584d7e06d | |||
| 7c261c3028 | |||
| 5d0c056449 | |||
| fd72c18d48 | |||
| dfd912bf07 | |||
| 8b627c000f | |||
| 56f7a6abb9 |
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.hrm.controller;
|
||||
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.AjaxResult;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.PageQuery;
|
||||
import com.ruoyi.common.core.domain.R;
|
||||
@@ -10,6 +11,7 @@ import com.ruoyi.hrm.domain.bo.HrmTravelReqBo;
|
||||
import com.ruoyi.hrm.domain.vo.HrmTravelReqVo;
|
||||
import com.ruoyi.hrm.service.IHrmTravelReqService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -28,6 +30,13 @@ import java.util.List;
|
||||
public class HrmTravelReqController extends BaseController {
|
||||
|
||||
private final IHrmTravelReqService service;
|
||||
private final IHrmTravelReqService hrmTravelReqService;
|
||||
|
||||
@Value("${fad.amap.webkey}")
|
||||
private String amapKey;
|
||||
|
||||
@Value("${fad.amap.securitykey}")
|
||||
private String amapSecurityKey;
|
||||
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo<HrmTravelReqVo> list(HrmTravelReqBo bo, PageQuery pageQuery) {
|
||||
@@ -56,6 +65,20 @@ public class HrmTravelReqController extends BaseController {
|
||||
public R<Void> remove(@PathVariable @NotEmpty Long[] bizIds) {
|
||||
return toAjax(service.deleteWithValidByIds(Arrays.asList(bizIds), true));
|
||||
}
|
||||
@PutMapping("/earlyEnd/{bizId}")
|
||||
public R<Void> earlyEnd(@PathVariable Long bizId) {
|
||||
return toAjax(hrmTravelReqService.earlyEnd(bizId));
|
||||
}
|
||||
|
||||
@GetMapping("/amapKey")
|
||||
public R<String> getAmapKey() {
|
||||
return R.ok(amapKey);
|
||||
}
|
||||
|
||||
@GetMapping("/amapSecurityKey")
|
||||
public R<String> getAmapSecurityKey() {
|
||||
return R.ok(amapSecurityKey);
|
||||
}
|
||||
|
||||
@GetMapping("/all")
|
||||
public R<List<HrmTravelReqVo>> all(HrmTravelReqBo bo) {
|
||||
|
||||
@@ -32,6 +32,7 @@ public class HrmTravelReq extends BaseEntity {
|
||||
private String bankName;
|
||||
private String bankAccount;
|
||||
private String remark;
|
||||
private Date actualEndTime;
|
||||
@TableLogic
|
||||
private Integer delFlag;
|
||||
}
|
||||
|
||||
@@ -22,5 +22,7 @@ public interface IHrmTravelReqService {
|
||||
|
||||
Boolean updateByBo(HrmTravelReqBo bo);
|
||||
|
||||
int earlyEnd(Long bizId);
|
||||
|
||||
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import com.ruoyi.hrm.domain.*;
|
||||
import com.ruoyi.hrm.domain.bo.HrmFlowTaskBo;
|
||||
import com.ruoyi.hrm.domain.bo.HrmSealStampBo;
|
||||
import com.ruoyi.hrm.domain.vo.HrmFlowTaskVo;
|
||||
import com.ruoyi.hrm.domain.vo.HrmEmployeeVo;
|
||||
import com.ruoyi.hrm.mapper.*;
|
||||
import com.ruoyi.hrm.service.IHrmFlowTaskService;
|
||||
import com.ruoyi.hrm.service.IHrmSealReqService;
|
||||
import com.ruoyi.hrm.service.IHrmFlowCcService;
|
||||
import com.ruoyi.hrm.service.IHrmEmployeeService;
|
||||
import com.ruoyi.hrm.domain.bo.HrmFlowCcBo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -47,6 +49,7 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
|
||||
private final HrmAppropriationReqMapper appropriationReqMapper;
|
||||
private final ObjectMapper objectMapper; // Spring Boot 默认提供
|
||||
private final UserService userService;
|
||||
private final IHrmEmployeeService employeeService;
|
||||
|
||||
@Override
|
||||
public HrmFlowTaskVo queryById(Long taskId) {
|
||||
@@ -74,7 +77,7 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private void fillBizData(List<HrmFlowTaskVo> tasks) {
|
||||
private void fillBizData(List<HrmFlowTaskVo> tasks) {
|
||||
// 1. 按 bizType 分组,并收集 bizId
|
||||
Map<String, List<Long>> bizIdsByType = tasks.stream()
|
||||
.filter(t -> t.getBizType() != null && t.getBizId() != null)
|
||||
@@ -90,19 +93,39 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
|
||||
if (bizIds.isEmpty()) return;
|
||||
switch (bizType) {
|
||||
case "leave":
|
||||
leaveReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("leave_" + d.getBizId(), d));
|
||||
leaveReqMapper.selectBatchIds(bizIds).forEach(d -> {
|
||||
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
|
||||
fillEmpName(dataMap, d.getEmpId());
|
||||
bizDataMap.put("leave_" + d.getBizId(), dataMap);
|
||||
});
|
||||
break;
|
||||
case "travel":
|
||||
travelReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("travel_" + d.getBizId(), d));
|
||||
travelReqMapper.selectBatchIds(bizIds).forEach(d -> {
|
||||
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
|
||||
fillEmpName(dataMap, d.getEmpId());
|
||||
bizDataMap.put("travel_" + d.getBizId(), dataMap);
|
||||
});
|
||||
break;
|
||||
case "seal":
|
||||
sealReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("seal_" + d.getBizId(), d));
|
||||
sealReqMapper.selectBatchIds(bizIds).forEach(d -> {
|
||||
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
|
||||
fillEmpName(dataMap, d.getEmpId());
|
||||
bizDataMap.put("seal_" + d.getBizId(), dataMap);
|
||||
});
|
||||
break;
|
||||
case "reimburse":
|
||||
reimburseReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("reimburse_" + d.getBizId(), d));
|
||||
reimburseReqMapper.selectBatchIds(bizIds).forEach(d -> {
|
||||
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
|
||||
fillEmpName(dataMap, d.getEmpId());
|
||||
bizDataMap.put("reimburse_" + d.getBizId(), dataMap);
|
||||
});
|
||||
break;
|
||||
case "appropriation":
|
||||
appropriationReqMapper.selectBatchIds(bizIds).forEach(d -> bizDataMap.put("appropriation_" + d.getBizId(), d));
|
||||
appropriationReqMapper.selectBatchIds(bizIds).forEach(d -> {
|
||||
Map<String, Object> dataMap = objectMapper.convertValue(d, Map.class);
|
||||
fillEmpName(dataMap, d.getEmpId());
|
||||
bizDataMap.put("appropriation_" + d.getBizId(), dataMap);
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -112,13 +135,20 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
|
||||
String key = task.getBizType() + "_" + task.getBizId();
|
||||
Object data = bizDataMap.get(key);
|
||||
if (data != null) {
|
||||
// 将实体对象转换为 Map<String, Object>,方便前端使用
|
||||
Map<String, Object> dataMap = objectMapper.convertValue(data, Map.class);
|
||||
task.setBizData(dataMap);
|
||||
task.setBizData((Map<String, Object>) data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fillEmpName(Map<String, Object> dataMap, Long empId) {
|
||||
if (empId != null) {
|
||||
HrmEmployeeVo emp = employeeService.queryById(empId);
|
||||
if (emp != null && emp.getEmpName() != null) {
|
||||
dataMap.put("empName", emp.getEmpName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -416,6 +446,11 @@ public class HrmFlowTaskServiceImpl implements IHrmFlowTaskService {
|
||||
|
||||
if (result.getRecords() != null && !result.getRecords().isEmpty()) {
|
||||
fillBizData(result.getRecords());
|
||||
result.getRecords().forEach(task -> {
|
||||
if (task.getAssigneeUserId() != null) {
|
||||
task.setAssigneeNickName(userService.selectNickNameById(task.getAssigneeUserId()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return TableDataInfo.build(result);
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Date;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
@@ -128,4 +129,36 @@ public class HrmTravelReqServiceImpl implements IHrmTravelReqService {
|
||||
private String defaultStatus(String status) {
|
||||
return status == null ? "draft" : status;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int earlyEnd(Long bizId) {
|
||||
HrmTravelReq travelReq = baseMapper.selectById(bizId);
|
||||
if (travelReq == null) {
|
||||
throw new RuntimeException("出差申请不存在");
|
||||
}
|
||||
|
||||
String status = travelReq.getStatus();
|
||||
if (!"approved".equals(status) && !"in_progress".equals(status)) {
|
||||
throw new RuntimeException("当前状态不能提前结束,只有已通过或进行中的出差才能提前结束");
|
||||
}
|
||||
|
||||
// 3. 检查是否已经提前结束过
|
||||
if (travelReq.getActualEndTime() != null) {
|
||||
throw new RuntimeException("该出差已经提前结束过了");
|
||||
}
|
||||
|
||||
// 4. 更新实际结束时间为当前时间
|
||||
travelReq.setActualEndTime(new Date());
|
||||
// 5. 可选:更新状态为已完成
|
||||
travelReq.setStatus("completed");
|
||||
|
||||
// 6. 执行更新
|
||||
int result = baseMapper.updateById(travelReq);
|
||||
if (result <= 0) {
|
||||
throw new RuntimeException("提前结束失败");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +51,13 @@
|
||||
<sql id="selectVo">
|
||||
SELECT
|
||||
e.emp_id, e.user_id, e.emp_no, e.emp_name, e.gender, e.mobile, e.email, e.id_no,
|
||||
e.hire_date, e.employment_type, e.status, e.dept_id, e.post_id, e.remark,
|
||||
e.hire_date, e.employment_type, e.status, su.dept_id, e.post_id, e.remark,
|
||||
e.create_by, e.create_time, e.update_by, e.update_time,
|
||||
d.dept_name,
|
||||
p.post_name
|
||||
FROM hrm_employee e
|
||||
LEFT JOIN sys_dept d ON e.dept_id = d.dept_id AND d.del_flag = '0'
|
||||
left join sys_user su on su.user_id = e.user_id
|
||||
LEFT JOIN sys_dept d ON su.dept_id = d.dept_id AND d.del_flag = 0
|
||||
LEFT JOIN sys_post p ON e.post_id = p.post_id
|
||||
WHERE e.del_flag = 0
|
||||
</sql>
|
||||
|
||||
@@ -170,6 +170,8 @@ security:
|
||||
- /oa/attendanceRecord/**
|
||||
- /oa/oaWarehouse/**
|
||||
- /oa/oaWarehouseMaster/**
|
||||
# 高德逆地理(经纬度转城市等,供前端/H5 调用)
|
||||
- /oa/amap/**
|
||||
|
||||
# MyBatisPlus配置
|
||||
# https://baomidou.com/config/
|
||||
@@ -317,8 +319,12 @@ flowable:
|
||||
check-process-definitions: false
|
||||
# 关闭历史任务定时任务job
|
||||
async-history-executor-activate: false
|
||||
|
||||
|
||||
fad:
|
||||
amap:
|
||||
key: 978ae5bc551f57d172d3e397af5a6f67
|
||||
# 留作后端接口调用(服务 API)的 Key
|
||||
key: 978ae5bc551f57d172d3e397af5a6f67
|
||||
# 新增的前端 Web 端使用的 Key 和安全密钥
|
||||
webKey: 34bf20d1db5b183558b9bb85d6eed783
|
||||
securityKey: 6f9171724396deb5f8c42ef256b3cbc5
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<AmapCityNameVo> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
* <p>
|
||||
* 配置项:{@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();
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"url": "https://gitee.com/KonBAI-Q/ruoyi-flowable-plus.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@babel/parser": "7.7.4",
|
||||
"@handsontable/vue": "^15.3.0",
|
||||
"@jiaminghi/data-view": "^2.10.0",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<title>
|
||||
<%= webpackConfig.name %>
|
||||
</title>
|
||||
|
||||
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
|
||||
<style>
|
||||
html,
|
||||
@@ -18,7 +19,7 @@
|
||||
height: 100%;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
|
||||
.chromeframe {
|
||||
margin: 0.2em 0;
|
||||
@@ -216,4 +217,4 @@
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -254,9 +254,4 @@ export function listAssignTask (instId) {
|
||||
url: `/hrm/flow/instance/tasks/${instId}`,
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
/**
|
||||
* 查询当前用户的审批历史
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
@@ -46,3 +46,22 @@ export function allTravelReq(query) {
|
||||
params: query
|
||||
})
|
||||
}
|
||||
export function earlyEndTravel(bizId) {
|
||||
return request({
|
||||
url: `/hrm/travel/earlyEnd/${bizId}`,
|
||||
method: 'put'
|
||||
})
|
||||
}
|
||||
|
||||
export function getAmapKey() {
|
||||
return request({
|
||||
url: '/hrm/travel/amapKey',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
export function getAmapSecurityKey() {
|
||||
return request({
|
||||
url: '/hrm/travel/amapSecurityKey',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
15
ruoyi-ui/src/api/oa/amap.js
Normal file
15
ruoyi-ui/src/api/oa/amap.js
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
371
ruoyi-ui/src/components/AmapCitySelect/index.vue
Normal file
371
ruoyi-ui/src/components/AmapCitySelect/index.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div class="amap-city-select">
|
||||
<el-input
|
||||
v-model="cityName"
|
||||
:placeholder="placeholder"
|
||||
@focus="openMapDialog"
|
||||
clearable
|
||||
readonly
|
||||
>
|
||||
<i slot="prefix" class="el-icon-location" style="color: #409eff"></i>
|
||||
</el-input>
|
||||
|
||||
<el-dialog
|
||||
title="选择出差城市 / 地点"
|
||||
:visible.sync="dialogVisible"
|
||||
width="900px"
|
||||
:append-to-body="true"
|
||||
@opened="onDialogOpened"
|
||||
>
|
||||
<div class="map-selector">
|
||||
<div class="city-sidebar">
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="输入城市或具体地点搜索"
|
||||
size="small"
|
||||
prefix-icon="el-icon-search"
|
||||
@keyup.enter.native="searchLocation"
|
||||
clearable
|
||||
@clear="searchKeyword = ''"
|
||||
>
|
||||
<el-button slot="append" @click="searchLocation">搜索</el-button>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 热门城市模块 -->
|
||||
<div class="hot-cities" v-if="!searchKeyword || searchResults.length === 0">
|
||||
<div class="section-title">热门城市</div>
|
||||
<div class="city-list">
|
||||
<span
|
||||
v-for="city in hotCities"
|
||||
:key="city"
|
||||
class="city-item"
|
||||
@click="selectHotCity(city)"
|
||||
>
|
||||
{{ city }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果模块 -->
|
||||
<div class="search-results" v-if="searchResults.length > 0">
|
||||
<div class="section-title">搜索结果</div>
|
||||
<div class="result-list">
|
||||
<div
|
||||
v-for="(result, index) in searchResults"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
@click="selectLocation(result)"
|
||||
>
|
||||
<i class="el-icon-location-outline"></i>
|
||||
<div class="result-info">
|
||||
<div class="poi-name">{{ result.name }}</div>
|
||||
<div class="poi-address">{{ result.address }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<div id="amapContainer" class="amap-wrapper"></div>
|
||||
<div class="map-tip">
|
||||
<i class="el-icon-info"></i> 点击地图上的位置或搜索地点,自动识别归属城市
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<div class="selected-info" v-if="selectedCity">
|
||||
识别城市:<span class="selected-city">{{ selectedCity }}</span>
|
||||
<span v-if="selectedPoi" style="margin-left: 10px; color: #909399; font-size: 12px;">
|
||||
( {{ selectedPoi }} )
|
||||
</span>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
<div>
|
||||
<el-button @click="closeDialog">取消</el-button>
|
||||
<el-button type="primary" @click="confirmCity" :disabled="!selectedCity">确定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AMapLoader from '@amap/amap-jsapi-loader'
|
||||
import { getAmapKey, getAmapSecurityKey } from '@/api/hrm/travel'
|
||||
|
||||
export default {
|
||||
name: 'AmapCitySelect',
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '请选择出差地点' }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cityName: '',
|
||||
dialogVisible: false,
|
||||
searchKeyword: '',
|
||||
searchResults: [],
|
||||
selectedCity: '', // 最终表单需要的城市名
|
||||
selectedPoi: '', // 具体的地点名(仅用于展示)
|
||||
map: null,
|
||||
geocoder: null,
|
||||
placeSearch: null, // 新增地点搜索对象
|
||||
marker: null,
|
||||
AMap: null,
|
||||
hotCities: [
|
||||
'北京市', '上海市', '广州市', '深圳市',
|
||||
'杭州市', '南京市', '成都市', '武汉市',
|
||||
'重庆市', '天津市', '苏州市', '西安市'
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(val) { this.cityName = val || '' }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onDialogOpened() {
|
||||
const AMap = await this.loadAMap()
|
||||
if (!AMap) return
|
||||
this.$nextTick(() => {
|
||||
this.initMap()
|
||||
})
|
||||
},
|
||||
|
||||
async loadAMap() {
|
||||
if (this.AMap && window._AMapSecurityConfig) return this.AMap
|
||||
try {
|
||||
const [keyRes, securityRes] = await Promise.all([getAmapKey(), getAmapSecurityKey()])
|
||||
const amapkey = keyRes.data || keyRes.msg
|
||||
const securityKey = securityRes.data || securityRes.msg
|
||||
|
||||
if (!amapkey) throw new Error('未获取到高德地图 Key')
|
||||
|
||||
window._AMapSecurityConfig = { securityJsCode: securityKey }
|
||||
|
||||
this.AMap = await AMapLoader.load({
|
||||
key: amapkey,
|
||||
version: '2.0',
|
||||
// 加入 PlaceSearch 插件
|
||||
plugins: ['AMap.Geocoder', 'AMap.PlaceSearch']
|
||||
})
|
||||
return this.AMap
|
||||
} catch (error) {
|
||||
console.error('地图加载失败:', error)
|
||||
this.$message.error('地图加载失败,请重试')
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
initMap() {
|
||||
const container = document.getElementById('amapContainer')
|
||||
if (!container) {
|
||||
setTimeout(() => this.initMap(), 100)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.map) {
|
||||
this.map.destroy()
|
||||
this.map = null
|
||||
}
|
||||
|
||||
this.map = new this.AMap.Map('amapContainer', {
|
||||
zoom: 12,
|
||||
center: [116.397428, 39.90923], // 默认北京
|
||||
resizeEnable: true
|
||||
})
|
||||
|
||||
this.geocoder = new this.AMap.Geocoder()
|
||||
|
||||
// 初始化地点搜索插件
|
||||
this.placeSearch = new this.AMap.PlaceSearch({
|
||||
pageSize: 15, // 单页显示结果条数
|
||||
pageIndex: 1, // 页码
|
||||
autoFitView: false // 禁用自动调整视图,我们自己控制
|
||||
})
|
||||
|
||||
// 监听地图点击
|
||||
this.map.on('click', (e) => {
|
||||
const lng = e.lnglat.getLng()
|
||||
const lat = e.lnglat.getLat()
|
||||
this.selectedPoi = '地图选点'
|
||||
this.getCityByLngLat(lng, lat, true)
|
||||
})
|
||||
},
|
||||
|
||||
// 通过经纬度逆解析城市
|
||||
getCityByLngLat(lng, lat, setCenter = false) {
|
||||
this.geocoder.getAddress([lng, lat], (status, result) => {
|
||||
if (status === 'complete' && result.regeocode) {
|
||||
const addrComp = result.regeocode.addressComponent
|
||||
let city = addrComp.city
|
||||
// 直辖市的 city 可能是空的,取 province
|
||||
if (!city || city === '[]' || city.length === 0) {
|
||||
city = addrComp.province
|
||||
}
|
||||
this.selectedCity = city
|
||||
this.addMarker(lng, lat)
|
||||
if(setCenter) {
|
||||
this.map.setCenter([lng, lat])
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 在地图上打点
|
||||
addMarker(lng, lat) {
|
||||
if (this.marker) {
|
||||
this.marker.setMap(null)
|
||||
}
|
||||
this.marker = new this.AMap.Marker({
|
||||
position: [lng, lat],
|
||||
map: this.map,
|
||||
animation: 'AMAP_ANIMATION_DROP' // 加上掉落动画
|
||||
})
|
||||
},
|
||||
|
||||
openMapDialog() {
|
||||
this.dialogVisible = true
|
||||
this.selectedCity = this.value || ''
|
||||
this.selectedPoi = ''
|
||||
this.searchKeyword = ''
|
||||
this.searchResults = []
|
||||
},
|
||||
|
||||
// 搜索地点核心方法
|
||||
async searchLocation() {
|
||||
if (!this.searchKeyword.trim()) {
|
||||
this.searchResults = []
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadAMap()
|
||||
|
||||
// 使用 PlaceSearch 搜索具体地点
|
||||
this.placeSearch.search(this.searchKeyword, (status, result) => {
|
||||
if (status === 'complete' && result.info === 'OK') {
|
||||
// 提取返回的 POI 列表
|
||||
const pois = result.poiList.pois
|
||||
this.searchResults = pois.map(poi => ({
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
address: poi.address && typeof poi.address === 'string' ? poi.address : poi.adname,
|
||||
location: poi.location, // 包含 lng/lat 的对象
|
||||
city: poi.cityname || poi.pname // 城市名
|
||||
}))
|
||||
} else {
|
||||
this.searchResults = []
|
||||
this.$message.info('未找到相关地点,请尝试更换关键词')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 选中左侧搜索结果列表的具体地点
|
||||
selectLocation(poi) {
|
||||
if (!poi.location) {
|
||||
this.$message.warning('该地点缺少坐标信息')
|
||||
return
|
||||
}
|
||||
|
||||
const lng = poi.location.lng
|
||||
const lat = poi.location.lat
|
||||
|
||||
// 设置地图中心点并放大(层级15看街道)
|
||||
this.map.setZoomAndCenter(15, [lng, lat])
|
||||
this.addMarker(lng, lat)
|
||||
|
||||
// 更新选择数据
|
||||
this.selectedCity = poi.city
|
||||
this.selectedPoi = poi.name
|
||||
this.$message.success(`已定位到:${poi.name}`)
|
||||
},
|
||||
|
||||
// 选中热门城市
|
||||
async selectHotCity(city) {
|
||||
await this.loadAMap()
|
||||
this.selectedCity = city
|
||||
this.selectedPoi = city
|
||||
|
||||
// 获取城市中心点坐标
|
||||
this.geocoder.getLocation(city, (status, result) => {
|
||||
if (status === 'complete' && result.geocodes.length) {
|
||||
const loc = result.geocodes[0].location
|
||||
this.map.setZoomAndCenter(11, [loc.lng, loc.lat])
|
||||
this.addMarker(loc.lng, loc.lat)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
confirmCity() {
|
||||
if (this.selectedCity) {
|
||||
this.cityName = this.selectedCity
|
||||
// 组件 v-model 抛出城市名
|
||||
this.$emit('input', this.selectedCity)
|
||||
// 如果你需要具体的地点名,可以额外抛出一个事件
|
||||
this.$emit('poi-change', { city: this.selectedCity, poi: this.selectedPoi })
|
||||
this.closeDialog()
|
||||
}
|
||||
},
|
||||
|
||||
closeDialog() {
|
||||
this.dialogVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.amap-city-select { width: 100%; }
|
||||
.map-selector { display: flex; gap: 20px; min-height: 450px;
|
||||
.city-sidebar { width: 300px; flex-shrink: 0; display: flex; flex-direction: column;
|
||||
.search-box { margin-bottom: 16px; }
|
||||
.section-title { font-size: 14px; font-weight: bold; color: #303133; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; }
|
||||
|
||||
.hot-cities .city-list { display: flex; flex-wrap: wrap; gap: 10px;
|
||||
.city-item { padding: 6px 14px; background: #f4f4f5; color: #606266; border-radius: 4px; font-size: 13px; cursor: pointer; transition: all 0.2s;
|
||||
&:hover { background: #409eff; color: white; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索结果列表样式升级 */
|
||||
.search-results {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.result-list {
|
||||
flex: 1; overflow-y: auto; padding-right: 5px;
|
||||
|
||||
/* 自定义滚动条 */
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 4px; }
|
||||
|
||||
.result-item {
|
||||
padding: 12px 10px; cursor: pointer; display: flex; align-items: flex-start; gap: 10px; border-bottom: 1px solid #ebeef5; transition: background 0.2s;
|
||||
&:hover { background: #f0f7ff; }
|
||||
i { color: #409eff; margin-top: 3px; font-size: 16px; }
|
||||
.result-info {
|
||||
flex: 1; overflow: hidden;
|
||||
.poi-name { font-size: 14px; color: #303133; font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.poi-address { font-size: 12px; color: #909399; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.map-container { flex: 1; display: flex; flex-direction: column;
|
||||
.amap-wrapper { flex: 1; width: 100%; border: 1px solid #dcdfe6; border-radius: 6px; overflow: hidden; }
|
||||
.map-tip { margin-top: 10px; font-size: 12px; color: #909399; text-align: center; i { margin-right: 4px; color: #e6a23c; } }
|
||||
}
|
||||
}
|
||||
.dialog-footer { display: flex; justify-content: space-between; align-items: center;
|
||||
.selected-info { font-size: 14px; color: #606266; .selected-city { color: #409eff; font-weight: bold; font-size: 15px; } }
|
||||
}
|
||||
</style>
|
||||
98
ruoyi-ui/src/utils/geolocationWorkPlace.js
Normal file
98
ruoyi-ui/src/utils/geolocationWorkPlace.js
Normal file
@@ -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<string>}
|
||||
*/
|
||||
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 '获取工作地点失败,请点击「重新获取定位」重试'
|
||||
}
|
||||
@@ -65,8 +65,8 @@
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="目的地" prop="destination">
|
||||
<el-input v-model="form.destination" placeholder="城市/地址/项目现场" />
|
||||
<div class="hint-text">请填写具体目的地,便于审批人判断出差必要性</div>
|
||||
<amap-city-select v-model="form.destination" placeholder="请选择出差城市" />
|
||||
<div class="hint-text">请通过地图或搜索选择具体城市,便于审批人判断出差必要性</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="block-title">出差说明</div>
|
||||
@@ -223,13 +223,15 @@ import { ccFlowTask, listFlowNode, listFlowTemplate } from '@/api/hrm/flow'
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import UserMultiSelect from '@/components/UserSelect/multi.vue'
|
||||
import UserSelect from '@/components/UserSelect/single.vue'
|
||||
import AmapCitySelect from '@/components/AmapCitySelect/index.vue'
|
||||
|
||||
export default {
|
||||
name: 'HrmTravelRequest',
|
||||
components: {
|
||||
UserSelect,
|
||||
FileUpload,
|
||||
UserMultiSelect
|
||||
UserMultiSelect,
|
||||
AmapCitySelect
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
<BizDetailContainer :bizId="currentBizId" bizType="travel" :preview="preview">
|
||||
<template slot-scope="{ detail }">
|
||||
<div>
|
||||
<!-- ===== 新增:提前结束按钮区域 ===== -->
|
||||
<div class="action-buttons" v-if="showEarlyEndButton(detail)">
|
||||
<el-button
|
||||
type="warning"
|
||||
size="small"
|
||||
icon="el-icon-finished"
|
||||
:loading="earlyEndLoading"
|
||||
@click="handleEarlyEnd"
|
||||
>
|
||||
提前结束
|
||||
</el-button>
|
||||
<span class="hint-text">提前结束将把当前时间记录为实际结束时间</span>
|
||||
</div>
|
||||
<!-- ===== 新增:显示已提前结束的信息 ===== -->
|
||||
<div v-if="detail.actualEndTime" class="early-end-info">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon>
|
||||
<template slot="default">
|
||||
该出差已于 {{ formatDate(detail.actualEndTime) }} 提前结束
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
<!-- 出差时间与行程 -->
|
||||
<div class="block-title">出差时间与行程</div>
|
||||
<el-card class="inner-card" shadow="never">
|
||||
@@ -41,6 +65,7 @@
|
||||
<script>
|
||||
import FilePreview from "@/components/FilePreview/index.vue";
|
||||
import BizDetailContainer from '@/views/hrm/components/BizDetailContainer/index.vue';
|
||||
import { earlyEndTravel } from '@/api/hrm/travel'
|
||||
|
||||
export default {
|
||||
name: 'TravelDetail',
|
||||
@@ -53,7 +78,11 @@ export default {
|
||||
BizDetailContainer,
|
||||
FilePreview
|
||||
},
|
||||
name: 'HrmTravelDetail',
|
||||
data() {
|
||||
return {
|
||||
earlyEndLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentBizId () {
|
||||
return this.bizId || this.$route?.params?.bizId || this.$route?.query?.bizId || this.$route?.params?.id
|
||||
@@ -66,7 +95,41 @@ export default {
|
||||
const p = n => (n < 10 ? `0${n}` : n)
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
},
|
||||
// 新增:处理提前结束
|
||||
showEarlyEndButton(detail) {
|
||||
if (!detail) return false
|
||||
// 已经提前结束的不显示
|
||||
if (detail.actualEndTime) return false
|
||||
// 只有已通过或进行中状态才显示
|
||||
const status = detail.status
|
||||
return status === 'approved' || status === 'in_progress'
|
||||
},
|
||||
|
||||
handleEarlyEnd() {
|
||||
this.$confirm('确认提前结束本次出差吗?结束后的实际时间将记录为当前时间。', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
this.earlyEndLoading = true
|
||||
try {
|
||||
const bizId = this.currentBizId
|
||||
await earlyEndTravel(bizId)
|
||||
this.$message.success('提前结束成功')
|
||||
// 刷新页面
|
||||
setTimeout(() => {
|
||||
location.reload()
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
this.$message.error(error.message || '提前结束失败')
|
||||
} finally {
|
||||
this.earlyEndLoading = false
|
||||
}
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -375,4 +438,17 @@ export default {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.action-buttons {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #fdf6ec;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.early-end-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -87,7 +87,19 @@
|
||||
</el-alert>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="96px">
|
||||
<el-form-item label="工作地点" prop="workPlace">
|
||||
<el-input v-model="form.workPlace" placeholder="请输入工作地点" />
|
||||
<el-input
|
||||
v-model="form.workPlace"
|
||||
readonly
|
||||
:placeholder="workPlaceLoading ? '正在获取定位…' : '请点击「重新获取定位」获取工作地点(不可手动输入)'"
|
||||
>
|
||||
<el-button
|
||||
slot="append"
|
||||
icon="el-icon-location-outline"
|
||||
:loading="workPlaceLoading"
|
||||
@click="refreshWorkPlace"
|
||||
>重新获取定位</el-button>
|
||||
</el-input>
|
||||
<div v-if="workPlaceLocateError" class="work-place-error">{{ workPlaceLocateError }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否出差" prop="isTrip">
|
||||
<el-radio-group v-model="form.isTrip">
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user