推送项目重构代码
This commit is contained in:
@@ -177,10 +177,14 @@ sms:
|
|||||||
|
|
||||||
--- # OpenIM 集成
|
--- # OpenIM 集成
|
||||||
openim:
|
openim:
|
||||||
# OpenIM 核心 API 地址(admin token、发消息)
|
# OpenIM 核心 API 地址 — 走 nginx 网关 :10006/api(直连 :10002 端口外网未开放)
|
||||||
api-url: http://49.232.154.205:10002
|
api-url: http://49.232.154.205:10006/api
|
||||||
# OpenIM chat 业务 API 地址(手机号注册/查询,可选)
|
# 给前端 SDK 用的 API 地址
|
||||||
chat-url: http://49.232.154.205:10008
|
public-api-url: http://49.232.154.205:10006/api
|
||||||
|
# WebSocket 长连接地址(与手机端一致)
|
||||||
|
ws-url: ws://49.232.154.205:10006/msg_gateway
|
||||||
|
# OpenIM chat 业务 API 地址
|
||||||
|
chat-url: http://49.232.154.205:10006/chat
|
||||||
# 与 OpenIM share.yml 中保持一致的 secret
|
# 与 OpenIM share.yml 中保持一致的 secret
|
||||||
secret: openIM123
|
secret: openIM123
|
||||||
# 管理员 userID(chat share.yml 中 adminUserID)
|
# 管理员 userID(chat share.yml 中 adminUserID)
|
||||||
|
|||||||
@@ -175,3 +175,22 @@ sms:
|
|||||||
signName: 测试
|
signName: 测试
|
||||||
# 腾讯专用
|
# 腾讯专用
|
||||||
sdkAppId:
|
sdkAppId:
|
||||||
|
|
||||||
|
|
||||||
|
--- # OpenIM 集成
|
||||||
|
openim:
|
||||||
|
# OpenIM 核心 API 地址 — 走 nginx 网关 :10006/api(直连 :10002 端口外网未开放)
|
||||||
|
api-url: http://49.232.154.205:10006/api
|
||||||
|
# 给前端 SDK 用的 API 地址
|
||||||
|
public-api-url: http://49.232.154.205:10006/api
|
||||||
|
# WebSocket 长连接地址(与手机端一致)
|
||||||
|
ws-url: ws://49.232.154.205:10006/msg_gateway
|
||||||
|
# OpenIM chat 业务 API 地址
|
||||||
|
chat-url: http://49.232.154.205:10006/chat
|
||||||
|
# 与 OpenIM share.yml 中保持一致的 secret
|
||||||
|
secret: openIM123
|
||||||
|
# 管理员 userID(chat share.yml 中 adminUserID)
|
||||||
|
admin-user-id: imAdmin
|
||||||
|
# 发送系统消息时显示的发送者
|
||||||
|
notification-sender: imAdmin
|
||||||
|
enabled: true
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import com.ruoyi.oa.domain.vo.OaProjectScheduleVo;
|
|||||||
import com.ruoyi.oa.domain.bo.OaProjectScheduleBo;
|
import com.ruoyi.oa.domain.bo.OaProjectScheduleBo;
|
||||||
import com.ruoyi.oa.service.IOaProjectScheduleService;
|
import com.ruoyi.oa.service.IOaProjectScheduleService;
|
||||||
import com.ruoyi.common.core.page.TableDataInfo;
|
import com.ruoyi.common.core.page.TableDataInfo;
|
||||||
|
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||||
|
import com.ruoyi.common.helper.LoginHelper;
|
||||||
|
import com.ruoyi.oa.im.ImSendService;
|
||||||
|
import com.ruoyi.system.service.ISysUserService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目进度
|
* 项目进度
|
||||||
@@ -38,6 +42,43 @@ import com.ruoyi.common.core.page.TableDataInfo;
|
|||||||
public class OaProjectScheduleController extends BaseController {
|
public class OaProjectScheduleController extends BaseController {
|
||||||
|
|
||||||
private final IOaProjectScheduleService iOaProjectScheduleService;
|
private final IOaProjectScheduleService iOaProjectScheduleService;
|
||||||
|
private final ImSendService imSendService;
|
||||||
|
private final ISysUserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 催促进度:给项目负责人发一条 IM 消息
|
||||||
|
*/
|
||||||
|
@PostMapping("/urge/{scheduleId}")
|
||||||
|
public R<Void> urgeProgress(@PathVariable Long scheduleId) {
|
||||||
|
OaProjectScheduleVo vo = iOaProjectScheduleService.queryById(scheduleId);
|
||||||
|
if (vo == null) {
|
||||||
|
return R.fail("项目进度不存在");
|
||||||
|
}
|
||||||
|
String header = vo.getHeader();
|
||||||
|
if (header == null || header.isEmpty()) {
|
||||||
|
return R.fail("该项目未设置负责人");
|
||||||
|
}
|
||||||
|
SysUser user = userService.selectUserByNickName(header);
|
||||||
|
if (user == null) {
|
||||||
|
return R.fail("找不到负责人 " + header + " 的账号");
|
||||||
|
}
|
||||||
|
// 防止自己催自己 → 还是发,但提示
|
||||||
|
String me = LoginHelper.getNickName();
|
||||||
|
String projectName = vo.getProjectName() == null ? "(未命名项目)" : vo.getProjectName();
|
||||||
|
String currentStep = vo.getCurrentStepName() == null ? "(无)" : vo.getCurrentStepName();
|
||||||
|
int percent = vo.getSchedulePercentage() == null ? 0 : vo.getSchedulePercentage().intValue();
|
||||||
|
long delay = vo.getDelayCount() == null ? 0 : vo.getDelayCount();
|
||||||
|
|
||||||
|
String title = "进度催促";
|
||||||
|
String desc = String.format("[%s] 催促您推进【%s】,当前 %d%%,当前步骤:%s%s",
|
||||||
|
me == null ? "系统" : me, projectName, percent, currentStep,
|
||||||
|
delay > 0 ? ",已有 " + delay + " 步延期" : "");
|
||||||
|
|
||||||
|
imSendService.sendToOaUser(user.getUserId(), title, desc,
|
||||||
|
"schedule", scheduleId, "/projectSchedule?scheduleId=" + scheduleId);
|
||||||
|
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询项目进度列表
|
* 查询项目进度列表
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ public class SysOaWarehouseMaster extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
|
/** 收货单/相关附件 OSS ID(逗号分隔) */
|
||||||
|
private String receiptDoc;
|
||||||
|
|
||||||
private Integer isLike;
|
private Integer isLike;
|
||||||
private Long status;
|
private Long status;
|
||||||
private Integer returnType;
|
private Integer returnType;
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ public class SysOaWarehouseMasterBo extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
|
/** 收货单/相关附件 OSS ID(逗号分隔) */
|
||||||
|
private String receiptDoc;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 涉及物料
|
* 涉及物料
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -97,5 +97,9 @@ public class SysOaWarehouseMasterVo {
|
|||||||
private Long totalQty;
|
private Long totalQty;
|
||||||
/** 物料概览(GROUP_CONCAT 名称) */
|
/** 物料概览(GROUP_CONCAT 名称) */
|
||||||
private String itemsSummary;
|
private String itemsSummary;
|
||||||
|
/** 收货单 OSS ID(CSV) */
|
||||||
|
private String receiptDoc;
|
||||||
|
/** 收货单文件信息(已联查 sys_oss,格式 ossId|name|url,, ) */
|
||||||
|
private String receiptFiles;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.ruoyi.oa.im;
|
||||||
|
|
||||||
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
|
import com.ruoyi.common.core.domain.R;
|
||||||
|
import com.ruoyi.common.helper.LoginHelper;
|
||||||
|
import com.ruoyi.oa.im.mapper.ImBindMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户的 IM 凭据(供 Web SDK 登录)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/system/user/im")
|
||||||
|
public class ImCredentialsController extends BaseController {
|
||||||
|
|
||||||
|
private final ImBindMapper bindMapper;
|
||||||
|
private final OpenImClient openImClient;
|
||||||
|
private final OpenImProperties props;
|
||||||
|
|
||||||
|
/** 当前用户 IM 登录所需信息:imUserId + token + apiUrl + wsUrl */
|
||||||
|
@GetMapping("/credentials")
|
||||||
|
public R<Map<String, Object>> credentials() {
|
||||||
|
Long userId = LoginHelper.getUserId();
|
||||||
|
ImBind bind = bindMapper.selectById(userId);
|
||||||
|
if (bind == null || bind.getImUserId() == null) {
|
||||||
|
return R.fail("当前账号未绑定 IM");
|
||||||
|
}
|
||||||
|
// platformId=5: Web
|
||||||
|
String token = openImClient.issueUserToken(bind.getImUserId(), 5);
|
||||||
|
if (token == null) {
|
||||||
|
return R.fail("获取 IM token 失败");
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("imUserId", bind.getImUserId());
|
||||||
|
data.put("imToken", token);
|
||||||
|
data.put("apiUrl", props.getPublicApiUrl());
|
||||||
|
data.put("wsUrl", props.getWsUrl());
|
||||||
|
return R.ok(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,20 +106,21 @@ public class OpenImClient {
|
|||||||
offlinePush.put("iOSPushSound", "default");
|
offlinePush.put("iOSPushSound", "default");
|
||||||
offlinePush.put("iOSBadgeCount", true);
|
offlinePush.put("iOSBadgeCount", true);
|
||||||
|
|
||||||
Map<String, Object> sendMsg = new HashMap<>();
|
// SendMsg 字段需要嵌套在 sendMessage 对象里(OpenIM v3.8 约定)
|
||||||
sendMsg.put("sendID", props.getNotificationSender());
|
Map<String, Object> sendMessage = new HashMap<>();
|
||||||
sendMsg.put("recvID", recvImUserId);
|
sendMessage.put("sendID", props.getNotificationSender());
|
||||||
sendMsg.put("senderNickname", "系统通知");
|
sendMessage.put("recvID", recvImUserId);
|
||||||
sendMsg.put("senderPlatformID", 1);
|
sendMessage.put("senderNickname", "系统通知");
|
||||||
sendMsg.put("content", content);
|
sendMessage.put("senderPlatformID", 1);
|
||||||
sendMsg.put("contentType", CUSTOM_CONTENT_TYPE);
|
sendMessage.put("content", content);
|
||||||
sendMsg.put("sessionType", SESSION_SINGLE);
|
sendMessage.put("contentType", CUSTOM_CONTENT_TYPE);
|
||||||
sendMsg.put("isOnlineOnly", false);
|
sendMessage.put("sessionType", SESSION_SINGLE);
|
||||||
sendMsg.put("notOfflinePush", false);
|
sendMessage.put("isOnlineOnly", false);
|
||||||
sendMsg.put("offlinePushInfo", offlinePush);
|
sendMessage.put("notOfflinePush", false);
|
||||||
|
sendMessage.put("offlinePushInfo", offlinePush);
|
||||||
|
|
||||||
Map<String, Object> body = new HashMap<>();
|
Map<String, Object> body = new HashMap<>();
|
||||||
body.put("sendMsg", sendMsg);
|
body.put("sendMessage", sendMessage);
|
||||||
|
|
||||||
JSONObject resp = postJson(props.getApiUrl() + "/msg/send_msg", body, getAdminToken());
|
JSONObject resp = postJson(props.getApiUrl() + "/msg/send_msg", body, getAdminToken());
|
||||||
Integer errCode = resp.getInteger("errCode");
|
Integer errCode = resp.getInteger("errCode");
|
||||||
@@ -130,6 +131,26 @@ public class OpenImClient {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用 admin 身份签发 IM 用户 token(Web SDK 登录需要)。
|
||||||
|
* @param imUserId IM userID
|
||||||
|
* @param platformId 平台编号(5=Web)
|
||||||
|
*/
|
||||||
|
public String issueUserToken(String imUserId, int platformId) {
|
||||||
|
if (!props.isEnabled()) { return null; }
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("secret", props.getSecret());
|
||||||
|
body.put("platformID", platformId);
|
||||||
|
body.put("userID", imUserId);
|
||||||
|
JSONObject resp = postJson(props.getApiUrl() + "/auth/get_user_token", body, getAdminToken());
|
||||||
|
JSONObject data = resp.getJSONObject("data");
|
||||||
|
if (data == null) {
|
||||||
|
log.warn("[OpenIM] issueUserToken failed: {}", resp);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.getString("token");
|
||||||
|
}
|
||||||
|
|
||||||
/** chat 后端 admin token */
|
/** chat 后端 admin token */
|
||||||
public String getChatAdminToken() {
|
public String getChatAdminToken() {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
|
|||||||
@@ -14,14 +14,20 @@ import org.springframework.stereotype.Component;
|
|||||||
@ConfigurationProperties(prefix = "openim")
|
@ConfigurationProperties(prefix = "openim")
|
||||||
public class OpenImProperties {
|
public class OpenImProperties {
|
||||||
|
|
||||||
/** OpenIM 核心 API 地址 */
|
/** OpenIM 核心 API 地址(走 nginx 网关,:10002 直连端口外网未开放) */
|
||||||
private String apiUrl = "http://49.232.154.205:10002";
|
private String apiUrl = "http://49.232.154.205:10006/api";
|
||||||
|
|
||||||
/** OpenIM chat 业务 API 地址 */
|
/** OpenIM WS 长连接地址(前端 SDK 用) */
|
||||||
private String chatUrl = "http://49.232.154.205:10008";
|
private String wsUrl = "ws://49.232.154.205:10006/msg_gateway";
|
||||||
|
|
||||||
/** OpenIM chat 管理 API 地址(注册新用户、admin login) */
|
/** 前端 SDK 可以直接访问的 API 地址(如果跟后端调用地址不同) */
|
||||||
private String chatAdminUrl = "http://49.232.154.205:10009";
|
private String publicApiUrl = "http://49.232.154.205:10006/api";
|
||||||
|
|
||||||
|
/** OpenIM chat 业务 API 地址(走 nginx 网关) */
|
||||||
|
private String chatUrl = "http://49.232.154.205:10006/chat";
|
||||||
|
|
||||||
|
/** OpenIM chat 管理 API 地址(走 nginx 网关) */
|
||||||
|
private String chatAdminUrl = "http://49.232.154.205:10006/chat";
|
||||||
|
|
||||||
/** chat 后端 admin 账号 */
|
/** chat 后端 admin 账号 */
|
||||||
private String chatAdminAccount = "chatAdmin";
|
private String chatAdminAccount = "chatAdmin";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ruoyi.oa.mapper;
|
package com.ruoyi.oa.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Constants;
|
import com.baomidou.mybatisplus.core.toolkit.Constants;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
@@ -18,5 +19,7 @@ import java.util.Map;
|
|||||||
*/
|
*/
|
||||||
public interface OaProjectScheduleMapper extends BaseMapperPlus<OaProjectScheduleMapper, OaProjectSchedule, OaProjectScheduleVo> {
|
public interface OaProjectScheduleMapper extends BaseMapperPlus<OaProjectScheduleMapper, OaProjectSchedule, OaProjectScheduleVo> {
|
||||||
|
|
||||||
|
/** dataPermission="true" 让 PlusDataPermissionInterceptor 跳过解析(SQL 太复杂 JsqlParser 不识别) */
|
||||||
|
@InterceptorIgnore(dataPermission = "true")
|
||||||
Page<OaProjectScheduleVo> selectVoPagePlus(@Param("page") Page<OaProjectScheduleVo> build,@Param(Constants.WRAPPER) QueryWrapper<OaProjectSchedule> lqw);
|
Page<OaProjectScheduleVo> selectVoPagePlus(@Param("page") Page<OaProjectScheduleVo> build,@Param(Constants.WRAPPER) QueryWrapper<OaProjectSchedule> lqw);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.ruoyi.oa.suggestion;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户修改意见(区别于已有的 oa_feedback 问题反馈,本表为顶栏入口提交的改进意见)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("sys_oa_feedback")
|
||||||
|
public class UserSuggestion {
|
||||||
|
|
||||||
|
@TableId(value = "feedback_id")
|
||||||
|
private Long feedbackId;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
private String content;
|
||||||
|
private String category;
|
||||||
|
private Integer priority;
|
||||||
|
private String pagePath;
|
||||||
|
private String attachment;
|
||||||
|
|
||||||
|
private Long submitterId;
|
||||||
|
private String submitterName;
|
||||||
|
|
||||||
|
/** 0待处理 1已受理 2已完成 3已关闭 */
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private Long handlerId;
|
||||||
|
private String handlerName;
|
||||||
|
private String acceptRemark;
|
||||||
|
private Date acceptTime;
|
||||||
|
private Date finishTime;
|
||||||
|
|
||||||
|
private String createBy;
|
||||||
|
private Date createTime;
|
||||||
|
private String updateBy;
|
||||||
|
private Date updateTime;
|
||||||
|
private Integer delFlag;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.ruoyi.oa.suggestion;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
|
import com.ruoyi.common.core.domain.PageQuery;
|
||||||
|
import com.ruoyi.common.core.domain.R;
|
||||||
|
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||||
|
import com.ruoyi.common.core.page.TableDataInfo;
|
||||||
|
import com.ruoyi.common.helper.LoginHelper;
|
||||||
|
import com.ruoyi.oa.im.ImSendService;
|
||||||
|
import com.ruoyi.oa.suggestion.mapper.UserSuggestionMapper;
|
||||||
|
import com.ruoyi.system.mapper.SysUserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户修改意见
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/oa/suggestion")
|
||||||
|
public class UserSuggestionController extends BaseController {
|
||||||
|
|
||||||
|
/** 信息化部 dept_id */
|
||||||
|
private static final Long IT_DEPT_ID = 1858416909042524161L;
|
||||||
|
|
||||||
|
private final UserSuggestionMapper mapper;
|
||||||
|
private final ImSendService imSendService;
|
||||||
|
private final SysUserMapper userMapper;
|
||||||
|
|
||||||
|
@GetMapping("/list")
|
||||||
|
public TableDataInfo<UserSuggestion> list(UserSuggestion query, PageQuery pageQuery) {
|
||||||
|
LambdaQueryWrapper<UserSuggestion> qw = Wrappers.lambdaQuery();
|
||||||
|
qw.eq(UserSuggestion::getDelFlag, 0);
|
||||||
|
Long uid = LoginHelper.getUserId();
|
||||||
|
if (!isItDeptMember(uid)) {
|
||||||
|
qw.eq(UserSuggestion::getSubmitterId, uid);
|
||||||
|
}
|
||||||
|
if (query.getStatus() != null) qw.eq(UserSuggestion::getStatus, query.getStatus());
|
||||||
|
if (query.getCategory() != null && !query.getCategory().isEmpty()) {
|
||||||
|
qw.eq(UserSuggestion::getCategory, query.getCategory());
|
||||||
|
}
|
||||||
|
if (query.getTitle() != null && !query.getTitle().isEmpty()) {
|
||||||
|
qw.like(UserSuggestion::getTitle, query.getTitle());
|
||||||
|
}
|
||||||
|
qw.orderByDesc(UserSuggestion::getCreateTime);
|
||||||
|
Page<UserSuggestion> result = mapper.selectPage(pageQuery.build(), qw);
|
||||||
|
return TableDataInfo.build(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public R<Void> submit(@RequestBody UserSuggestion body) {
|
||||||
|
Long uid = LoginHelper.getUserId();
|
||||||
|
String name = LoginHelper.getNickName();
|
||||||
|
body.setFeedbackId(null);
|
||||||
|
body.setSubmitterId(uid);
|
||||||
|
body.setSubmitterName(name);
|
||||||
|
body.setStatus(0);
|
||||||
|
body.setCreateBy(name);
|
||||||
|
body.setCreateTime(new Date());
|
||||||
|
body.setDelFlag(0);
|
||||||
|
mapper.insert(body);
|
||||||
|
|
||||||
|
List<SysUser> itUsers = userMapper.selectList(
|
||||||
|
Wrappers.<SysUser>lambdaQuery()
|
||||||
|
.eq(SysUser::getDeptId, IT_DEPT_ID)
|
||||||
|
.eq(SysUser::getDelFlag, "0"));
|
||||||
|
String title = "新的修改意见";
|
||||||
|
String desc = String.format("[%s] %s", name == null ? "用户" : name,
|
||||||
|
body.getTitle() == null ? "未命名" : body.getTitle());
|
||||||
|
for (SysUser u : itUsers) {
|
||||||
|
if (u.getUserId() == null || u.getUserId().equals(uid)) continue;
|
||||||
|
imSendService.sendToOaUser(u.getUserId(), title, desc,
|
||||||
|
"suggestion", body.getFeedbackId(),
|
||||||
|
"/system/feedback?id=" + body.getFeedbackId());
|
||||||
|
}
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/accept")
|
||||||
|
public R<Void> accept(@PathVariable Long id, @RequestParam(required = false) String remark) {
|
||||||
|
UserSuggestion fb = mapper.selectById(id);
|
||||||
|
if (fb == null) return R.fail("不存在");
|
||||||
|
if (!isItDeptMember(LoginHelper.getUserId())) return R.fail("仅信息化部可操作");
|
||||||
|
fb.setStatus(1);
|
||||||
|
fb.setHandlerId(LoginHelper.getUserId());
|
||||||
|
fb.setHandlerName(LoginHelper.getNickName());
|
||||||
|
fb.setAcceptRemark(remark);
|
||||||
|
fb.setAcceptTime(new Date());
|
||||||
|
mapper.updateById(fb);
|
||||||
|
if (fb.getSubmitterId() != null) {
|
||||||
|
imSendService.sendToOaUser(fb.getSubmitterId(),
|
||||||
|
"您的意见已受理",
|
||||||
|
String.format("[%s] 受理了您的意见:%s%s",
|
||||||
|
fb.getHandlerName() == null ? "信息化部" : fb.getHandlerName(),
|
||||||
|
fb.getTitle(),
|
||||||
|
remark == null || remark.isEmpty() ? "" : "(备注:" + remark + ")"),
|
||||||
|
"suggestion", id, "/system/feedback?id=" + id);
|
||||||
|
}
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/finish")
|
||||||
|
public R<Void> finish(@PathVariable Long id, @RequestParam(required = false) String remark) {
|
||||||
|
UserSuggestion fb = mapper.selectById(id);
|
||||||
|
if (fb == null) return R.fail("不存在");
|
||||||
|
if (!isItDeptMember(LoginHelper.getUserId())) return R.fail("仅信息化部可操作");
|
||||||
|
fb.setStatus(2);
|
||||||
|
fb.setFinishTime(new Date());
|
||||||
|
if (remark != null) fb.setAcceptRemark(remark);
|
||||||
|
mapper.updateById(fb);
|
||||||
|
if (fb.getSubmitterId() != null) {
|
||||||
|
imSendService.sendToOaUser(fb.getSubmitterId(),
|
||||||
|
"您的意见已完成",
|
||||||
|
String.format("[%s] 已处理完毕:%s", fb.getHandlerName(), fb.getTitle()),
|
||||||
|
"suggestion", id, "/system/feedback?id=" + id);
|
||||||
|
}
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/close")
|
||||||
|
public R<Void> close(@PathVariable Long id, @RequestParam(required = false) String remark) {
|
||||||
|
UserSuggestion fb = mapper.selectById(id);
|
||||||
|
if (fb == null) return R.fail("不存在");
|
||||||
|
Long uid = LoginHelper.getUserId();
|
||||||
|
if (!isItDeptMember(uid) && !uid.equals(fb.getSubmitterId())) return R.fail("无权关闭");
|
||||||
|
fb.setStatus(3);
|
||||||
|
if (remark != null) fb.setAcceptRemark(remark);
|
||||||
|
mapper.updateById(fb);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/isItMember")
|
||||||
|
public R<Boolean> isItMember() {
|
||||||
|
return R.ok(isItDeptMember(LoginHelper.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isItDeptMember(Long userId) {
|
||||||
|
if (userId == null) return false;
|
||||||
|
SysUser u = userMapper.selectById(userId);
|
||||||
|
return u != null && IT_DEPT_ID.equals(u.getDeptId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ruoyi.oa.suggestion.mapper;
|
||||||
|
|
||||||
|
import com.ruoyi.common.core.mapper.BaseMapperPlus;
|
||||||
|
import com.ruoyi.oa.suggestion.UserSuggestion;
|
||||||
|
|
||||||
|
public interface UserSuggestionMapper extends BaseMapperPlus<UserSuggestionMapper, UserSuggestion, UserSuggestion> {
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
|
|
||||||
|
<select id="selectVoPagePlus_COUNT" resultType="java.lang.Long">
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM oa_project_schedule AS ops
|
||||||
|
LEFT JOIN sys_oa_project AS op ON ops.project_id = op.project_id
|
||||||
|
${ew.getCustomSqlSegment}
|
||||||
|
</select>
|
||||||
|
|
||||||
<select id="selectVoPagePlus" resultType="com.ruoyi.oa.domain.vo.OaProjectScheduleVo">
|
<select id="selectVoPagePlus" resultType="com.ruoyi.oa.domain.vo.OaProjectScheduleVo">
|
||||||
SELECT ops.schedule_id,
|
SELECT ops.schedule_id,
|
||||||
ops.project_id,
|
ops.project_id,
|
||||||
@@ -32,7 +39,6 @@
|
|||||||
ops.status,
|
ops.status,
|
||||||
ops.steward,
|
ops.steward,
|
||||||
ops.remark,
|
ops.remark,
|
||||||
/* ======================== 项目信息 ==================== */
|
|
||||||
op.project_id AS opProjectId,
|
op.project_id AS opProjectId,
|
||||||
op.project_name,
|
op.project_name,
|
||||||
op.project_num,
|
op.project_num,
|
||||||
@@ -48,22 +54,33 @@
|
|||||||
op.trade_type,
|
op.trade_type,
|
||||||
op.project_code,
|
op.project_code,
|
||||||
op.pre_pay,
|
op.pre_pay,
|
||||||
op.is_top AS isTop,
|
op.is_top AS isTop,
|
||||||
(SELECT COUNT(*) FROM oa_project_schedule_step opss
|
op.functionary AS header,
|
||||||
WHERE opss.schedule_id = ops.schedule_id AND opss.del_flag = '0') AS totalCount,
|
cs.step_name AS currentStepName,
|
||||||
(SELECT COUNT(*) FROM oa_project_schedule_step opss
|
IFNULL(agg.total_count, 0) AS totalCount,
|
||||||
WHERE opss.schedule_id = ops.schedule_id AND opss.del_flag = '0' AND opss.status IN (0,1)) AS unFinishCount,
|
IFNULL(agg.unfinish_count, 0) AS unFinishCount,
|
||||||
(SELECT COUNT(*) FROM oa_project_schedule_step opss
|
IFNULL(agg.delay_count, 0) AS delayCount,
|
||||||
WHERE opss.schedule_id = ops.schedule_id AND opss.del_flag = '0'
|
IFNULL(agg.percent, 0) AS schedulePercentage
|
||||||
AND (
|
|
||||||
opss.use_flag = 0
|
|
||||||
OR (opss.original_end_time IS NOT NULL AND opss.plan_end IS NOT NULL AND opss.plan_end > opss.original_end_time)
|
|
||||||
OR (opss.status = 0 AND opss.original_end_time IS NOT NULL AND CURDATE() > DATE(opss.original_end_time))
|
|
||||||
)
|
|
||||||
) AS delayCount
|
|
||||||
FROM oa_project_schedule AS ops
|
FROM oa_project_schedule AS ops
|
||||||
LEFT JOIN sys_oa_project AS op
|
LEFT JOIN sys_oa_project AS op
|
||||||
ON ops.project_id = op.project_id
|
ON ops.project_id = op.project_id
|
||||||
|
LEFT JOIN oa_project_schedule_step cs
|
||||||
|
ON cs.track_id = ops.current_step
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
opss.schedule_id,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
SUM(CASE WHEN opss.status IN (0,1) THEN 1 ELSE 0 END) AS unfinish_count,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN opss.use_flag = 0
|
||||||
|
OR (opss.original_end_time IS NOT NULL AND opss.plan_end IS NOT NULL AND opss.plan_end > opss.original_end_time)
|
||||||
|
OR (opss.status = 0 AND opss.original_end_time IS NOT NULL AND CURDATE() > DATE(opss.original_end_time))
|
||||||
|
THEN 1 ELSE 0 END) AS delay_count,
|
||||||
|
ROUND(SUM(CASE WHEN opss.status = 2 THEN 1 ELSE 0 END) / COUNT(*) * 100, 1) AS percent
|
||||||
|
FROM oa_project_schedule_step opss
|
||||||
|
WHERE opss.del_flag = '0'
|
||||||
|
GROUP BY opss.schedule_id
|
||||||
|
) AS agg ON agg.schedule_id = ops.schedule_id
|
||||||
${ew.getCustomSqlSegment}
|
${ew.getCustomSqlSegment}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
SELECT GROUP_CONCAT(CONCAT_WS('|', o.oss_id, COALESCE(o.original_name, o.file_name), o.url)
|
SELECT GROUP_CONCAT(CONCAT_WS('|', o.oss_id, COALESCE(o.original_name, o.file_name), o.url)
|
||||||
ORDER BY FIND_IN_SET(o.oss_id, r.accessory) SEPARATOR ',,')
|
ORDER BY FIND_IN_SET(o.oss_id, r.accessory) SEPARATOR ',,')
|
||||||
FROM sys_oss o
|
FROM sys_oss o
|
||||||
WHERE r.accessory IS NOT NULL AND r.accessory <> ''
|
WHERE r.accessory IS NOT NULL AND r.accessory != ''
|
||||||
AND FIND_IN_SET(o.oss_id, r.accessory)
|
AND FIND_IN_SET(o.oss_id, r.accessory)
|
||||||
) AS accessory_files
|
) AS accessory_files
|
||||||
FROM oa_requirements r
|
FROM oa_requirements r
|
||||||
@@ -52,7 +52,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
SELECT GROUP_CONCAT(CONCAT_WS('|', o.oss_id, COALESCE(o.original_name, o.file_name), o.url)
|
SELECT GROUP_CONCAT(CONCAT_WS('|', o.oss_id, COALESCE(o.original_name, o.file_name), o.url)
|
||||||
ORDER BY FIND_IN_SET(o.oss_id, r.accessory) SEPARATOR ',,')
|
ORDER BY FIND_IN_SET(o.oss_id, r.accessory) SEPARATOR ',,')
|
||||||
FROM sys_oss o
|
FROM sys_oss o
|
||||||
WHERE r.accessory IS NOT NULL AND r.accessory <> ''
|
WHERE r.accessory IS NOT NULL AND r.accessory != ''
|
||||||
AND FIND_IN_SET(o.oss_id, r.accessory)
|
AND FIND_IN_SET(o.oss_id, r.accessory)
|
||||||
) AS accessory_files
|
) AS accessory_files
|
||||||
FROM oa_requirements r
|
FROM oa_requirements r
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
<result property="itemCount" column="item_count"/>
|
<result property="itemCount" column="item_count"/>
|
||||||
<result property="totalQty" column="total_qty"/>
|
<result property="totalQty" column="total_qty"/>
|
||||||
<result property="itemsSummary" column="items_summary"/>
|
<result property="itemsSummary" column="items_summary"/>
|
||||||
|
<result property="receiptDoc" column="receipt_doc"/>
|
||||||
|
<result property="receiptFiles" column="receipt_files"/>
|
||||||
|
|
||||||
<collection property="warehouseList"
|
<collection property="warehouseList"
|
||||||
column="master_id"
|
column="master_id"
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
sowm.sign_time,
|
sowm.sign_time,
|
||||||
sowm.sign_user,
|
sowm.sign_user,
|
||||||
sowm.remark,
|
sowm.remark,
|
||||||
|
sowm.receipt_doc,
|
||||||
sowm.status,
|
sowm.status,
|
||||||
sowm.is_like,
|
sowm.is_like,
|
||||||
sowm.requirement_id,
|
sowm.requirement_id,
|
||||||
@@ -64,7 +67,14 @@
|
|||||||
req.title AS requirementName,
|
req.title AS requirementName,
|
||||||
agg.item_count,
|
agg.item_count,
|
||||||
agg.total_qty,
|
agg.total_qty,
|
||||||
agg.items_summary
|
agg.items_summary,
|
||||||
|
(
|
||||||
|
SELECT GROUP_CONCAT(CONCAT_WS('|', o.oss_id, COALESCE(o.original_name, o.file_name), o.url)
|
||||||
|
ORDER BY FIND_IN_SET(o.oss_id, sowm.receipt_doc) SEPARATOR ',,')
|
||||||
|
FROM sys_oss o
|
||||||
|
WHERE sowm.receipt_doc IS NOT NULL AND sowm.receipt_doc != ''
|
||||||
|
AND FIND_IN_SET(o.oss_id, sowm.receipt_doc)
|
||||||
|
) AS receipt_files
|
||||||
FROM sys_oa_warehouse_master sowm
|
FROM sys_oa_warehouse_master sowm
|
||||||
LEFT JOIN sys_oa_project sop ON sop.project_id = sowm.project_id
|
LEFT JOIN sys_oa_project sop ON sop.project_id = sowm.project_id
|
||||||
LEFT JOIN oa_requirements req ON req.requirement_id = sowm.requirement_id
|
LEFT JOIN oa_requirements req ON req.requirement_id = sowm.requirement_id
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"@handsontable/vue": "^15.3.0",
|
"@handsontable/vue": "^15.3.0",
|
||||||
"@jiaminghi/data-view": "^2.10.0",
|
"@jiaminghi/data-view": "^2.10.0",
|
||||||
"@micro-zoe/micro-app": "^1.0.0-rc.26",
|
"@micro-zoe/micro-app": "^1.0.0-rc.26",
|
||||||
|
"@openim/wasm-client-sdk": "^3.8.3",
|
||||||
"@riophae/vue-treeselect": "0.4.0",
|
"@riophae/vue-treeselect": "0.4.0",
|
||||||
"@vue-office/docx": "^1.6.3",
|
"@vue-office/docx": "^1.6.3",
|
||||||
"@vue-office/excel": "^1.7.14",
|
"@vue-office/excel": "^1.7.14",
|
||||||
@@ -117,6 +118,7 @@
|
|||||||
"sass": "1.32.13",
|
"sass": "1.32.13",
|
||||||
"sass-loader": "10.1.1",
|
"sass-loader": "10.1.1",
|
||||||
"script-ext-html-webpack-plugin": "2.1.5",
|
"script-ext-html-webpack-plugin": "2.1.5",
|
||||||
|
"string-replace-loader": "^2.3.0",
|
||||||
"svg-sprite-loader": "5.1.1",
|
"svg-sprite-loader": "5.1.1",
|
||||||
"vue-template-compiler": "2.6.12"
|
"vue-template-compiler": "2.6.12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,6 +204,8 @@
|
|||||||
<script>
|
<script>
|
||||||
window.__MICRO_APP_ENVIRONMENT__ = true
|
window.__MICRO_APP_ENVIRONMENT__ = true
|
||||||
</script>
|
</script>
|
||||||
|
<!-- OpenIM Web SDK 需要 Go WASM 运行时(必须在 SDK 之前加载) -->
|
||||||
|
<script src="<%= BASE_URL %>wasm_exec.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
ruoyi-ui/public/openIM.wasm
Normal file
BIN
ruoyi-ui/public/openIM.wasm
Normal file
Binary file not shown.
BIN
ruoyi-ui/public/sql-wasm.wasm
Executable file
BIN
ruoyi-ui/public/sql-wasm.wasm
Executable file
Binary file not shown.
561
ruoyi-ui/public/wasm_exec.js
Normal file
561
ruoyi-ui/public/wasm_exec.js
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
1
ruoyi-ui/public/worker-legacy.js
Normal file
1
ruoyi-ui/public/worker-legacy.js
Normal file
File diff suppressed because one or more lines are too long
1
ruoyi-ui/public/worker.js
Normal file
1
ruoyi-ui/public/worker.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,5 +1,13 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 催促进度:给项目负责人发 IM 消息
|
||||||
|
export function urgeProgress (scheduleId) {
|
||||||
|
return request({
|
||||||
|
url: '/oa/projectSchedule/urge/' + scheduleId,
|
||||||
|
method: 'post'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 查询项目进度列表
|
// 查询项目进度列表
|
||||||
export function listProjectSchedule (query) {
|
export function listProjectSchedule (query) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
25
ruoyi-ui/src/api/oa/suggestion.js
Normal file
25
ruoyi-ui/src/api/oa/suggestion.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function listSuggestion (query) {
|
||||||
|
return request({ url: '/oa/suggestion/list', method: 'get', params: query })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitSuggestion (data) {
|
||||||
|
return request({ url: '/oa/suggestion', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acceptSuggestion (id, remark) {
|
||||||
|
return request({ url: `/oa/suggestion/${id}/accept`, method: 'put', params: { remark } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishSuggestion (id, remark) {
|
||||||
|
return request({ url: `/oa/suggestion/${id}/finish`, method: 'put', params: { remark } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSuggestion (id, remark) {
|
||||||
|
return request({ url: `/oa/suggestion/${id}/close`, method: 'put', params: { remark } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isItMember () {
|
||||||
|
return request({ url: '/oa/suggestion/isItMember', method: 'get' })
|
||||||
|
}
|
||||||
9
ruoyi-ui/src/api/system/im.js
Normal file
9
ruoyi-ui/src/api/system/im.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 当前用户 IM 登录凭据
|
||||||
|
export function getImCredentials () {
|
||||||
|
return request({
|
||||||
|
url: '/system/user/im/credentials',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -24,16 +24,20 @@ body {
|
|||||||
|
|
||||||
// 表单 / 输入 / 按钮 紧凑
|
// 表单 / 输入 / 按钮 紧凑
|
||||||
.el-form-item {
|
.el-form-item {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.el-form-item__label {
|
.el-form-item__label {
|
||||||
font-size: 12px;
|
font-size: 12px !important;
|
||||||
line-height: 30px;
|
font-weight: normal !important;
|
||||||
padding: 0 8px 0 0;
|
color: #606266;
|
||||||
}
|
line-height: 26px !important;
|
||||||
.el-form-item--small.el-form-item {
|
padding: 0 6px 0 0 !important;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
.el-form-item--small .el-form-item__label,
|
||||||
|
.el-form-item--medium .el-form-item__label { font-size: 12px !important; line-height: 26px !important; }
|
||||||
|
.el-form-item--mini .el-form-item__label { font-size: 11px !important; line-height: 22px !important; }
|
||||||
|
.el-form-item--small.el-form-item { margin-bottom: 8px; }
|
||||||
|
.el-form-item--mini.el-form-item { margin-bottom: 6px; }
|
||||||
/* 全局输入控件统一压低高度(含 input/select/cascader/date/range/autocomplete) */
|
/* 全局输入控件统一压低高度(含 input/select/cascader/date/range/autocomplete) */
|
||||||
.el-input__inner,
|
.el-input__inner,
|
||||||
.el-textarea__inner,
|
.el-textarea__inner,
|
||||||
@@ -45,39 +49,56 @@ body {
|
|||||||
.el-date-editor--datetimerange.el-range-editor,
|
.el-date-editor--datetimerange.el-range-editor,
|
||||||
.el-date-editor--monthrange.el-range-editor,
|
.el-date-editor--monthrange.el-range-editor,
|
||||||
.el-range-editor.el-input__inner {
|
.el-range-editor.el-input__inner {
|
||||||
height: 28px !important;
|
height: 26px !important;
|
||||||
line-height: 28px !important;
|
line-height: 26px !important;
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
padding: 0 8px !important;
|
padding: 0 8px !important;
|
||||||
}
|
}
|
||||||
.el-input--medium .el-input__inner { height: 30px !important; line-height: 30px !important; }
|
.el-input--medium .el-input__inner { height: 28px !important; line-height: 28px !important; }
|
||||||
.el-input--small .el-input__inner { height: 28px !important; line-height: 28px !important; }
|
.el-input--small .el-input__inner { height: 26px !important; line-height: 26px !important; }
|
||||||
.el-input--mini .el-input__inner { height: 24px !important; line-height: 24px !important; font-size: 12px !important; }
|
.el-input--mini .el-input__inner { height: 22px !important; line-height: 22px !important; font-size: 12px !important; }
|
||||||
.el-input--mini.el-input,
|
.el-input--mini.el-input,
|
||||||
.el-input--mini .el-input__suffix,
|
.el-input--mini .el-input__suffix,
|
||||||
.el-input--mini .el-input__prefix { line-height: 24px !important; }
|
.el-input--mini .el-input__prefix { line-height: 22px !important; }
|
||||||
.el-range-editor.el-input__inner { padding: 0 10px !important; }
|
.el-range-editor.el-input__inner { padding: 0 10px !important; }
|
||||||
.el-range-editor .el-range-input { font-size: 12px !important; height: 100% !important; line-height: 1 !important; }
|
.el-range-editor .el-range-input { font-size: 12px !important; height: 100% !important; line-height: 1 !important; }
|
||||||
.el-range-editor .el-range-separator { line-height: 26px !important; font-size: 12px !important; }
|
.el-range-editor .el-range-separator { line-height: 24px !important; font-size: 12px !important; }
|
||||||
.el-input__icon { line-height: 28px !important; }
|
.el-input__icon { line-height: 26px !important; }
|
||||||
.el-input__suffix-inner .el-input__icon { line-height: 28px !important; }
|
.el-input__suffix-inner .el-input__icon { line-height: 26px !important; }
|
||||||
.el-form-item__content { line-height: 28px; }
|
.el-form-item__content { line-height: 26px; }
|
||||||
|
|
||||||
|
/* 按钮:默认 26px 高,mini 22px,medium 28px */
|
||||||
.el-button {
|
.el-button {
|
||||||
padding: 7px 12px;
|
padding: 5px 10px !important;
|
||||||
font-size: 12px;
|
font-size: 12px !important;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.el-button--mini {
|
||||||
|
padding: 3px 8px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
height: 22px;
|
||||||
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
.el-button--mini,
|
|
||||||
.el-button--small {
|
.el-button--small {
|
||||||
padding: 6px 10px;
|
padding: 4px 10px !important;
|
||||||
font-size: 12px;
|
font-size: 12px !important;
|
||||||
|
height: 24px;
|
||||||
}
|
}
|
||||||
.el-button--medium {
|
.el-button--medium {
|
||||||
padding: 8px 14px;
|
padding: 6px 12px !important;
|
||||||
font-size: 12px;
|
font-size: 12px !important;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.el-button [class*="el-icon-"] + span {
|
||||||
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
.el-button + .el-button {
|
.el-button + .el-button {
|
||||||
margin-left: 8px;
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
/* 文字按钮去掉额外 padding */
|
||||||
|
.el-button--text {
|
||||||
|
padding: 2px 4px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表格紧凑
|
// 表格紧凑
|
||||||
@@ -143,15 +164,36 @@ body {
|
|||||||
|
|
||||||
// Tabs / Tags
|
// Tabs / Tags
|
||||||
.el-tabs__item {
|
.el-tabs__item {
|
||||||
height: 36px;
|
height: 30px;
|
||||||
line-height: 36px;
|
line-height: 30px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.el-tag {
|
.el-tag {
|
||||||
height: 22px;
|
height: 18px !important;
|
||||||
line-height: 20px;
|
line-height: 16px !important;
|
||||||
padding: 0 8px;
|
padding: 0 6px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.el-tag.el-tag--mini {
|
||||||
|
height: 16px !important;
|
||||||
|
line-height: 14px !important;
|
||||||
|
padding: 0 5px !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
.el-tag .el-tag__close {
|
||||||
|
font-size: 11px;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
.el-checkbox__inner,
|
||||||
|
.el-radio__inner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
.el-radio__label,
|
||||||
|
.el-checkbox__label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
padding-left: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 描述列表 / 步骤
|
// 描述列表 / 步骤
|
||||||
@@ -194,19 +236,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 圆角按钮/输入框
|
// 圆角按钮/输入框
|
||||||
.el-button {
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.el-input__inner,
|
.el-input__inner,
|
||||||
.el-textarea__inner {
|
.el-textarea__inner {
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.el-card {
|
.el-card {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 注意:原本全局 label { font-weight: 700 } 会把所有表单 label 加粗看上去傻大,去掉 */
|
||||||
label {
|
label {
|
||||||
font-weight: 700;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -372,30 +412,10 @@ aside {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* 全局按钮缩小:默认按钮等比 small 化 */
|
/* 旧的按钮缩小块已合并到顶部 .el-button 主样式,这里不再重复 */
|
||||||
.el-button {
|
|
||||||
padding: 7px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.el-button--medium {
|
|
||||||
padding: 8px 14px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.el-button--small {
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.el-button--mini {
|
|
||||||
padding: 5px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.el-button [class*="el-icon-"] + span {
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
.el-button-group > .el-button {
|
.el-button-group > .el-button {
|
||||||
padding-left: 10px;
|
padding-left: 8px !important;
|
||||||
padding-right: 10px;
|
padding-right: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表页搜索表单紧凑模式(加 class="compact-search" 即可生效) */
|
/* 列表页搜索表单紧凑模式(加 class="compact-search" 即可生效) */
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import MiniList from './components/MiniList.vue';
|
|||||||
import ExpressQuestionList from './modules/ExpressQuestionList.vue';
|
import ExpressQuestionList from './modules/ExpressQuestionList.vue';
|
||||||
import FeedbackList from './modules/FeedbackList.vue';
|
import FeedbackList from './modules/FeedbackList.vue';
|
||||||
import FinancialCharts from './modules/FinancialCharts.vue';
|
import FinancialCharts from './modules/FinancialCharts.vue';
|
||||||
|
import ImChatPanel from './modules/ImChatPanel.vue';
|
||||||
|
import MyProgressList from './modules/MyProgressList.vue';
|
||||||
import MyTaskList from './modules/MyTaskList.vue';
|
import MyTaskList from './modules/MyTaskList.vue';
|
||||||
import OwnerTaskList from './modules/OwnerTaskList.vue';
|
import OwnerTaskList from './modules/OwnerTaskList.vue';
|
||||||
import ProjectManagement from './modules/ProjectManagement.vue';
|
import ProjectManagement from './modules/ProjectManagement.vue';
|
||||||
@@ -12,7 +14,9 @@ export {
|
|||||||
ExpressQuestionList,
|
ExpressQuestionList,
|
||||||
FeedbackList,
|
FeedbackList,
|
||||||
FinancialCharts,
|
FinancialCharts,
|
||||||
|
ImChatPanel,
|
||||||
MiniList,
|
MiniList,
|
||||||
|
MyProgressList,
|
||||||
MyTaskList,
|
MyTaskList,
|
||||||
OwnerTaskList, ProjectManagement, RequirementList
|
OwnerTaskList, ProjectManagement, RequirementList
|
||||||
};
|
};
|
||||||
|
|||||||
270
ruoyi-ui/src/components/HomeModules/modules/ImChatPanel.vue
Normal file
270
ruoyi-ui/src/components/HomeModules/modules/ImChatPanel.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div class="im-chat-panel" v-loading="loading">
|
||||||
|
<div v-if="errorMsg" class="im-error">
|
||||||
|
<i class="el-icon-warning-outline"></i>
|
||||||
|
{{ errorMsg }}
|
||||||
|
<el-button v-if="canRetry" type="text" @click="init">重试</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="im-layout">
|
||||||
|
<!-- 左侧会话列表 -->
|
||||||
|
<div class="conv-list">
|
||||||
|
<div v-if="!conversations.length && !loading" class="empty">暂无会话</div>
|
||||||
|
<div v-for="conv in conversations" :key="conv.conversationID"
|
||||||
|
class="conv-item" :class="{ active: current && current.conversationID === conv.conversationID }"
|
||||||
|
@click="openConv(conv)">
|
||||||
|
<div class="conv-avatar" :style="avatarStyle(conv)">{{ avatarText(conv) }}</div>
|
||||||
|
<div class="conv-body">
|
||||||
|
<div class="conv-title">
|
||||||
|
<span class="conv-name">{{ conv.showName || '未命名' }}</span>
|
||||||
|
<span class="conv-time">{{ formatTime(conv.latestMsgSendTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="conv-snippet">
|
||||||
|
<span class="snippet">{{ snippet(conv) }}</span>
|
||||||
|
<span v-if="conv.unreadCount > 0" class="badge">{{ conv.unreadCount > 99 ? '99+' : conv.unreadCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧消息 -->
|
||||||
|
<div class="msg-pane">
|
||||||
|
<template v-if="current">
|
||||||
|
<div class="msg-header">{{ current.showName || '未命名' }}</div>
|
||||||
|
<div class="msg-list" ref="msgListRef">
|
||||||
|
<div v-for="m in messages" :key="m.clientMsgID"
|
||||||
|
class="msg-row" :class="{ mine: m.sendID === myUserId }">
|
||||||
|
<div class="msg-bubble">
|
||||||
|
<div class="msg-meta">{{ m.senderNickname || m.sendID }} · {{ formatTime(m.sendTime) }}</div>
|
||||||
|
<div class="msg-text">{{ renderText(m) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!messages.length" class="empty">无消息</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg-input">
|
||||||
|
<el-input v-model="draft" size="mini" placeholder="输入消息,回车发送"
|
||||||
|
@keyup.enter.native="send" />
|
||||||
|
<el-button type="primary" size="mini" :disabled="!draft.trim()" @click="send">发送</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty pick-conv">从左侧选择一个会话</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getImCredentials } from '@/api/system/im'
|
||||||
|
import { im, imBus, MessageType } from '@/utils/imClient'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ImChatPanel',
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
errorMsg: '',
|
||||||
|
canRetry: false,
|
||||||
|
conversations: [],
|
||||||
|
current: null,
|
||||||
|
messages: [],
|
||||||
|
draft: '',
|
||||||
|
myUserId: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.init()
|
||||||
|
imBus.$on('new-message', this.onNewMessage)
|
||||||
|
imBus.$on('conv-changed', this.refreshConversations)
|
||||||
|
imBus.$on('disconnected', () => { this.errorMsg = 'IM 断线,尝试重连…'; this.canRetry = true })
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
imBus.$off('new-message', this.onNewMessage)
|
||||||
|
imBus.$off('conv-changed', this.refreshConversations)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async init () {
|
||||||
|
this.loading = true
|
||||||
|
this.errorMsg = ''
|
||||||
|
try {
|
||||||
|
const res = await getImCredentials()
|
||||||
|
if (!res || res.code !== 200 || !res.data) {
|
||||||
|
this.errorMsg = (res && res.msg) || '获取 IM 凭据失败'
|
||||||
|
this.canRetry = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cred = res.data
|
||||||
|
const ok = await im.login(cred)
|
||||||
|
if (!ok) {
|
||||||
|
const e = im.lastError
|
||||||
|
const detail = e && (e.errMsg || e.message || JSON.stringify(e))
|
||||||
|
this.errorMsg = '登录 IM 失败:' + (detail || '未知原因(查看 Console)')
|
||||||
|
this.canRetry = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.myUserId = cred.imUserId
|
||||||
|
await this.refreshConversations()
|
||||||
|
} catch (e) {
|
||||||
|
this.errorMsg = e.message || '初始化失败'
|
||||||
|
this.canRetry = true
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshConversations () {
|
||||||
|
this.conversations = await im.getConversations(0, 50)
|
||||||
|
},
|
||||||
|
async openConv (conv) {
|
||||||
|
this.current = conv
|
||||||
|
this.messages = await im.getMessages(conv.conversationID, 30)
|
||||||
|
this.$nextTick(() => this.scrollBottom())
|
||||||
|
im.markRead(conv.conversationID)
|
||||||
|
},
|
||||||
|
async send () {
|
||||||
|
if (!this.current || !this.draft.trim()) return
|
||||||
|
const text = this.draft
|
||||||
|
this.draft = ''
|
||||||
|
try {
|
||||||
|
await im.sendText(this.current, text)
|
||||||
|
const newMessages = await im.getMessages(this.current.conversationID, 30)
|
||||||
|
this.messages = newMessages
|
||||||
|
this.$nextTick(() => this.scrollBottom())
|
||||||
|
} catch (e) {
|
||||||
|
this.$message.error('发送失败:' + (e.message || e))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNewMessage (msg) {
|
||||||
|
// 当前会话的消息追加
|
||||||
|
if (this.current && msg && this.current.conversationID === msg.conversationID) {
|
||||||
|
this.messages.push(msg)
|
||||||
|
this.$nextTick(() => this.scrollBottom())
|
||||||
|
im.markRead(this.current.conversationID)
|
||||||
|
}
|
||||||
|
// 异步刷新会话列表(未读、最新消息)
|
||||||
|
this.refreshConversations()
|
||||||
|
},
|
||||||
|
scrollBottom () {
|
||||||
|
const el = this.$refs.msgListRef
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
},
|
||||||
|
snippet (conv) {
|
||||||
|
const t = conv.latestMsg
|
||||||
|
if (!t) return ''
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(t)
|
||||||
|
if (m.textElem) return m.textElem.content
|
||||||
|
if (m.pictureElem) return '[图片]'
|
||||||
|
if (m.voiceElem) return '[语音]'
|
||||||
|
if (m.fileElem) return '[文件]'
|
||||||
|
if (m.contentType === 110) return '[系统通知] ' + (m.customElem && JSON.parse(m.customElem.data || '{}').title || '')
|
||||||
|
return ''
|
||||||
|
} catch (e) { return '' }
|
||||||
|
},
|
||||||
|
renderText (m) {
|
||||||
|
if (!m) return ''
|
||||||
|
if (m.contentType === MessageType.TextMessage) return m.textElem && m.textElem.content
|
||||||
|
if (m.contentType === MessageType.PictureMessage) return '[图片]'
|
||||||
|
if (m.contentType === MessageType.VoiceMessage) return '[语音]'
|
||||||
|
if (m.contentType === MessageType.FileMessage) return '[文件] ' + (m.fileElem && m.fileElem.fileName || '')
|
||||||
|
if (m.contentType === 110) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(m.customElem && m.customElem.data || '{}')
|
||||||
|
return '[系统通知] ' + (data.title || '') + ' · ' + (data.description || '')
|
||||||
|
} catch (e) { return '[系统通知]' }
|
||||||
|
}
|
||||||
|
return '[暂不支持的消息]'
|
||||||
|
},
|
||||||
|
avatarText (conv) {
|
||||||
|
const s = (conv.showName || '?').trim()
|
||||||
|
return s.charAt(s.length - 1) || '?'
|
||||||
|
},
|
||||||
|
avatarStyle (conv) {
|
||||||
|
const COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#a06bf3']
|
||||||
|
const hash = (conv.conversationID || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0)
|
||||||
|
return { background: COLORS[hash % COLORS.length] }
|
||||||
|
},
|
||||||
|
formatTime (t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const d = new Date(typeof t === 'number' && t < 1e12 ? t * 1000 : t)
|
||||||
|
const now = new Date()
|
||||||
|
if (d.toDateString() === now.toDateString()) {
|
||||||
|
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0')
|
||||||
|
}
|
||||||
|
return (d.getMonth() + 1) + '/' + d.getDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.im-chat-panel { height: 100%; min-height: 240px; font-size: 12px; }
|
||||||
|
.im-error {
|
||||||
|
padding: 24px; text-align: center; color: #f56c6c;
|
||||||
|
i { font-size: 24px; display: block; margin-bottom: 6px; }
|
||||||
|
}
|
||||||
|
.im-layout { display: flex; height: 100%; gap: 8px; }
|
||||||
|
.conv-list {
|
||||||
|
flex: 0 0 38%;
|
||||||
|
border-right: 1px solid #ebeef5;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.empty { color: #c0c4cc; text-align: center; padding: 24px 0; }
|
||||||
|
.pick-conv { padding-top: 80px; }
|
||||||
|
.conv-item {
|
||||||
|
display: flex; gap: 6px; padding: 6px 4px; cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background .15s;
|
||||||
|
&:hover { background: #f5f7fa; }
|
||||||
|
&.active { background: #ecf5ff; }
|
||||||
|
}
|
||||||
|
.conv-avatar {
|
||||||
|
flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
color: #fff; font-size: 13px; font-weight: 600;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.conv-body { flex: 1; min-width: 0; }
|
||||||
|
.conv-title {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
.conv-name { font-weight: 600; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.conv-time { color: #909399; font-size: 10px; flex-shrink: 0; margin-left: 4px; }
|
||||||
|
}
|
||||||
|
.conv-snippet {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
.snippet { color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.badge {
|
||||||
|
background: #f56c6c; color: #fff;
|
||||||
|
border-radius: 9px; padding: 0 5px;
|
||||||
|
font-size: 10px; line-height: 14px; height: 14px; min-width: 14px;
|
||||||
|
text-align: center; flex-shrink: 0; margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.msg-pane {
|
||||||
|
flex: 1; display: flex; flex-direction: column; min-width: 0;
|
||||||
|
}
|
||||||
|
.msg-header {
|
||||||
|
padding: 6px 8px; border-bottom: 1px solid #ebeef5;
|
||||||
|
font-weight: 600; color: #303133;
|
||||||
|
}
|
||||||
|
.msg-list {
|
||||||
|
flex: 1; overflow-y: auto; padding: 8px 4px;
|
||||||
|
}
|
||||||
|
.msg-row {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.msg-row.mine { justify-content: flex-end; }
|
||||||
|
.msg-bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.msg-row.mine .msg-bubble { background: #ecf5ff; }
|
||||||
|
.msg-meta { color: #909399; font-size: 10px; margin-bottom: 2px; }
|
||||||
|
.msg-text { color: #303133; word-break: break-word; }
|
||||||
|
.msg-input {
|
||||||
|
display: flex; gap: 4px; padding: 6px 4px;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.msg-input .el-input { flex: 1; }
|
||||||
|
</style>
|
||||||
143
ruoyi-ui/src/components/HomeModules/modules/MyProgressList.vue
Normal file
143
ruoyi-ui/src/components/HomeModules/modules/MyProgressList.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<div class="my-progress" v-loading="loading">
|
||||||
|
<div class="tab-row">
|
||||||
|
<span :class="['tab', filter === 'all' ? 'active' : '']" @click="setFilter('all')">
|
||||||
|
全部 <i class="cnt">{{ totalAll }}</i>
|
||||||
|
</span>
|
||||||
|
<span :class="['tab', filter === 'delayed' ? 'active' : '']" @click="setFilter('delayed')">
|
||||||
|
延期 <i class="cnt delayed">{{ totalDelayed }}</i>
|
||||||
|
</span>
|
||||||
|
<span :class="['tab', filter === 'soon' ? 'active' : '']" @click="setFilter('soon')">
|
||||||
|
将到期 <i class="cnt soon">{{ totalSoon }}</i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="step-list">
|
||||||
|
<li v-for="row in displayList" :key="row.trackId" @click="goToProject(row)">
|
||||||
|
<div class="step-line1">
|
||||||
|
<span class="step-name">{{ row.stepName || '(未命名步骤)' }}</span>
|
||||||
|
<el-tag v-if="isDelayed(row)" type="danger" size="mini" effect="plain">逾 {{ delayDays(row) }}d</el-tag>
|
||||||
|
<el-tag v-else-if="isSoon(row)" type="warning" size="mini" effect="plain">剩 {{ leftDays(row) }}d</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="step-line2">
|
||||||
|
<span class="proj">{{ row.projectName || '—' }}</span>
|
||||||
|
<span class="end">{{ formatDate(row.planEnd) }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="!displayList.length && !loading" class="empty">没有待做的步骤 🎉</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { listMyPage } from '@/api/oa/projectScheduleStep'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'MyProgressList',
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
list: [],
|
||||||
|
filter: 'all'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
totalAll () { return this.list.length },
|
||||||
|
totalDelayed () { return this.list.filter(this.isDelayed).length },
|
||||||
|
totalSoon () { return this.list.filter(this.isSoon).length },
|
||||||
|
displayList () {
|
||||||
|
if (this.filter === 'delayed') return this.list.filter(this.isDelayed)
|
||||||
|
if (this.filter === 'soon') return this.list.filter(this.isSoon)
|
||||||
|
// 默认按 延期 → 将到期 → 其他 排序
|
||||||
|
const score = r => this.isDelayed(r) ? 0 : (this.isSoon(r) ? 1 : 2)
|
||||||
|
return [...this.list].sort((a, b) => score(a) - score(b))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () { this.fetch() },
|
||||||
|
methods: {
|
||||||
|
fetch () {
|
||||||
|
this.loading = true
|
||||||
|
listMyPage({ pageNum: 1, pageSize: 100, status: 0 })
|
||||||
|
.then(res => { this.list = res.rows || [] })
|
||||||
|
.finally(() => { this.loading = false })
|
||||||
|
},
|
||||||
|
setFilter (f) { this.filter = f },
|
||||||
|
daysUntil (date) {
|
||||||
|
if (!date) return null
|
||||||
|
const d = new Date(date); d.setHours(0, 0, 0, 0)
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||||
|
return Math.floor((d - today) / 86400000)
|
||||||
|
},
|
||||||
|
isDelayed (r) {
|
||||||
|
const n = this.daysUntil(r.planEnd)
|
||||||
|
return n !== null && n < 0 && r.status !== 2
|
||||||
|
},
|
||||||
|
isSoon (r) {
|
||||||
|
const n = this.daysUntil(r.planEnd)
|
||||||
|
return n !== null && n >= 0 && n <= 3 && r.status !== 2
|
||||||
|
},
|
||||||
|
delayDays (r) { return Math.abs(this.daysUntil(r.planEnd) || 0) },
|
||||||
|
leftDays (r) { return this.daysUntil(r.planEnd) || 0 },
|
||||||
|
formatDate (t) {
|
||||||
|
if (!t) return '—'
|
||||||
|
const d = new Date(t)
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
},
|
||||||
|
goToProject (row) {
|
||||||
|
this.$router.push({
|
||||||
|
path: '/step/files',
|
||||||
|
query: { scheduleId: String(row.scheduleId || ''), trackId: String(row.trackId || '') }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.my-progress { font-size: 12px; height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.tab-row { display: flex; gap: 4px; padding-bottom: 6px; border-bottom: 1px solid #f0f0f0; margin-bottom: 4px; }
|
||||||
|
.tab {
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #606266;
|
||||||
|
border-radius: 3px;
|
||||||
|
&.active { background: #ecf5ff; color: #409eff; font-weight: 600; }
|
||||||
|
.cnt {
|
||||||
|
display: inline-block;
|
||||||
|
background: #e4e7ed;
|
||||||
|
color: #606266;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
&.delayed { background: #fef0f0; color: #f56c6c; }
|
||||||
|
&.soon { background: #fdf6ec; color: #e6a23c; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.step-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
li {
|
||||||
|
padding: 6px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px dashed #f0f0f0;
|
||||||
|
transition: background .15s;
|
||||||
|
&:hover { background: #f5f7fa; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.step-line1 {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
.step-name { font-weight: 600; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
.step-line2 {
|
||||||
|
display: flex; justify-content: space-between; color: #909399;
|
||||||
|
margin-top: 2px;
|
||||||
|
.proj { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; }
|
||||||
|
.end { font-size: 11px; }
|
||||||
|
}
|
||||||
|
.empty { text-align: center; color: #c0c4cc; padding: 32px 0; }
|
||||||
|
</style>
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
ExpressQuestionList,
|
ExpressQuestionList,
|
||||||
FeedbackList,
|
FeedbackList,
|
||||||
FinancialCharts,
|
FinancialCharts,
|
||||||
|
ImChatPanel,
|
||||||
|
MyProgressList,
|
||||||
MyTaskList,
|
MyTaskList,
|
||||||
OwnerTaskList,
|
OwnerTaskList,
|
||||||
ProjectManagement,
|
ProjectManagement,
|
||||||
@@ -56,6 +58,16 @@ export const WIDGET_REGISTRY = {
|
|||||||
component: ExpressQuestionList,
|
component: ExpressQuestionList,
|
||||||
defaultSize: { w: 4, h: 8 }
|
defaultSize: { w: 4, h: 8 }
|
||||||
},
|
},
|
||||||
|
myProgress: {
|
||||||
|
title: '我的待做进度',
|
||||||
|
component: MyProgressList,
|
||||||
|
defaultSize: { w: 4, h: 10 }
|
||||||
|
},
|
||||||
|
imChat: {
|
||||||
|
title: 'IM 聊天',
|
||||||
|
component: ImChatPanel,
|
||||||
|
defaultSize: { w: 6, h: 12 }
|
||||||
|
},
|
||||||
miniCalendar: {
|
miniCalendar: {
|
||||||
title: '日程日历',
|
title: '日程日历',
|
||||||
component: MiniCalendar,
|
component: MiniCalendar,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="project-select-wrap">
|
||||||
<!-- 项目代号搜索框 -->
|
<!-- 项目代号搜索框(与下方选择框同行) -->
|
||||||
<el-input v-model="codeSearchText" placeholder="搜索项目代号" clearable prefix-icon="el-icon-search" size="small"
|
<el-input v-model="codeSearchText" placeholder="搜索代号" clearable prefix-icon="el-icon-search" size="small"
|
||||||
style="margin-bottom: 8px;" @input="handleCodeSearch" />
|
class="ps-code-input" @input="handleCodeSearch" />
|
||||||
|
|
||||||
<el-select v-if="projectList.length && !loading" v-model="projectId" placeholder="请选择项目" clearable filterable
|
<el-select v-if="projectList.length && !loading" v-model="projectId" placeholder="请选择项目" clearable filterable
|
||||||
:filter-method="customFilter" :disabled="disabled" :style="styles" @change="handleChange">
|
:filter-method="customFilter" :disabled="disabled" :style="styles" class="ps-select" @change="handleChange">
|
||||||
<el-option v-for="projectItem in filteredProjects" :key="projectItem.projectId" :label="projectItem.projectName"
|
<el-option v-for="projectItem in filteredProjects" :key="projectItem.projectId" :label="projectItem.projectName"
|
||||||
:value="projectItem.projectId">
|
:value="projectItem.projectId">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
||||||
@@ -329,4 +329,22 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.project-select-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ps-code-input {
|
||||||
|
flex: 0 0 130px;
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
.ps-select {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
97
ruoyi-ui/src/layout/components/FeedbackEntry.vue
Normal file
97
ruoyi-ui/src/layout/components/FeedbackEntry.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<span class="feedback-entry">
|
||||||
|
<el-tooltip content="提交修改意见 / 反馈问题" effect="dark" placement="bottom">
|
||||||
|
<el-button size="mini" type="text" icon="el-icon-edit-outline" class="entry-btn"
|
||||||
|
@click="open = true">意见</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
|
<el-dialog title="提交修改意见" :visible.sync="open" width="520px" append-to-body
|
||||||
|
:close-on-click-modal="false">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" size="mini" label-width="68px">
|
||||||
|
<el-form-item label="标题" prop="title">
|
||||||
|
<el-input v-model="form.title" placeholder="一句话概括问题或建议" maxlength="120" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" prop="category">
|
||||||
|
<el-radio-group v-model="form.category">
|
||||||
|
<el-radio-button label="bug">Bug</el-radio-button>
|
||||||
|
<el-radio-button label="feature">新功能</el-radio-button>
|
||||||
|
<el-radio-button label="other">其他</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="优先级" prop="priority">
|
||||||
|
<el-radio-group v-model="form.priority">
|
||||||
|
<el-radio :label="1">高</el-radio>
|
||||||
|
<el-radio :label="2">中</el-radio>
|
||||||
|
<el-radio :label="3">低</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="详细" prop="content">
|
||||||
|
<el-input v-model="form.content" type="textarea" :autosize="{ minRows: 4, maxRows: 8 }"
|
||||||
|
placeholder="描述问题复现步骤 / 期望的功能 / 建议改进点……" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="附件" prop="attachment">
|
||||||
|
<file-upload v-model="form.attachment" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<span slot="footer">
|
||||||
|
<el-button size="mini" @click="open = false">取消</el-button>
|
||||||
|
<el-button size="mini" type="primary" :loading="submitting" @click="onSubmit">
|
||||||
|
提交(会通过 IM 通知信息化部)
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</el-dialog>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { submitSuggestion } from '@/api/oa/suggestion'
|
||||||
|
import FileUpload from '@/components/FileUpload'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FeedbackEntry',
|
||||||
|
components: { FileUpload },
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
open: false,
|
||||||
|
submitting: false,
|
||||||
|
form: this.emptyForm(),
|
||||||
|
rules: {
|
||||||
|
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||||
|
content: [{ required: true, message: '请填写详细内容', trigger: 'blur' }],
|
||||||
|
category: [{ required: true, message: '请选择类型', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emptyForm () {
|
||||||
|
return {
|
||||||
|
title: '', content: '', category: 'feature', priority: 2,
|
||||||
|
attachment: '', pagePath: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSubmit () {
|
||||||
|
this.$refs.formRef.validate(ok => {
|
||||||
|
if (!ok) return
|
||||||
|
this.submitting = true
|
||||||
|
this.form.pagePath = this.$route && this.$route.fullPath
|
||||||
|
submitSuggestion(this.form).then(() => {
|
||||||
|
this.$modal.msgSuccess('已提交,感谢反馈!')
|
||||||
|
this.open = false
|
||||||
|
this.form = this.emptyForm()
|
||||||
|
}).finally(() => { this.submitting = false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.feedback-entry { display: inline-block; }
|
||||||
|
.entry-btn {
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #e6a23c;
|
||||||
|
i { margin-right: 2px; }
|
||||||
|
&:hover { color: #f56c6c; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,17 +9,7 @@
|
|||||||
<div class="right-menu">
|
<div class="right-menu">
|
||||||
<template v-if="device !== 'mobile'">
|
<template v-if="device !== 'mobile'">
|
||||||
<search id="header-search" class="right-menu-item" />
|
<search id="header-search" class="right-menu-item" />
|
||||||
|
<feedback-entry class="right-menu-item" />
|
||||||
<!-- <div style="position: absolute; top: 0; right: 300px; font-weight: 200">
|
|
||||||
<el-button class="el-icon-s-comment" @click="chat = true" style=""
|
|
||||||
>打开聊天</el-button
|
|
||||||
>
|
|
||||||
<chat-component
|
|
||||||
:drawerVisible="chat"
|
|
||||||
ref="chatComponent"
|
|
||||||
@close="hiddenChat"
|
|
||||||
/>
|
|
||||||
</div> -->
|
|
||||||
<screenfull id="screenfull" class="right-menu-item hover-effect" />
|
<screenfull id="screenfull" class="right-menu-item hover-effect" />
|
||||||
<el-tooltip content="用户" effect="dark" placement="bottom">
|
<el-tooltip content="用户" effect="dark" placement="bottom">
|
||||||
<div class="right-menu-item hover-effect">
|
<div class="right-menu-item hover-effect">
|
||||||
@@ -62,6 +52,7 @@ import Screenfull from "@/components/Screenfull";
|
|||||||
import SizeSelect from "@/components/SizeSelect";
|
import SizeSelect from "@/components/SizeSelect";
|
||||||
import TopNav from "@/components/TopNav";
|
import TopNav from "@/components/TopNav";
|
||||||
import AIChat from "@/layout/components/AIChat/index.vue";
|
import AIChat from "@/layout/components/AIChat/index.vue";
|
||||||
|
import FeedbackEntry from "@/layout/components/FeedbackEntry.vue";
|
||||||
import { parseTime } from "@/utils/ruoyi";
|
import { parseTime } from "@/utils/ruoyi";
|
||||||
import { mapGetters } from "vuex";
|
import { mapGetters } from "vuex";
|
||||||
// import {
|
// import {
|
||||||
@@ -81,6 +72,7 @@ export default {
|
|||||||
RuoYiGit,
|
RuoYiGit,
|
||||||
RuoYiDoc,
|
RuoYiDoc,
|
||||||
AIChat,
|
AIChat,
|
||||||
|
FeedbackEntry,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
// chatComponent() {
|
// chatComponent() {
|
||||||
|
|||||||
207
ruoyi-ui/src/layout/components/TutorialGuide.vue
Normal file
207
ruoyi-ui/src/layout/components/TutorialGuide.vue
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="active" class="tutorial-overlay" @click.self="next">
|
||||||
|
<div class="tutorial-card" :style="cardStyle">
|
||||||
|
<div class="tut-title">
|
||||||
|
<span class="step-num">{{ stepIndex + 1 }} / {{ steps.length }}</span>
|
||||||
|
{{ currentStep.title }}
|
||||||
|
</div>
|
||||||
|
<div class="tut-body" v-html="currentStep.html" />
|
||||||
|
<div class="tut-footer">
|
||||||
|
<el-button size="mini" type="text" @click="finish">跳过</el-button>
|
||||||
|
<div style="flex:1" />
|
||||||
|
<el-button v-if="stepIndex > 0" size="mini" @click="prev">上一步</el-button>
|
||||||
|
<el-button size="mini" type="primary" @click="next">
|
||||||
|
{{ stepIndex === steps.length - 1 ? '完成' : '下一步' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 高亮目标元素的"挖空" -->
|
||||||
|
<div v-if="holeStyle" class="tutorial-hole" :style="holeStyle" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const VERSION = 'oa_tutorial_v2' // 升级版本号即可让所有人重看一次
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{
|
||||||
|
title: '👋 欢迎使用',
|
||||||
|
html: '<p>这是一个简短的引导,约 6 步,带您熟悉常用入口。</p><p>任意时候点空白处或按"跳过"退出引导,下次不会再出现。</p>'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '.feedback-entry',
|
||||||
|
title: '提交修改意见',
|
||||||
|
html: '遇到问题或想提建议?点这里提交反馈,会自动 IM 通知信息化部门。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '#header-search',
|
||||||
|
title: '快速搜索',
|
||||||
|
html: '不知道功能在哪?这里输入菜单名就能跳转。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '.sidebar-container',
|
||||||
|
title: '左侧菜单',
|
||||||
|
html: '所有功能按模块分组。鼠标移上去看二级菜单。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '.workbench-edit-fab, .workbench, .home',
|
||||||
|
title: '个人工作台',
|
||||||
|
html: '首页是您的个人工作台,可以添加/拖拽组件(待办、聊天、进度等),每个人都不一样。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '🎉 开始使用',
|
||||||
|
html: '随时可在浏览器地址栏添加 <code>?tutorial=1</code> 重新打开本引导。<br>祝您工作顺利~'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TutorialGuide',
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
stepIndex: 0,
|
||||||
|
steps: STEPS,
|
||||||
|
targetRect: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentStep () { return this.steps[this.stepIndex] || {} },
|
||||||
|
cardStyle () {
|
||||||
|
if (!this.targetRect) {
|
||||||
|
// 居中
|
||||||
|
return { top: '40vh', left: '50%', transform: 'translateX(-50%)' }
|
||||||
|
}
|
||||||
|
const r = this.targetRect
|
||||||
|
// 放在目标下方,如果超出则放上方
|
||||||
|
const cardH = 200
|
||||||
|
const margin = 12
|
||||||
|
let top = r.bottom + margin
|
||||||
|
if (top + cardH > window.innerHeight) top = Math.max(20, r.top - cardH - margin)
|
||||||
|
let left = r.left
|
||||||
|
if (left + 380 > window.innerWidth) left = window.innerWidth - 380 - 12
|
||||||
|
return { top: top + 'px', left: Math.max(12, left) + 'px' }
|
||||||
|
},
|
||||||
|
holeStyle () {
|
||||||
|
if (!this.targetRect) return null
|
||||||
|
const r = this.targetRect
|
||||||
|
const pad = 4
|
||||||
|
return {
|
||||||
|
top: (r.top - pad) + 'px',
|
||||||
|
left: (r.left - pad) + 'px',
|
||||||
|
width: (r.width + pad * 2) + 'px',
|
||||||
|
height: (r.height + pad * 2) + 'px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.$nextTick(this.maybeStart)
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
window.addEventListener('resize', this.refreshTarget)
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
window.removeEventListener('resize', this.refreshTarget)
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route' () {
|
||||||
|
if (!this.active) this.maybeStart()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
maybeStart () {
|
||||||
|
const force = (this.$route && this.$route.query && this.$route.query.tutorial) === '1'
|
||||||
|
const done = !force && localStorage.getItem(VERSION) === '1'
|
||||||
|
if (done) return
|
||||||
|
// 等 layout 渲染完
|
||||||
|
setTimeout(() => {
|
||||||
|
this.active = true
|
||||||
|
this.stepIndex = 0
|
||||||
|
this.refreshTarget()
|
||||||
|
}, 800)
|
||||||
|
},
|
||||||
|
refreshTarget () {
|
||||||
|
const sel = this.currentStep.target
|
||||||
|
if (!sel) { this.targetRect = null; return }
|
||||||
|
const el = document.querySelector(sel)
|
||||||
|
if (!el) { this.targetRect = null; return }
|
||||||
|
this.targetRect = el.getBoundingClientRect()
|
||||||
|
},
|
||||||
|
prev () {
|
||||||
|
if (this.stepIndex > 0) {
|
||||||
|
this.stepIndex--
|
||||||
|
this.$nextTick(this.refreshTarget)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
next () {
|
||||||
|
if (this.stepIndex === this.steps.length - 1) {
|
||||||
|
this.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.stepIndex++
|
||||||
|
this.$nextTick(this.refreshTarget)
|
||||||
|
},
|
||||||
|
finish () {
|
||||||
|
this.active = false
|
||||||
|
localStorage.setItem(VERSION, '1')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tutorial-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.tutorial-hole {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px solid #409eff;
|
||||||
|
transition: all .25s;
|
||||||
|
}
|
||||||
|
.tutorial-card {
|
||||||
|
position: absolute;
|
||||||
|
width: 380px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
.tut-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #303133;
|
||||||
|
.step-num {
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tut-body {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
::v-deep code {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tut-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,12 +12,14 @@
|
|||||||
<settings/>
|
<settings/>
|
||||||
</right-panel>
|
</right-panel>
|
||||||
</div>
|
</div>
|
||||||
|
<tutorial-guide />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import RightPanel from '@/components/RightPanel'
|
import RightPanel from '@/components/RightPanel'
|
||||||
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
|
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
|
||||||
|
import TutorialGuide from './components/TutorialGuide.vue'
|
||||||
import ResizeMixin from './mixin/ResizeHandler'
|
import ResizeMixin from './mixin/ResizeHandler'
|
||||||
import { mapState } from 'vuex'
|
import { mapState } from 'vuex'
|
||||||
import variables from '@/assets/styles/variables.scss'
|
import variables from '@/assets/styles/variables.scss'
|
||||||
@@ -28,6 +30,7 @@ export default {
|
|||||||
AppMain,
|
AppMain,
|
||||||
Navbar,
|
Navbar,
|
||||||
RightPanel,
|
RightPanel,
|
||||||
|
TutorialGuide,
|
||||||
Settings,
|
Settings,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
TagsView,
|
TagsView,
|
||||||
|
|||||||
123
ruoyi-ui/src/utils/imClient.js
Normal file
123
ruoyi-ui/src/utils/imClient.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* OpenIM Web SDK 单例封装
|
||||||
|
* 用 Vue 事件总线让多个组件共享 SDK 实例与登录态
|
||||||
|
*/
|
||||||
|
import { CbEvents, MessageType, SessionType, Platform, getSDK } from '@openim/wasm-client-sdk'
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export const imBus = new Vue()
|
||||||
|
|
||||||
|
class ImClient {
|
||||||
|
constructor () {
|
||||||
|
this.sdk = null
|
||||||
|
this.loggedIn = false
|
||||||
|
this.userID = ''
|
||||||
|
this.loginPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用 OA 后端拿到的 imUserId + imToken 登录
|
||||||
|
* @param {{imUserId,imToken,apiUrl,wsUrl}} cred
|
||||||
|
*/
|
||||||
|
async login (cred) {
|
||||||
|
if (this.loggedIn && this.userID === cred.imUserId) return true
|
||||||
|
if (this.loginPromise) return this.loginPromise
|
||||||
|
|
||||||
|
if (!this.sdk) {
|
||||||
|
this.sdk = getSDK({ coreWasmPath: '/openIM.wasm', debug: false })
|
||||||
|
this._wireEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loginPromise = (async () => {
|
||||||
|
try {
|
||||||
|
console.log('[IM] login start', { userID: cred.imUserId, apiAddr: cred.apiUrl, wsAddr: cred.wsUrl, platformID: Platform.Web })
|
||||||
|
const result = await this.sdk.login({
|
||||||
|
userID: cred.imUserId,
|
||||||
|
token: cred.imToken,
|
||||||
|
platformID: Platform.Web,
|
||||||
|
apiAddr: cred.apiUrl,
|
||||||
|
wsAddr: cred.wsUrl
|
||||||
|
})
|
||||||
|
console.log('[IM] login result', result)
|
||||||
|
this.loggedIn = true
|
||||||
|
this.userID = cred.imUserId
|
||||||
|
imBus.$emit('logged-in', cred.imUserId)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[IM] login failed FULL:', e)
|
||||||
|
console.error('[IM] error keys:', Object.keys(e || {}))
|
||||||
|
console.error('[IM] error JSON:', JSON.stringify(e))
|
||||||
|
this.lastError = e
|
||||||
|
imBus.$emit('login-failed', e)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
this.loginPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return this.loginPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout () {
|
||||||
|
if (!this.sdk || !this.loggedIn) return
|
||||||
|
try { await this.sdk.logout() } catch (e) {}
|
||||||
|
this.loggedIn = false
|
||||||
|
this.userID = ''
|
||||||
|
imBus.$emit('logged-out')
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireEvents () {
|
||||||
|
this.sdk.on(CbEvents.OnRecvNewMessage, ({ data }) => {
|
||||||
|
imBus.$emit('new-message', data)
|
||||||
|
})
|
||||||
|
this.sdk.on(CbEvents.OnConversationChanged, ({ data }) => {
|
||||||
|
imBus.$emit('conv-changed', data)
|
||||||
|
})
|
||||||
|
this.sdk.on(CbEvents.OnNewConversation, ({ data }) => {
|
||||||
|
imBus.$emit('conv-changed', data)
|
||||||
|
})
|
||||||
|
this.sdk.on(CbEvents.OnTotalUnreadMessageCountChanged, ({ data }) => {
|
||||||
|
imBus.$emit('total-unread', data)
|
||||||
|
})
|
||||||
|
this.sdk.on(CbEvents.OnConnectFailed, () => imBus.$emit('disconnected'))
|
||||||
|
this.sdk.on(CbEvents.OnKickedOffline, () => imBus.$emit('kicked'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉会话列表
|
||||||
|
async getConversations (offset = 0, count = 50) {
|
||||||
|
if (!this.loggedIn) return []
|
||||||
|
const { data } = await this.sdk.getConversationListSplit({ offset, count })
|
||||||
|
return data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉某会话最近的消息
|
||||||
|
async getMessages (conversationID, count = 30) {
|
||||||
|
if (!this.loggedIn) return []
|
||||||
|
const { data } = await this.sdk.getAdvancedHistoryMessageList({
|
||||||
|
lastMinSeq: 0,
|
||||||
|
count,
|
||||||
|
startClientMsgID: '',
|
||||||
|
conversationID
|
||||||
|
})
|
||||||
|
return (data && data.messageList) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送文本消息
|
||||||
|
async sendText (conv, text) {
|
||||||
|
if (!this.loggedIn || !text || !text.trim()) return null
|
||||||
|
const { data: msg } = await this.sdk.createTextMessage(text)
|
||||||
|
return this.sdk.sendMessage({
|
||||||
|
recvID: conv.userID || '',
|
||||||
|
groupID: conv.groupID || '',
|
||||||
|
message: msg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记会话已读
|
||||||
|
async markRead (conversationID) {
|
||||||
|
if (!this.loggedIn) return
|
||||||
|
try { await this.sdk.markConversationMessageAsRead(conversationID) } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const im = new ImClient()
|
||||||
|
export { CbEvents, MessageType, SessionType }
|
||||||
@@ -100,6 +100,21 @@
|
|||||||
</template>
|
</template>
|
||||||
{{ detailData.remark == null ? '空' : detailData.remark }}
|
{{ detailData.remark == null ? '空' : detailData.remark }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item :span="3">
|
||||||
|
<template slot="label">
|
||||||
|
<i class="el-icon-paperclip"></i>
|
||||||
|
收货单
|
||||||
|
</template>
|
||||||
|
<template v-if="detailData.receiptFiles">
|
||||||
|
<a v-for="f in parseReceiptFiles(detailData.receiptFiles)" :key="f.ossId"
|
||||||
|
:href="f.url" target="_blank" download
|
||||||
|
style="color:#409eff; margin-right:10px; font-size:12px;">
|
||||||
|
<i class="el-icon-paperclip"></i>{{ f.name }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color:#c0c4cc;">无</span>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="oaOutWarehouseList" @selection-change="handleSelectionChange">
|
<el-table v-loading="loading" :data="oaOutWarehouseList" @selection-change="handleSelectionChange">
|
||||||
@@ -148,6 +163,9 @@
|
|||||||
<el-input placeholder="请输入编号" v-model="form.masterNum">
|
<el-input placeholder="请输入编号" v-model="form.masterNum">
|
||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="收货单" prop="receiptDoc">
|
||||||
|
<file-upload v-model="form.receiptDoc" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item v-if="drawer" label="项目名">
|
<el-form-item v-if="drawer" label="项目名">
|
||||||
<el-input :value="form.projectName" disabled></el-input>
|
<el-input :value="form.projectName" disabled></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -246,12 +264,14 @@ import {
|
|||||||
updateOaWarehouseMaster
|
updateOaWarehouseMaster
|
||||||
} from "@/api/oa/warehouse/warehouseMaster";
|
} from "@/api/oa/warehouse/warehouseMaster";
|
||||||
import { listRequirements } from "@/api/oa/requirement";
|
import { listRequirements } from "@/api/oa/requirement";
|
||||||
|
import FileUpload from "@/components/FileUpload";
|
||||||
import ProjectSelect from "@/components/fad-service/ProjectSelect";
|
import ProjectSelect from "@/components/fad-service/ProjectSelect";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "OaOutWarehouse",
|
name: "OaOutWarehouse",
|
||||||
components: {
|
components: {
|
||||||
ProjectSelect
|
ProjectSelect,
|
||||||
|
FileUpload
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
@@ -338,8 +358,31 @@ export default {
|
|||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.getList();
|
this.getList();
|
||||||
|
// 路由带 requirementId 进来时自动打开新增入库表单并预填
|
||||||
|
const q = this.$route && this.$route.query;
|
||||||
|
if (q && q.requirementId) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.handleAdd();
|
||||||
|
this.form.requirementId = Number(q.requirementId) || q.requirementId;
|
||||||
|
// 顺手把预填项推到选项列表,让 select 能显示
|
||||||
|
if (q.requirementTitle) {
|
||||||
|
this.requirementOptions = [{
|
||||||
|
requirementId: this.form.requirementId,
|
||||||
|
title: q.requirementTitle
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 后端联查的收货单文件解析("ossId|name|url,,...")
|
||||||
|
parseReceiptFiles (raw) {
|
||||||
|
if (!raw) return []
|
||||||
|
return String(raw).split(',,').map(s => {
|
||||||
|
const [ossId, name, url] = s.split('|')
|
||||||
|
return { ossId, name: name || '收货单', url: url || '' }
|
||||||
|
}).filter(f => f.ossId)
|
||||||
|
},
|
||||||
// 远程搜索采购需求
|
// 远程搜索采购需求
|
||||||
loadRequirementOptions (keyword) {
|
loadRequirementOptions (keyword) {
|
||||||
this.requirementLoading = true
|
this.requirementLoading = true
|
||||||
@@ -409,6 +452,7 @@ export default {
|
|||||||
this.form = {
|
this.form = {
|
||||||
projectId: undefined,
|
projectId: undefined,
|
||||||
requirementId: undefined,
|
requirementId: undefined,
|
||||||
|
receiptDoc: undefined,
|
||||||
warehouseList: [],
|
warehouseList: [],
|
||||||
};
|
};
|
||||||
this.resetForm("form");
|
this.resetForm("form");
|
||||||
|
|||||||
@@ -54,9 +54,15 @@
|
|||||||
<el-select v-model="batchStatus" size="mini" placeholder="批量设置状态" style="width:140px">
|
<el-select v-model="batchStatus" size="mini" placeholder="批量设置状态" style="width:140px">
|
||||||
<el-option v-for="s in statusOptions" :key="s.value" :value="s.value" :label="s.label" />
|
<el-option v-for="s in statusOptions" :key="s.value" :value="s.value" :label="s.label" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button size="mini" type="success" @click="submitComplete">执行入库</el-button>
|
<el-button size="mini" type="success" @click="submitComplete(props.row)">执行入库</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 收货单上传:仅入库批量场景需要 -->
|
||||||
|
<div v-if="mode === 'batch' && props.row.status === 0" class="receipt-row">
|
||||||
|
<span class="r-label"><i class="el-icon-paperclip"></i> 收货单:</span>
|
||||||
|
<file-upload v-model="props.row.receiptDoc" />
|
||||||
|
<span class="r-hint">提交时会一并保存到本单据</span>
|
||||||
|
</div>
|
||||||
<el-table v-loading="itemsLoading[props.row.masterId]"
|
<el-table v-loading="itemsLoading[props.row.masterId]"
|
||||||
:data="itemsMap[props.row.masterId] || []" size="mini" stripe ref="warehouseTable">
|
:data="itemsMap[props.row.masterId] || []" size="mini" stripe ref="warehouseTable">
|
||||||
<el-table-column v-if="mode === 'batch' && props.row.status === 0"
|
<el-table-column v-if="mode === 'batch' && props.row.status === 0"
|
||||||
@@ -161,6 +167,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { listRequirements } from "@/api/oa/requirement";
|
import { listRequirements } from "@/api/oa/requirement";
|
||||||
|
import FileUpload from "@/components/FileUpload";
|
||||||
import AddPurchaseDialog from "./components/AddPurchaseDialog.vue";
|
import AddPurchaseDialog from "./components/AddPurchaseDialog.vue";
|
||||||
import {
|
import {
|
||||||
addOaWarehouseMaster,
|
addOaWarehouseMaster,
|
||||||
@@ -179,7 +186,7 @@ import {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "OaOutWarehouse",
|
name: "OaOutWarehouse",
|
||||||
components: { AddPurchaseDialog },
|
components: { AddPurchaseDialog, FileUpload },
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
// 顶部状态筛选
|
// 顶部状态筛选
|
||||||
@@ -365,7 +372,7 @@ export default {
|
|||||||
this.warehouseTaskList.splice(index, 1)
|
this.warehouseTaskList.splice(index, 1)
|
||||||
this.$message.success('删除成功')
|
this.$message.success('删除成功')
|
||||||
},
|
},
|
||||||
submitComplete () {
|
submitComplete (masterRow) {
|
||||||
const rows = this.$refs.warehouseTable.selection || []
|
const rows = this.$refs.warehouseTable.selection || []
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
return this.$message.warning('请先勾选物料')
|
return this.$message.warning('请先勾选物料')
|
||||||
@@ -374,17 +381,22 @@ export default {
|
|||||||
return this.$message.warning('请选择批量状态')
|
return this.$message.warning('请选择批量状态')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前端直接设值(实际项目可调用接口)
|
|
||||||
rows.forEach(r => { r.taskStatus = this.batchStatus })
|
rows.forEach(r => { r.taskStatus = this.batchStatus })
|
||||||
|
|
||||||
// 批量入库采购单
|
// 1. 如果有收货单 → 先保存到 master
|
||||||
updateOaWarehouseTaskBatch(rows).then(res => {
|
const saveMaster = (masterRow && masterRow.receiptDoc)
|
||||||
this.getList();
|
? updateOaWarehouseMaster({
|
||||||
this.drawer = false;
|
masterId: masterRow.masterId,
|
||||||
|
receiptDoc: masterRow.receiptDoc,
|
||||||
|
type: masterRow.type
|
||||||
|
})
|
||||||
|
: Promise.resolve()
|
||||||
|
|
||||||
|
saveMaster.then(() => updateOaWarehouseTaskBatch(rows)).then(() => {
|
||||||
|
this.getList()
|
||||||
|
this.drawer = false
|
||||||
this.$message.success(`已批量入库 ${rows.length} 条`)
|
this.$message.success(`已批量入库 ${rows.length} 条`)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
},
|
},
|
||||||
/** 执行入库操作 */
|
/** 执行入库操作 */
|
||||||
handleIn (row) {
|
handleIn (row) {
|
||||||
@@ -671,4 +683,20 @@ export default {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
i { font-size: 11px; margin-right: 2px; }
|
i { font-size: 11px; margin-right: 2px; }
|
||||||
}
|
}
|
||||||
|
.receipt-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-bottom: 1px dashed #ebeef5;
|
||||||
|
.r-label {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
i { margin-right: 2px; color: #909399; }
|
||||||
|
}
|
||||||
|
.r-hint { color: #909399; font-size: 11px; }
|
||||||
|
::v-deep .el-upload-list { font-size: 12px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
193
ruoyi-ui/src/views/oa/project/pace/components/GanttView.vue
Normal file
193
ruoyi-ui/src/views/oa/project/pace/components/GanttView.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div class="gantt-view">
|
||||||
|
<div class="gantt-header">
|
||||||
|
<div class="left-label">项目</div>
|
||||||
|
<div class="time-axis">
|
||||||
|
<div v-for="(m, i) in months" :key="i" class="month-cell">{{ m.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gantt-body">
|
||||||
|
<div v-for="item in items" :key="item.scheduleId" class="gantt-row" @click="$emit('open-detail', item)">
|
||||||
|
<div class="left-label">
|
||||||
|
<el-tag v-if="item.projectCode" size="mini" type="info">{{ item.projectCode }}</el-tag>
|
||||||
|
<span class="row-name" :title="item.projectName">{{ item.projectName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="today-line" :style="todayStyle"></div>
|
||||||
|
<div class="bar" :class="barClass(item)" :style="barStyle(item)" :title="barTitle(item)">
|
||||||
|
<span class="bar-label">{{ Math.round(item.schedulePercentage || 0) }}%</span>
|
||||||
|
<div class="bar-progress" :style="{ width: (item.schedulePercentage || 0) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!items.length" class="empty">无可显示的项目</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'GanttView',
|
||||||
|
props: {
|
||||||
|
list: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
items () {
|
||||||
|
// 只展示有起止时间的
|
||||||
|
return (this.list || []).filter(r => r.startTime && r.endTime)
|
||||||
|
},
|
||||||
|
range () {
|
||||||
|
if (!this.items.length) {
|
||||||
|
const now = new Date()
|
||||||
|
return { min: new Date(now.getFullYear(), now.getMonth() - 1, 1),
|
||||||
|
max: new Date(now.getFullYear(), now.getMonth() + 3, 1) }
|
||||||
|
}
|
||||||
|
const times = this.items.flatMap(r => [new Date(r.startTime), new Date(r.endTime)])
|
||||||
|
const min = new Date(Math.min(...times)); min.setDate(1)
|
||||||
|
const max = new Date(Math.max(...times)); max.setMonth(max.getMonth() + 1, 1)
|
||||||
|
return { min, max }
|
||||||
|
},
|
||||||
|
months () {
|
||||||
|
const arr = []
|
||||||
|
const cur = new Date(this.range.min)
|
||||||
|
while (cur < this.range.max) {
|
||||||
|
arr.push({ label: `${cur.getFullYear()}/${cur.getMonth() + 1}`, date: new Date(cur) })
|
||||||
|
cur.setMonth(cur.getMonth() + 1)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
},
|
||||||
|
spanMs () {
|
||||||
|
return this.range.max - this.range.min
|
||||||
|
},
|
||||||
|
todayStyle () {
|
||||||
|
const now = new Date()
|
||||||
|
if (now < this.range.min || now > this.range.max) return { display: 'none' }
|
||||||
|
const pct = (now - this.range.min) / this.spanMs * 100
|
||||||
|
return { left: pct + '%' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
barStyle (r) {
|
||||||
|
const s = new Date(r.startTime)
|
||||||
|
const e = new Date(r.endTime)
|
||||||
|
const left = (s - this.range.min) / this.spanMs * 100
|
||||||
|
const width = (e - s) / this.spanMs * 100
|
||||||
|
return { left: left + '%', width: Math.max(width, 1) + '%' }
|
||||||
|
},
|
||||||
|
barClass (r) {
|
||||||
|
if (r.status === 2) return 'done'
|
||||||
|
if ((r.delayCount || 0) > 0) return 'delayed'
|
||||||
|
return 'running'
|
||||||
|
},
|
||||||
|
barTitle (r) {
|
||||||
|
const s = new Date(r.startTime).toISOString().slice(0, 10)
|
||||||
|
const e = new Date(r.endTime).toISOString().slice(0, 10)
|
||||||
|
return `${r.projectName}\n${s} ~ ${e}\n进度 ${Math.round(r.schedulePercentage || 0)}%`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.gantt-view {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.gantt-header {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.gantt-body {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.gantt-row {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #f4f4f4;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover { background: #f9fbff; }
|
||||||
|
}
|
||||||
|
.left-label {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
border-right: 1px solid #ebeef5;
|
||||||
|
padding: 6px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: inherit;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.time-axis {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.month-cell {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #606266;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-right: 1px solid #ebeef5;
|
||||||
|
&:last-child { border-right: none; }
|
||||||
|
}
|
||||||
|
.bar-track {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
height: 28px;
|
||||||
|
background-image: linear-gradient(to right, #f4f4f4 1px, transparent 1px);
|
||||||
|
background-size: 80px 100%;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(64, 158, 255, 0.15);
|
||||||
|
border: 1px solid #409eff;
|
||||||
|
&.running { background: rgba(64, 158, 255, 0.15); border-color: #409eff; }
|
||||||
|
&.delayed { background: rgba(245, 108, 108, 0.18); border-color: #f56c6c; }
|
||||||
|
&.done { background: rgba(103, 194, 58, 0.18); border-color: #67c23a; }
|
||||||
|
}
|
||||||
|
.bar-progress {
|
||||||
|
position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.bar.running .bar-progress { color: #409eff; }
|
||||||
|
.bar.delayed .bar-progress { color: #f56c6c; }
|
||||||
|
.bar.done .bar-progress { color: #67c23a; }
|
||||||
|
.bar-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 14px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.today-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; bottom: 0;
|
||||||
|
width: 0;
|
||||||
|
border-left: 1px dashed #e6a23c;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
163
ruoyi-ui/src/views/oa/project/pace/components/KanbanView.vue
Normal file
163
ruoyi-ui/src/views/oa/project/pace/components/KanbanView.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div class="kanban-view">
|
||||||
|
<div v-for="col in columns" :key="col.key" class="kanban-col">
|
||||||
|
<div class="col-header" :style="{ borderTopColor: col.color }">
|
||||||
|
<span class="col-title">{{ col.title }}</span>
|
||||||
|
<span class="col-count">{{ filterCol(col.key).length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-body">
|
||||||
|
<div v-for="item in filterCol(col.key)" :key="item.scheduleId" class="kb-card"
|
||||||
|
@click="$emit('open-detail', item)">
|
||||||
|
<div class="card-title">
|
||||||
|
<el-tag v-if="item.projectCode" size="mini" type="info">{{ item.projectCode }}</el-tag>
|
||||||
|
<span>{{ item.projectName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="meta-item"><i class="el-icon-user"></i> {{ item.steward || item.functionary || '—' }}</span>
|
||||||
|
<span class="meta-item" v-if="item.endTime">
|
||||||
|
<i class="el-icon-time"></i> {{ formatDate(item.endTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-progress :percentage="Math.round(item.schedulePercentage || 0)"
|
||||||
|
:status="progressStatus(item)" :stroke-width="6" :show-text="false" />
|
||||||
|
<div class="card-bottom">
|
||||||
|
<span>{{ Math.round(item.schedulePercentage || 0) }}%</span>
|
||||||
|
<el-tag v-if="item.delayCount > 0" type="danger" size="mini" effect="plain">
|
||||||
|
⚠ {{ item.delayCount }} 延期
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="item.unFinishCount > 0 && col.key !== 'done'" size="mini" effect="plain">
|
||||||
|
{{ item.unFinishCount }} 未完成
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!filterCol(col.key).length" class="col-empty">无</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'KanbanView',
|
||||||
|
props: {
|
||||||
|
list: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
{ key: 'pending', title: '未开始', color: '#909399' },
|
||||||
|
{ key: 'running', title: '进行中', color: '#409eff' },
|
||||||
|
{ key: 'delayed', title: '存在延期', color: '#f56c6c' },
|
||||||
|
{ key: 'done', title: '已完成', color: '#67c23a' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
filterCol (key) {
|
||||||
|
const list = this.list || []
|
||||||
|
switch (key) {
|
||||||
|
case 'done': return list.filter(r => r.status === 2)
|
||||||
|
case 'delayed': return list.filter(r => r.status !== 2 && (r.delayCount || 0) > 0)
|
||||||
|
case 'pending': return list.filter(r => r.status !== 2 && (r.schedulePercentage || 0) === 0 && (r.delayCount || 0) === 0)
|
||||||
|
case 'running': return list.filter(r => r.status !== 2 && (r.schedulePercentage || 0) > 0 && (r.delayCount || 0) === 0)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
progressStatus (r) {
|
||||||
|
if (r.status === 2) return 'success'
|
||||||
|
if ((r.delayCount || 0) > 0) return 'exception'
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
formatDate (t) {
|
||||||
|
if (!t) return ''
|
||||||
|
const d = new Date(t)
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.kanban-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
.kanban-col {
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.col-header {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-top: 3px solid;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.col-title { color: #303133; }
|
||||||
|
.col-count {
|
||||||
|
background: #e4e7ed;
|
||||||
|
color: #606266;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.col-body {
|
||||||
|
padding: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
max-height: 600px;
|
||||||
|
}
|
||||||
|
.col-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #c0c4cc;
|
||||||
|
padding: 24px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.kb-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.card-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="two-level-filter">
|
<div class="two-level-filter">
|
||||||
<!-- 第一级:进度类别 -->
|
<!-- 第一级:进度类别 — 只有一个时隐藏面板(自动选中) -->
|
||||||
<div class="filter-panel first-level">
|
<div v-if="tabOption.length > 1" class="filter-panel first-level">
|
||||||
<h3 class="panel-title">进度类别</h3>
|
<h3 class="panel-title">进度类别</h3>
|
||||||
<ul class="option-list">
|
<ul class="option-list">
|
||||||
<li v-for="item in tabOption" :key="item.value" :class="{ 'active': defaultTabNode === item.value }"
|
<li v-for="item in tabOption" :key="item.value" :class="{ 'active': defaultTabNode === item.value }"
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
|
|
||||||
<!-- 第二级:一级分类 -->
|
<!-- 第二级:一级分类 -->
|
||||||
<div class="filter-panel second-level">
|
<div class="filter-panel second-level">
|
||||||
<h3 class="panel-title">一级分类</h3>
|
<h3 class="panel-title">
|
||||||
|
一级分类
|
||||||
|
<span v-if="tabOption.length === 1 && defaultTabNode" class="sub-cat">({{ tabOption[0].label }})</span>
|
||||||
|
</h3>
|
||||||
<div class="second-level-content">
|
<div class="second-level-content">
|
||||||
<template v-if="defaultTabNode">
|
<template v-if="defaultTabNode">
|
||||||
<ul class="option-list" v-if="renderFirstLevelOption.length">
|
<ul class="option-list" v-if="renderFirstLevelOption.length">
|
||||||
@@ -51,6 +54,23 @@ export default {
|
|||||||
defaultFirstLevelNode: ""
|
defaultFirstLevelNode: ""
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
tabOption: {
|
||||||
|
immediate: true,
|
||||||
|
handler (v) {
|
||||||
|
// 只有一个进度类别 → 自动选中
|
||||||
|
if (v && v.length === 1 && !this.defaultTabNode) {
|
||||||
|
this.$nextTick(() => this.handleTabChange(v[0].value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderFirstLevelOption (v) {
|
||||||
|
// 只有一个一级分类 → 自动选中
|
||||||
|
if (v && v.length === 1 && !this.defaultFirstLevelNode) {
|
||||||
|
this.$nextTick(() => this.handleFirstLevelChange(v[0].value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
// 根据选中的进度类别过滤一级分类
|
// 根据选中的进度类别过滤一级分类
|
||||||
renderFirstLevelOption () {
|
renderFirstLevelOption () {
|
||||||
@@ -123,12 +143,16 @@ export default {
|
|||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px 16px;
|
padding: 6px 10px;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #606266;
|
||||||
background-color: #f9fafb;
|
background-color: #f9fafb;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
.panel-title .sub-cat {
|
||||||
|
color: #909399; font-size: 11px; font-weight: normal; margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.option-list {
|
.option-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -137,8 +161,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.option-item {
|
.option-item {
|
||||||
padding: 12px 16px;
|
padding: 6px 10px;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
@@ -171,9 +196,9 @@ export default {
|
|||||||
|
|
||||||
.empty-tip {
|
.empty-tip {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-size: 14px;
|
font-size: 11px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 16px;
|
padding: 8px 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-row style="margin-bottom: 10px;">
|
<div class="step-table-toolbar">
|
||||||
<el-col>
|
<el-button type="text" icon="el-icon-plus" @click="addInnerData">新增</el-button>
|
||||||
<el-col :span="1.5">
|
<el-button type="text" icon="el-icon-time" style="color:#e6a23c"
|
||||||
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="addInnerData">新增</el-button>
|
@click="handleBatchDelay" :disabled="selectedRows.length === 0">批量延期</el-button>
|
||||||
<el-button type="warning" plain icon="el-icon-time" size="mini" @click="handleBatchDelay" :disabled="selectedRows.length === 0">批量延期</el-button>
|
<slot name="extra-buttons"></slot>
|
||||||
<slot name="extra-buttons"></slot>
|
</div>
|
||||||
</el-col>
|
<vxe-table size="mini" :height="tableHeight" ref="tableRef" border show-overflow :edit-config="editConfig" :data="innerData"
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
<vxe-table size="mini" height="500" ref="tableRef" border show-overflow :edit-config="editConfig" :data="innerData"
|
|
||||||
:row-config="{ 'isCurrent': true, 'isHover': true }" :column-config="{ 'isCurrent': true }" :sort-config="sortConfig"
|
:row-config="{ 'isCurrent': true, 'isHover': true }" :column-config="{ 'isCurrent': true }" :sort-config="sortConfig"
|
||||||
@checkbox-change="handleCheckboxChange" @checkbox-all="handleCheckboxAll">
|
@checkbox-change="handleCheckboxChange" @checkbox-all="handleCheckboxAll">
|
||||||
<vxe-column type="checkbox" width="50"></vxe-column>
|
<vxe-column type="checkbox" width="50"></vxe-column>
|
||||||
<vxe-column field="sortNum" title="顺序" :edit-render="{ name: 'input' }" width="70" sortable></vxe-column>
|
<vxe-column field="sortNum" title="顺序" :edit-render="{ name: 'input' }" width="70" sortable></vxe-column>
|
||||||
<vxe-column field="secondLevelNode" title="步骤名称" :edit-render="{ name: 'input' }"></vxe-column>
|
<vxe-column field="secondLevelNode" title="步骤名称" :edit-render="{ name: 'input' }"></vxe-column>
|
||||||
<vxe-column field="tabNode" title="进度类别" :edit-render="{ name: 'input' }"></vxe-column>
|
<vxe-column field="tabNode" title="进度类别" :edit-render="{ name: 'input' }"></vxe-column>
|
||||||
<vxe-column field="firstLevelNode" title="一级分类" :edit-render="{ name: 'input' }"></vxe-column>
|
<vxe-column field="firstLevelNode" title="一级分类" :edit-render="{ name: 'input' }"
|
||||||
|
min-width="140" show-overflow="false"></vxe-column>
|
||||||
<vxe-column field="nodeHeader" title="负责人" :edit-render="{}">
|
<vxe-column field="nodeHeader" title="负责人" :edit-render="{}">
|
||||||
<template slot-scope="{ row }" slot="default">
|
<template slot-scope="{ row }" slot="default">
|
||||||
{{ row.nodeHeader }}
|
{{ row.nodeHeader }}
|
||||||
@@ -121,24 +119,21 @@
|
|||||||
<!-- <vxe-button>更多</vxe-button> -->
|
<!-- <vxe-button>更多</vxe-button> -->
|
||||||
</template>
|
</template>
|
||||||
</vxe-column>
|
</vxe-column>
|
||||||
<vxe-column title="操作" width="200" v-if="editable && isCEO">
|
<vxe-column title="操作" width="100" align="center" v-if="editable && isCEO">
|
||||||
<template v-slot:default="{ row }">
|
<template v-slot:default="{ row }">
|
||||||
<template v-if="showEdit(row)">
|
<template v-if="showEdit(row)">
|
||||||
<template v-if="row.trackId">
|
<template v-if="row.trackId">
|
||||||
<template v-if="hasEditStatus(row)">
|
<template v-if="hasEditStatus(row)">
|
||||||
<vxe-button @click="saveRowEvent(row)">保存</vxe-button>
|
<el-button type="text" size="mini" @click="saveRowEvent(row)" style="color:#67c23a">保存</el-button>
|
||||||
<vxe-button @click="cancelRowEvent()">取消</vxe-button>
|
<el-button type="text" size="mini" @click="cancelRowEvent()">取消</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- <vxe-button v-if="row.useFlag == 0" @click="agreeDelay(row)">
|
<el-button type="text" size="mini" @click="editRowEvent(row)">编辑</el-button>
|
||||||
同意延期
|
<el-button type="text" size="mini" style="color:#f56c6c" @click="handleDelete(row)">删除</el-button>
|
||||||
</vxe-button> -->
|
|
||||||
<vxe-button @click="editRowEvent(row)">编辑</vxe-button>
|
|
||||||
<vxe-button @click="handleDelete(row)">删除</vxe-button>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<vxe-button @click="handleAdd(row)">新增</vxe-button>
|
<el-button type="text" size="mini" @click="handleAdd(row)">新增</el-button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -282,6 +277,7 @@ export default {
|
|||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
innerData: [],
|
innerData: [],
|
||||||
|
tableHeight: 640,
|
||||||
buttonLoading: false,
|
buttonLoading: false,
|
||||||
editConfig: { trigger: 'manual', mode: 'row' },
|
editConfig: { trigger: 'manual', mode: 'row' },
|
||||||
sortConfig: {
|
sortConfig: {
|
||||||
@@ -808,4 +804,27 @@ export default {
|
|||||||
color: #409eff !important;
|
color: #409eff !important;
|
||||||
/* 蓝色:申请中 */
|
/* 蓝色:申请中 */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<style scoped>
|
||||||
|
/* 顶部工具条:text 风格按钮,紧凑排列 */
|
||||||
|
.step-table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.step-table-toolbar > .el-button {
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
}
|
||||||
|
/* vxe-table 行高/字号放大,避免覆盖 */
|
||||||
|
.step-table-toolbar + .vxe-table /deep/ .vxe-body--column,
|
||||||
|
.step-table-toolbar + .vxe-table /deep/ .vxe-header--column {
|
||||||
|
height: 38px !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
}
|
||||||
|
.step-table-toolbar + .vxe-table /deep/ .vxe-cell {
|
||||||
|
white-space: normal !important;
|
||||||
|
word-break: break-word !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
169
ruoyi-ui/src/views/oa/project/pace/components/StepTimeline.vue
Normal file
169
ruoyi-ui/src/views/oa/project/pace/components/StepTimeline.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="step-timeline">
|
||||||
|
<div class="tl-header">
|
||||||
|
<span class="title">步骤时间线({{ stepList.length }})</span>
|
||||||
|
<slot name="extra-buttons" />
|
||||||
|
</div>
|
||||||
|
<div v-if="!stepList.length" class="empty">无步骤</div>
|
||||||
|
<div v-else class="tl-list">
|
||||||
|
<div v-for="step in stepList" :key="step.trackId"
|
||||||
|
class="tl-item" :class="cardClass(step)" @click="$emit('edit', step)">
|
||||||
|
<div class="tl-dot" :style="{ background: dotColor(step) }">
|
||||||
|
<span>{{ step.sortNum != null ? step.sortNum : '·' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tl-card">
|
||||||
|
<div class="tl-line1">
|
||||||
|
<span class="step-name">{{ step.stepName || '(未命名)' }}</span>
|
||||||
|
<el-tag v-if="step.status === 2" type="success" size="mini">已完成</el-tag>
|
||||||
|
<el-tag v-else-if="step.status === 1" type="warning" size="mini">待验收</el-tag>
|
||||||
|
<el-tag v-else type="info" size="mini">进行中</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="tl-line2">
|
||||||
|
<span class="cat">{{ step.tabNode || '' }} · {{ step.firstLevelNode || '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tl-line3">
|
||||||
|
<span class="meta">
|
||||||
|
<i class="el-icon-user"></i>
|
||||||
|
{{ step.nodeHeader || '—' }}
|
||||||
|
</span>
|
||||||
|
<span class="meta">
|
||||||
|
<i class="el-icon-time"></i>
|
||||||
|
{{ formatDate(step.planEnd) }}
|
||||||
|
</span>
|
||||||
|
<span class="meta remain" :style="{ color: remainColor(step) }">
|
||||||
|
{{ remainText(step) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="(step.fileCount || 0) > 0" class="meta">
|
||||||
|
<i class="el-icon-paperclip"></i>{{ step.fileCount }}
|
||||||
|
</span>
|
||||||
|
<span v-if="(step.picCount || 0) > 0" class="meta">
|
||||||
|
<i class="el-icon-picture-outline"></i>{{ step.picCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'StepTimeline',
|
||||||
|
props: {
|
||||||
|
stepList: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatDate (t) {
|
||||||
|
if (!t) return '未设置'
|
||||||
|
const d = new Date(t)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
},
|
||||||
|
daysUntil (t) {
|
||||||
|
if (!t) return null
|
||||||
|
const d = new Date(t); d.setHours(0, 0, 0, 0)
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||||
|
return Math.floor((d - today) / 86400000)
|
||||||
|
},
|
||||||
|
remainText (s) {
|
||||||
|
if (s.status === 2) return ''
|
||||||
|
if (!s.planEnd) return ''
|
||||||
|
const n = this.daysUntil(s.planEnd)
|
||||||
|
if (n < 0) return `逾期 ${-n} 天`
|
||||||
|
if (n === 0) return '今日到期'
|
||||||
|
if (n <= 3) return `临期 ${n} 天`
|
||||||
|
return `剩余 ${n} 天`
|
||||||
|
},
|
||||||
|
remainColor (s) {
|
||||||
|
if (s.status === 2) return '#67c23a'
|
||||||
|
const n = this.daysUntil(s.planEnd)
|
||||||
|
if (n == null) return '#909399'
|
||||||
|
if (n < 0) return '#f56c6c'
|
||||||
|
if (n <= 3) return '#e6a23c'
|
||||||
|
return '#909399'
|
||||||
|
},
|
||||||
|
dotColor (s) {
|
||||||
|
if (s.status === 2) return '#67c23a'
|
||||||
|
if (s.status === 1) return '#e6a23c'
|
||||||
|
const n = this.daysUntil(s.planEnd)
|
||||||
|
if (n != null && n < 0) return '#f56c6c'
|
||||||
|
return '#409eff'
|
||||||
|
},
|
||||||
|
cardClass (s) {
|
||||||
|
if (s.status === 2) return 'done'
|
||||||
|
const n = this.daysUntil(s.planEnd)
|
||||||
|
if (n != null && n < 0) return 'delayed'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.step-timeline { padding: 8px 4px; }
|
||||||
|
.tl-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
.title { font-weight: 600; color: #303133; font-size: 13px; }
|
||||||
|
}
|
||||||
|
.empty { text-align: center; color: #909399; padding: 32px 0; font-size: 12px; }
|
||||||
|
.tl-list {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 26px;
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 14px; top: 6px; bottom: 6px;
|
||||||
|
width: 2px;
|
||||||
|
background: #ebeef5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tl-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover .tl-card { box-shadow: 0 2px 8px rgba(0,0,0,0.08); border-color: #409eff; }
|
||||||
|
}
|
||||||
|
.tl-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -22px; top: 4px;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 1px #ebeef5;
|
||||||
|
}
|
||||||
|
.tl-card {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.tl-item.done .tl-card { background: #f0f9eb; }
|
||||||
|
.tl-item.delayed .tl-card { background: #fff1f0; border-color: #fbc4c4; }
|
||||||
|
|
||||||
|
.tl-line1 {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
.step-name { font-weight: 600; color: #303133; font-size: 13px; }
|
||||||
|
}
|
||||||
|
.tl-line2 {
|
||||||
|
margin-top: 2px;
|
||||||
|
.cat { font-size: 11px; color: #909399; }
|
||||||
|
}
|
||||||
|
.tl-line3 {
|
||||||
|
margin-top: 5px;
|
||||||
|
display: flex; flex-wrap: wrap; gap: 12px;
|
||||||
|
.meta {
|
||||||
|
font-size: 11px; color: #606266;
|
||||||
|
i { margin-right: 2px; }
|
||||||
|
}
|
||||||
|
.remain { font-weight: 600; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,31 +43,29 @@
|
|||||||
|
|
||||||
<el-divider></el-divider>
|
<el-divider></el-divider>
|
||||||
<div style="position: relative;">
|
<div style="position: relative;">
|
||||||
<el-radio-group v-model="viewMode" style="position: absolute; top: -40px; left: 0; z-index: 9999;">
|
<el-radio-group v-model="viewMode" size="mini"
|
||||||
<el-radio-button label="xmind">思维导图</el-radio-button>
|
style="position: absolute; top: -40px; left: 0; z-index: 9999;">
|
||||||
<el-radio-button label="table">表格</el-radio-button>
|
<el-radio-button label="table">表格</el-radio-button>
|
||||||
|
<el-radio-button label="timeline">时间线</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<el-row v-show="viewMode === 'xmind'">
|
<div class="step-layout">
|
||||||
<xmind :list="projectScheduleStepList" @refresh="getList"></xmind>
|
<div class="step-side">
|
||||||
</el-row>
|
|
||||||
<el-row :gutter="20" v-show="viewMode === 'table'">
|
|
||||||
<el-col :span="4">
|
|
||||||
<menu-select ref="menuSelectRef" :tabOption="tabOption" :firstLevelOption="firstLevelOption"
|
<menu-select ref="menuSelectRef" :tabOption="tabOption" :firstLevelOption="firstLevelOption"
|
||||||
@change="handleChange"></menu-select>
|
@change="handleChange"></menu-select>
|
||||||
</el-col>
|
</div>
|
||||||
<el-col :span="20">
|
<div class="step-main">
|
||||||
<step-table ref="stepTableRef" :defaultTabNode="defaultTabNode" :defaultFirstLevelNode="defaultFirstLevelNode"
|
<div class="step-toolbar">
|
||||||
:stepList="filterList" @refresh="getList" @add="submitForm" @delete="handleDelete" :editable="true"
|
<el-button type="text" icon="el-icon-refresh" @click="getList">刷新</el-button>
|
||||||
:master="master">
|
<el-checkbox v-model="filterParams.onlyMy" style="margin-left:8px;">只看我的</el-checkbox>
|
||||||
<template slot="extra-buttons">
|
<el-checkbox v-model="filterParams.onlyUnfinished">只看未完成</el-checkbox>
|
||||||
<el-button type="primary" plain icon="el-icon-camera" size="mini" @click="handleOverview">总览</el-button>
|
</div>
|
||||||
<el-button type="primary" plain icon="el-icon-refresh" size="mini" @click="getList">刷新</el-button>
|
<step-timeline v-if="viewMode === 'timeline'" :stepList="filterList" @edit="handleEditStep" />
|
||||||
<el-checkbox style="margin-left: 10px;" v-model="filterParams.onlyMy">只看我的</el-checkbox>
|
<step-table v-show="viewMode === 'table'" ref="stepTableRef"
|
||||||
<el-checkbox v-model="filterParams.onlyUnfinished">只看未完成</el-checkbox>
|
:defaultTabNode="defaultTabNode" :defaultFirstLevelNode="defaultFirstLevelNode"
|
||||||
</template>
|
:stepList="filterList" @refresh="getList" @add="submitForm" @delete="handleDelete"
|
||||||
</step-table>
|
:editable="true" :master="master" />
|
||||||
</el-col>
|
</div>
|
||||||
</el-row>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -78,7 +76,7 @@ import { addProjectScheduleStep, delProjectScheduleStep, getProjectScheduleStep,
|
|||||||
import ProjectInfo from "@/components/fad-service/ProjectInfo/index.vue";
|
import ProjectInfo from "@/components/fad-service/ProjectInfo/index.vue";
|
||||||
import MenuSelect from "@/views/oa/project/pace/components/MenuSelect.vue";
|
import MenuSelect from "@/views/oa/project/pace/components/MenuSelect.vue";
|
||||||
import StepTable from "@/views/oa/project/pace/components/StepTable.vue";
|
import StepTable from "@/views/oa/project/pace/components/StepTable.vue";
|
||||||
import Xmind from "./xmind.vue";
|
import StepTimeline from "@/views/oa/project/pace/components/StepTimeline.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ProjectScheduleStep",
|
name: "ProjectScheduleStep",
|
||||||
@@ -115,13 +113,13 @@ export default {
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
StepTable,
|
StepTable,
|
||||||
|
StepTimeline,
|
||||||
MenuSelect,
|
MenuSelect,
|
||||||
Xmind,
|
|
||||||
ProjectInfo,
|
ProjectInfo,
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
viewMode: 'xmind',
|
viewMode: 'table',
|
||||||
defaultTabNode: "",
|
defaultTabNode: "",
|
||||||
defaultFirstLevelNode: "",
|
defaultFirstLevelNode: "",
|
||||||
// 按钮loading
|
// 按钮loading
|
||||||
@@ -236,6 +234,20 @@ export default {
|
|||||||
tabNode: item.tabNode // 关联的 tabNode(此时必然正确)
|
tabNode: item.tabNode // 关联的 tabNode(此时必然正确)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 按中文数字 一二三四五六七八九十百 顺序排(无前缀按字母顺序排到后面)
|
||||||
|
const CN_NUM = ['一','二','三','四','五','六','七','八','九','十','十一','十二','十三','十四','十五','十六','十七','十八','十九','二十'];
|
||||||
|
const cnOrder = (label) => {
|
||||||
|
if (!label) return 9999;
|
||||||
|
const head = String(label).trim().split(/[、.\s]/)[0];
|
||||||
|
const idx = CN_NUM.indexOf(head);
|
||||||
|
return idx === -1 ? 9999 : idx;
|
||||||
|
};
|
||||||
|
firstLevelNodes.sort((a, b) => {
|
||||||
|
const oa = cnOrder(a.label), ob = cnOrder(b.label);
|
||||||
|
if (oa !== ob) return oa - ob;
|
||||||
|
return String(a.label).localeCompare(String(b.label));
|
||||||
|
});
|
||||||
|
|
||||||
return firstLevelNodes;
|
return firstLevelNodes;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -302,6 +314,16 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 时间线点击行 → 切换到表格视图并尝试聚焦该 trackId
|
||||||
|
handleEditStep (step) {
|
||||||
|
if (!step || !step.trackId) return
|
||||||
|
this.viewMode = 'table'
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.stepTableRef && this.$refs.stepTableRef.scrollToTrack) {
|
||||||
|
this.$refs.stepTableRef.scrollToTrack(step.trackId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
applyInitialStepFocus () {
|
applyInitialStepFocus () {
|
||||||
const hint = this.initialStepFocus;
|
const hint = this.initialStepFocus;
|
||||||
if (!hint || !this.projectScheduleStepList.length) {
|
if (!hint || !this.projectScheduleStepList.length) {
|
||||||
@@ -461,3 +483,44 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* 左 + 右 自适应布局:左侧分类自然宽,不裁剪文字 */
|
||||||
|
.step-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.step-side {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: 200px;
|
||||||
|
/* 让一级分类的文字(如"一、技术审查")完整显示,左侧菜单整体缩小 */
|
||||||
|
}
|
||||||
|
.step-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.step-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格行高放大,避免文字被遮挡 */
|
||||||
|
::v-deep .vxe-table .vxe-body--row .vxe-body--column,
|
||||||
|
::v-deep .vxe-table .vxe-header--row .vxe-header--column {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
::v-deep .vxe-table .vxe-body--row .vxe-body--column {
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
::v-deep .vxe-table .vxe-cell {
|
||||||
|
white-space: normal !important;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container" v-loading="loading">
|
<div class="app-container" v-loading="loading">
|
||||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
|
<el-form v-show="!detailMode && showSearch" :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="68px">
|
||||||
<el-form-item label="项目名称" prop="projectId">
|
<el-form-item label="项目名称" prop="projectId" class="form-project-select">
|
||||||
<project-select v-model="queryParams.projectId" placeholder="请选择项目" clearable />
|
<project-select v-model="queryParams.projectId" placeholder="请选择项目" clearable />
|
||||||
<!-- <el-select v-model="queryParams.projectId" filterable placeholder="请选择">
|
|
||||||
<el-option v-for="item in projects" :key="item.projectId" :label="item.projectName" :value="item.projectId">
|
|
||||||
</el-option>
|
|
||||||
</el-select> -->
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="项目编号" prop="projectNum">
|
<el-form-item label="项目编号" prop="projectNum">
|
||||||
<el-input v-model="queryParams.projectNum" placeholder="请输入项目编号" clearable />
|
<el-input v-model="queryParams.projectNum" placeholder="请输入项目编号" clearable />
|
||||||
@@ -19,7 +15,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="项目代号" prop="projectCode">
|
<el-form-item label="项目代号" prop="projectCode">
|
||||||
<el-select v-model="queryParams.projectCode" placeholder="请选择代号类型" style="width: 100%" filterable
|
<el-select v-model="queryParams.projectCode" placeholder="代号" style="width: 120px" filterable
|
||||||
@change="handleQuery">
|
@change="handleQuery">
|
||||||
<el-option v-for="dict in dict.type.sys_project_code" :key="dict.value" :label="dict.label"
|
<el-option v-for="dict in dict.type.sys_project_code" :key="dict.value" :label="dict.label"
|
||||||
:value="dict.value">
|
:value="dict.value">
|
||||||
@@ -55,7 +51,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-row :gutter="10" class="mb8">
|
<el-row v-show="!detailMode" :gutter="10" class="mb8">
|
||||||
<el-col :span="1.5">
|
<el-col :span="1.5">
|
||||||
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">绑定进度
|
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">绑定进度
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -69,9 +65,16 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
|
||||||
<el-row :gutter="20">
|
<el-tabs v-show="!detailMode" v-model="statusTab" @tab-click="onStatusTab" class="compact-tabs">
|
||||||
<el-table v-loading="loading" :data="scheduleList" @selection-change="handleSelectionChange">
|
<el-tab-pane label="未完成" name="undone" />
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<el-tab-pane label="全部" name="all" />
|
||||||
|
<el-tab-pane label="已完成" name="done" />
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-row v-show="!detailMode" :gutter="20">
|
||||||
|
<el-table v-loading="loading" :data="scheduleList" @selection-change="handleSelectionChange"
|
||||||
|
:row-class-name="paceRowClass" stripe>
|
||||||
|
<el-table-column type="selection" width="44" align="center" />
|
||||||
<el-table-column label="代号" prop="projectCode" align="center">
|
<el-table-column label="代号" prop="projectCode" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-tag v-if="scope.row.projectCode == null" type="danger">无</el-tag>
|
<el-tag v-if="scope.row.projectCode == null" type="danger">无</el-tag>
|
||||||
@@ -99,8 +102,43 @@
|
|||||||
<span v-else style="">一般项目</span>
|
<span v-else style="">一般项目</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="延期进度数" prop="delayCount" />
|
<el-table-column label="进度" min-width="240">
|
||||||
<el-table-column label="未完成进度" prop="unFinishCount" />
|
<template slot-scope="scope">
|
||||||
|
<div class="progress-cell">
|
||||||
|
<svg class="mini-donut" viewBox="0 0 36 36">
|
||||||
|
<circle cx="18" cy="18" r="15" fill="none" stroke="#ebeef5" stroke-width="6" />
|
||||||
|
<circle cx="18" cy="18" r="15" fill="none"
|
||||||
|
:stroke="progressColor(scope.row)" stroke-width="6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
:stroke-dasharray="`${(scope.row.schedulePercentage || 0) * 0.942} 100`"
|
||||||
|
transform="rotate(-90 18 18)" />
|
||||||
|
<text x="18" y="20" text-anchor="middle" font-size="10" fill="#303133" font-weight="600">
|
||||||
|
{{ Math.round(scope.row.schedulePercentage || 0) }}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div class="progress-meta">
|
||||||
|
<span class="step-count">{{ doneSteps(scope.row) }} / {{ scope.row.totalCount || 0 }} 步</span>
|
||||||
|
<div class="progress-tags">
|
||||||
|
<!-- 进度 100% 但项目状态尚未标为完成 → 高亮提示,点击切换 -->
|
||||||
|
<a v-if="Math.round(scope.row.schedulePercentage || 0) === 100 && scope.row.status !== 2"
|
||||||
|
class="all-done-hint" @click.stop="handleComplete(scope.row)">
|
||||||
|
<i class="el-icon-success"></i>
|
||||||
|
进度已完成 · 点击切换为已完成
|
||||||
|
</a>
|
||||||
|
<el-tag v-if="(scope.row.unFinishCount || 0) > 0" type="warning" size="mini" effect="dark">
|
||||||
|
未完 {{ scope.row.unFinishCount }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="(scope.row.delayCount || 0) > 0" type="danger" size="mini" effect="dark">
|
||||||
|
⚠ 延期 {{ scope.row.delayCount }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="scope.row.currentStepName" type="info" size="mini">
|
||||||
|
当前: {{ scope.row.currentStepName }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="完成状态" align="center" prop="sortNum">
|
<el-table-column label="完成状态" align="center" prop="sortNum">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-select size="mini" v-model="scope.row.status" placeholder="请选择完成状态"
|
<el-select size="mini" v-model="scope.row.status" placeholder="请选择完成状态"
|
||||||
@@ -120,6 +158,9 @@
|
|||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleDetail(scope.row)">进度详情
|
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleDetail(scope.row)">进度详情
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button size="mini" type="text" icon="el-icon-bell" style="color:#e6a23c"
|
||||||
|
v-if="scope.row.status !== 2" @click="handleUrge(scope.row)">催促
|
||||||
|
</el-button>
|
||||||
<el-button size="mini" type="text" icon="el-icon-time" @click="handlePostpone(scope.row)">延期记录
|
<el-button size="mini" type="text" icon="el-icon-time" @click="handlePostpone(scope.row)">延期记录
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="mini" type="text" icon="el-icon-s-order" @click="handleOpLog(scope.row)">操作历史
|
<el-button size="mini" type="text" icon="el-icon-s-order" @click="handleOpLog(scope.row)">操作历史
|
||||||
@@ -138,14 +179,21 @@
|
|||||||
@pagination="getList" />
|
@pagination="getList" />
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-drawer title="进度详情" :visible.sync="detailDrawer" direction="btt" size="90%" :before-close="closeDetailShow">
|
<!-- 旧抽屉保留为 v-if false,避免误改其它逻辑 -->
|
||||||
<div style="padding:0 20px">
|
<el-drawer v-if="false" title="进度详情" :visible.sync="detailDrawer" direction="btt" size="90%" />
|
||||||
<project-schedule-step :scheduleId="scheduleDetail.scheduleId" :master="scheduleDetail.functionary"
|
|
||||||
:projectName="scheduleDetail.projectName" :projectStatus="scheduleDetail.projectStatus"
|
<!-- 进度详情 inline 面板(master/detail,同页面切换,无抽屉) -->
|
||||||
:isTop="scheduleDetail.isTop" :projectId="scheduleDetail.projectId"
|
<div v-if="detailMode" class="detail-pane">
|
||||||
:initial-step-focus="scheduleStepFocusHint" />
|
<div class="detail-bar">
|
||||||
|
<el-button type="text" icon="el-icon-arrow-left" @click="closeDetailShow">返回项目列表</el-button>
|
||||||
|
<span class="bar-divider"></span>
|
||||||
|
<span class="bar-title">进度详情</span>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
<project-schedule-step :scheduleId="scheduleDetail.scheduleId" :master="scheduleDetail.functionary"
|
||||||
|
:projectName="scheduleDetail.projectName" :projectStatus="scheduleDetail.projectStatus"
|
||||||
|
:isTop="scheduleDetail.isTop" :projectId="scheduleDetail.projectId"
|
||||||
|
:initial-step-focus="scheduleStepFocusHint" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormDialog v-model="addDialog" :projects="projects" @save="handleSave" />
|
<FormDialog v-model="addDialog" :projects="projects" @save="handleSave" />
|
||||||
|
|
||||||
@@ -164,7 +212,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { listProject } from "@/api/oa/project";
|
import { listProject } from "@/api/oa/project";
|
||||||
import { addByProjectId, delProjectSchedule, getProjectSchedule, listProjectSchedule, updateProjectSchedule } from "@/api/oa/projectSchedule";
|
import { addByProjectId, delProjectSchedule, getProjectSchedule, listProjectSchedule, updateProjectSchedule, urgeProgress } from "@/api/oa/projectSchedule";
|
||||||
import { listUser } from "@/api/system/user";
|
import { listUser } from "@/api/system/user";
|
||||||
import ProjectSelect from "@/components/fad-service/ProjectSelect/index.vue";
|
import ProjectSelect from "@/components/fad-service/ProjectSelect/index.vue";
|
||||||
import UserSelect from "@/components/UserSelect/index.vue";
|
import UserSelect from "@/components/UserSelect/index.vue";
|
||||||
@@ -186,7 +234,9 @@ export default {
|
|||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
statusTab: 'undone',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
detailMode: false,
|
||||||
detailDrawer: false,
|
detailDrawer: false,
|
||||||
fileShow: false,
|
fileShow: false,
|
||||||
addDialog: false,
|
addDialog: false,
|
||||||
@@ -239,6 +289,8 @@ export default {
|
|||||||
mounted () {
|
mounted () {
|
||||||
this.currentUser = this.$store.state.user
|
this.currentUser = this.$store.state.user
|
||||||
this.applyPaceRouteQueryBeforeFetch();
|
this.applyPaceRouteQueryBeforeFetch();
|
||||||
|
// 默认进入"未完成" tab
|
||||||
|
if (this.queryParams.status === undefined) this.queryParams.status = 1;
|
||||||
this.getList();
|
this.getList();
|
||||||
this.getProjectList();
|
this.getProjectList();
|
||||||
this.getAllUser();
|
this.getAllUser();
|
||||||
@@ -248,6 +300,45 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
handleUrge (row) {
|
||||||
|
if (!row.header && !row.functionary) {
|
||||||
|
this.$modal.msgWarning('该项目没有设置负责人')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const who = row.header || row.functionary
|
||||||
|
this.$confirm(`确认通过 IM 催促 ${who}(项目:${row.projectName})?`, '催促进度', {
|
||||||
|
confirmButtonText: '发送',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
urgeProgress(row.scheduleId).then(res => {
|
||||||
|
if (res && res.code === 200) this.$modal.msgSuccess(`已通过 IM 催促 ${who}`)
|
||||||
|
else this.$modal.msgError(res && res.msg || '催促失败')
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
},
|
||||||
|
onStatusTab () {
|
||||||
|
if (this.statusTab === 'all') this.queryParams.status = undefined
|
||||||
|
else if (this.statusTab === 'done') this.queryParams.status = 2
|
||||||
|
else this.queryParams.status = 1
|
||||||
|
this.queryParams.pageNum = 1
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
progressColor (row) {
|
||||||
|
if (row.status === 2) return '#67c23a'
|
||||||
|
if ((row.delayCount || 0) > 0) return '#f56c6c'
|
||||||
|
return '#409eff'
|
||||||
|
},
|
||||||
|
doneSteps (row) {
|
||||||
|
const total = row.totalCount || 0
|
||||||
|
const unfin = row.unFinishCount || 0
|
||||||
|
return Math.max(0, total - unfin)
|
||||||
|
},
|
||||||
|
paceRowClass ({ row }) {
|
||||||
|
if (row.status === 2) return ''
|
||||||
|
if ((row.delayCount || 0) > 0) return 'row-delayed'
|
||||||
|
return ''
|
||||||
|
},
|
||||||
applyPaceRouteQueryBeforeFetch () {
|
applyPaceRouteQueryBeforeFetch () {
|
||||||
const q = this.$route.query || {};
|
const q = this.$route.query || {};
|
||||||
if (q.projectId != null && q.projectId !== '') {
|
if (q.projectId != null && q.projectId !== '') {
|
||||||
@@ -300,8 +391,10 @@ export default {
|
|||||||
},
|
},
|
||||||
closeDetailShow (done) {
|
closeDetailShow (done) {
|
||||||
this.scheduleStepFocusHint = null;
|
this.scheduleStepFocusHint = null;
|
||||||
|
this.detailMode = false;
|
||||||
|
this.detailDrawer = false;
|
||||||
this.getList();
|
this.getList();
|
||||||
done()
|
if (typeof done === 'function') done()
|
||||||
},
|
},
|
||||||
getAllUser () {
|
getAllUser () {
|
||||||
listUser({ pageNum: 1, pageSize: 999 }).then(res => {
|
listUser({ pageNum: 1, pageSize: 999 }).then(res => {
|
||||||
@@ -404,7 +497,7 @@ export default {
|
|||||||
},
|
},
|
||||||
getScheduleDetail (row) {
|
getScheduleDetail (row) {
|
||||||
this.scheduleDetail = row
|
this.scheduleDetail = row
|
||||||
this.detailDrawer = true
|
this.detailMode = true
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ========= 左侧主列表删除(支持单删或批量 ids) ========= */
|
/* ========= 左侧主列表删除(支持单删或批量 ids) ========= */
|
||||||
@@ -475,4 +568,88 @@ export default {
|
|||||||
.file-actions el-button+el-button {
|
.file-actions el-button+el-button {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 进度列 */
|
||||||
|
.progress-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.mini-donut {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.progress-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.progress-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.step-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.all-done-hint {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #f0f9eb;
|
||||||
|
border: 1px solid #c2e7b0;
|
||||||
|
color: #67c23a;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
i { font-size: 13px; }
|
||||||
|
&:hover {
|
||||||
|
background: #67c23a;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #67c23a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 含延期的行整行染浅红 */
|
||||||
|
::v-deep .el-table .row-delayed > td.el-table__cell {
|
||||||
|
background: #fff1f0 !important;
|
||||||
|
}
|
||||||
|
::v-deep .el-table .row-delayed:hover > td.el-table__cell {
|
||||||
|
background: #ffd8d6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 项目名称(搜索代号 + 项目选择)放在一行 */
|
||||||
|
::v-deep .form-project-select .el-form-item__content {
|
||||||
|
width: 360px;
|
||||||
|
}
|
||||||
|
::v-deep .form-project-select .project-select-wrap {
|
||||||
|
width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度详情 inline 面板 */
|
||||||
|
.detail-pane {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 600px;
|
||||||
|
}
|
||||||
|
.detail-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.bar-divider {
|
||||||
|
width: 1px; height: 14px; background: #dcdfe6;
|
||||||
|
}
|
||||||
|
.bar-title { font-weight: 600; color: #303133; font-size: 13px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,306 +1,201 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="100px">
|
<!-- 状态筛选 -->
|
||||||
<!-- 状态 -->
|
<el-tabs v-model="statusTab" @tab-click="onStatusTab" class="compact-tabs">
|
||||||
<!-- <el-form-item label="状态" prop="status">
|
<el-tab-pane name="undone">
|
||||||
<el-select v-model="queryParams.status" placeholder="请选择状态">
|
<span slot="label" :style="stat.delayed > 0 ? 'color:#f56c6c;' : ''">
|
||||||
<el-option label="进行中" value="0" />
|
<i v-if="stat.delayed > 0" class="el-icon-warning-outline"></i>
|
||||||
<el-option label="待验收" value="1" />
|
未完成 ({{ stat.undone }})
|
||||||
<el-option label="已完成" value="2" />
|
</span>
|
||||||
</el-select>
|
</el-tab-pane>
|
||||||
</el-form-item> -->
|
<el-tab-pane name="pending">
|
||||||
<!-- 计划结束时间 -->
|
<span slot="label">待验收 ({{ stat.pending }})</span>
|
||||||
<el-form-item label="计划结束时间" prop="planEnd">
|
</el-tab-pane>
|
||||||
|
<el-tab-pane name="done">
|
||||||
|
<span slot="label">已完成 ({{ stat.done }})</span>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="全部" name="all" />
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-form :model="queryParams" ref="queryForm" size="mini" :inline="true" v-show="showSearch"
|
||||||
|
label-width="60px" class="compact-search">
|
||||||
|
<el-form-item label="项目" prop="projectName">
|
||||||
|
<el-input v-model="queryParams.projectName" placeholder="项目名" clearable style="width: 180px"
|
||||||
|
@keyup.enter.native="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="步骤" prop="stepName">
|
||||||
|
<el-input v-model="queryParams.stepName" placeholder="步骤名" clearable style="width: 160px"
|
||||||
|
@keyup.enter.native="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计划完成" prop="planEnd">
|
||||||
<el-date-picker v-model="queryParams.planEndRange" type="daterange" value-format="yyyy-MM-dd"
|
<el-date-picker v-model="queryParams.planEndRange" type="daterange" value-format="yyyy-MM-dd"
|
||||||
placeholder="选择计划结束时间" />
|
start-placeholder="开始" end-placeholder="结束" style="width: 220px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
||||||
|
|
||||||
<el-row :gutter="10" class="mb8">
|
<el-table v-loading="loading" :data="projectScheduleStepList" stripe size="small"
|
||||||
<el-col :span="1.5">
|
:row-class-name="rowClassName" @row-click="goToProject">
|
||||||
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出</el-button>
|
<el-table-column label="所属项目" prop="projectName" min-width="180" show-overflow-tooltip>
|
||||||
</el-col>
|
|
||||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="projectScheduleStepList" @selection-change="handleSelectionChange">
|
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
|
||||||
<el-table-column label="所属项目" align="center" prop="projectName" />
|
|
||||||
<el-table-column label="步骤名称" align="center" prop="tabNode">
|
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span>{{ scope.row.tabNode }}/ {{ scope.row.firstLevelNode }} / {{ scope.row.secondLevelNode }}</span>
|
<span style="color:#409eff; cursor:pointer">{{ scope.row.projectName || '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="计划完成" align="center" prop="planEnd" width="180">
|
<el-table-column label="步骤" prop="secondLevelNode" min-width="200" show-overflow-tooltip>
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span>{{ parseTime(scope.row.planEnd, '{y}-{m}-{d}') }}</span>
|
<span class="path">{{ scope.row.tabNode }} · {{ scope.row.firstLevelNode }} ·</span>
|
||||||
|
<span class="step-name">{{ scope.row.secondLevelNode }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剩余时间" align="center" prop="endTime" width="180">
|
<el-table-column label="计划完成" prop="planEnd" width="110" align="center">
|
||||||
|
<template slot-scope="scope">{{ parseTime(scope.row.planEnd, '{y}-{m}-{d}') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="剩余" width="120" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span v-if="scope.row.status == 2" style="color: #36d399">{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}
|
<span v-if="scope.row.status === 2" style="color:#67c23a;">
|
||||||
已完成</span>
|
✓ {{ parseTime(scope.row.endTime, '{m}-{d}') }}
|
||||||
<span v-else-if="scope.row.status == 1" style="color: #4096ff">待验收</span>
|
|
||||||
<span v-else>
|
|
||||||
<!-- 调用计算方法获取剩余天数 -->
|
|
||||||
<template v-if="scope.row.planEnd">
|
|
||||||
<span v-if="calcRemainingDays(scope.row.planEnd) < 0" style="color: #f56c6c">已逾期 {{
|
|
||||||
-calcRemainingDays(scope.row.planEnd) }} 天</span>
|
|
||||||
<span v-else-if="calcRemainingDays(scope.row.planEnd) <= 3" style="color: #e6a23c">临期 | 还剩 {{
|
|
||||||
calcRemainingDays(scope.row.planEnd) }} 天</span>
|
|
||||||
<span v-else style="color: #67c23a">还剩 {{ calcRemainingDays(scope.row.planEnd) }} 天</span>
|
|
||||||
</template>
|
|
||||||
<span v-else>未设置计划日期</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="scope.row.status === 1" style="color:#409eff;">待验收</span>
|
||||||
|
<template v-else-if="scope.row.planEnd">
|
||||||
|
<el-tag v-if="dayDiff(scope.row.planEnd) < 0" type="danger" size="mini" effect="dark">
|
||||||
|
逾期 {{ -dayDiff(scope.row.planEnd) }} 天
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else-if="dayDiff(scope.row.planEnd) === 0" type="danger" size="mini" effect="plain">
|
||||||
|
今日到期
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else-if="dayDiff(scope.row.planEnd) <= 3" type="warning" size="mini" effect="plain">
|
||||||
|
剩 {{ dayDiff(scope.row.planEnd) }} 天
|
||||||
|
</el-tag>
|
||||||
|
<span v-else style="color:#67c23a;">剩 {{ dayDiff(scope.row.planEnd) }} 天</span>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color:#c0c4cc;">未设</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" align="center" prop="status">
|
<el-table-column label="状态" prop="status" width="90" align="center">
|
||||||
<!-- 0进行中,1待验收,2已完成 -->
|
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span>{{ scope.row.status === 0 ? '进行中' : (scope.row.status === 1 ? '待验收' : '已完成') }}</span>
|
<el-tag size="mini" :type="statusTagType(scope.row.status)">
|
||||||
|
{{ statusLabel(scope.row.status) }}
|
||||||
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
||||||
@pagination="getList" />
|
@pagination="getList" />
|
||||||
|
|
||||||
<!-- 添加或修改项目进度步骤跟踪对话框 -->
|
|
||||||
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
|
|
||||||
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
|
|
||||||
</el-form>
|
|
||||||
<div slot="footer" class="dialog-footer">
|
|
||||||
<el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
|
|
||||||
<el-button @click="cancel">取 消</el-button>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { addProjectScheduleStep, delProjectScheduleStep, getProjectScheduleStep, listMyPage as listProjectScheduleStep, updateProjectScheduleStep } from "@/api/oa/projectScheduleStep";
|
import { listMyPage as listProjectScheduleStep } from '@/api/oa/projectScheduleStep'
|
||||||
import { listUser } from "@/api/system/user";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ProjectScheduleStep",
|
name: 'MyProjectScheduleStep',
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
// 按钮loading
|
loading: false,
|
||||||
buttonLoading: false,
|
showSearch: false,
|
||||||
// 用户列表loading
|
|
||||||
userLoading: false,
|
|
||||||
// 遮罩层
|
|
||||||
loading: true,
|
|
||||||
// 选中数组
|
|
||||||
ids: [],
|
|
||||||
// 非单个禁用
|
|
||||||
single: true,
|
|
||||||
// 非多个禁用
|
|
||||||
multiple: true,
|
|
||||||
// 显示搜索条件
|
|
||||||
showSearch: true,
|
|
||||||
// 总条数
|
|
||||||
total: 0,
|
total: 0,
|
||||||
// 项目进度步骤跟踪表格数据
|
statusTab: 'all',
|
||||||
|
stat: { undone: 0, pending: 0, done: 0, delayed: 0 },
|
||||||
projectScheduleStepList: [],
|
projectScheduleStepList: [],
|
||||||
// 弹出层标题
|
|
||||||
title: "",
|
|
||||||
// 是否显示弹出层
|
|
||||||
open: false,
|
|
||||||
// 查询参数
|
|
||||||
queryParams: {
|
queryParams: {
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
pageSize: 10,
|
pageSize: 50,
|
||||||
scheduleId: undefined,
|
projectName: undefined,
|
||||||
stepName: undefined,
|
stepName: undefined,
|
||||||
planEnd: undefined,
|
|
||||||
status: undefined,
|
status: undefined,
|
||||||
tabNode: undefined,
|
planEndRange: undefined
|
||||||
firstLevelNode: undefined,
|
}
|
||||||
secondLevelNode: undefined,
|
}
|
||||||
endTime: undefined,
|
|
||||||
nodeHeader: undefined,
|
|
||||||
relatedDocs: undefined,
|
|
||||||
relatedImages: undefined,
|
|
||||||
supplierId: undefined,
|
|
||||||
requirementFile: undefined,
|
|
||||||
specification: undefined,
|
|
||||||
planEndRange: undefined,
|
|
||||||
},
|
|
||||||
// 表单参数
|
|
||||||
form: {},
|
|
||||||
// 表单校验
|
|
||||||
rules: {
|
|
||||||
},
|
|
||||||
userList: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
this.getList();
|
|
||||||
this.getListUser();
|
|
||||||
},
|
},
|
||||||
|
created () { this.getList() },
|
||||||
methods: {
|
methods: {
|
||||||
/** 查询项目进度步骤跟踪列表 */
|
onStatusTab () {
|
||||||
/** 计算剩余天数(当前日期 - 计划完成日期) */
|
if (this.statusTab === 'all') this.queryParams.status = undefined
|
||||||
calcRemainingDays (planEnd) {
|
else if (this.statusTab === 'done') this.queryParams.status = 2
|
||||||
if (!planEnd) return '无计划日期';
|
else if (this.statusTab === 'pending') this.queryParams.status = 1
|
||||||
// 转换计划日期为时间戳(忽略时分秒,按日期当天结束计算)
|
else this.queryParams.status = 0
|
||||||
const planEndTime = new Date(planEnd).setHours(23, 59, 59, 999);
|
this.queryParams.pageNum = 1
|
||||||
const currentTime = new Date().getTime();
|
this.getList()
|
||||||
// 计算天数差值(向上取整)
|
|
||||||
const diffDays = Math.ceil((planEndTime - currentTime) / (1000 * 60 * 60 * 24));
|
|
||||||
return diffDays;
|
|
||||||
},
|
},
|
||||||
/** 查询用户列表 */
|
handleQuery () { this.queryParams.pageNum = 1; this.getList() },
|
||||||
getListUser () {
|
resetQuery () {
|
||||||
this.userLoading = true;
|
this.queryParams = {
|
||||||
listUser({ pageSize: 999 }).then(response => {
|
pageNum: 1, pageSize: 50,
|
||||||
this.userList = response.rows;
|
projectName: undefined, stepName: undefined,
|
||||||
this.userLoading = false;
|
status: this.queryParams.status, planEndRange: undefined
|
||||||
});
|
}
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
buildQuery () {
|
||||||
|
const { planEndRange, ...rest } = this.queryParams
|
||||||
|
const startTime = planEndRange ? planEndRange[0] + ' 00:00:00' : undefined
|
||||||
|
const endTime = planEndRange ? planEndRange[1] + ' 23:59:59' : undefined
|
||||||
|
return { ...rest, startTime, endTime }
|
||||||
},
|
},
|
||||||
getList () {
|
getList () {
|
||||||
this.loading = true;
|
this.loading = true
|
||||||
const endTime = this.queryParams.planEndRange ? this.queryParams.planEndRange[1] + ' 23:59:59' : undefined;
|
listProjectScheduleStep(this.buildQuery()).then(res => {
|
||||||
const startTime = this.queryParams.planEndRange ? this.queryParams.planEndRange[0] + ' 00:00:00' : undefined;
|
this.projectScheduleStepList = res.rows || []
|
||||||
const { planEndRange, ...querys } = {
|
this.total = res.total || 0
|
||||||
...this.queryParams,
|
// 顺便算出当前页里有多少逾期
|
||||||
startTime,
|
this.stat.delayed = (res.rows || []).filter(r =>
|
||||||
endTime,
|
r.status !== 2 && r.planEnd && this.dayDiff(r.planEnd) < 0).length
|
||||||
}
|
}).finally(() => { this.loading = false })
|
||||||
listProjectScheduleStep(querys).then(response => {
|
this.refreshStat()
|
||||||
this.projectScheduleStepList = response.rows;
|
|
||||||
this.total = response.total;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
// 取消按钮
|
refreshStat () {
|
||||||
cancel () {
|
const base = { ...this.buildQuery(), pageNum: 1, pageSize: 1, status: undefined }
|
||||||
this.open = false;
|
Promise.all([
|
||||||
this.reset();
|
listProjectScheduleStep({ ...base, status: 0 }).catch(() => ({ total: 0 })),
|
||||||
|
listProjectScheduleStep({ ...base, status: 1 }).catch(() => ({ total: 0 })),
|
||||||
|
listProjectScheduleStep({ ...base, status: 2 }).catch(() => ({ total: 0 }))
|
||||||
|
]).then(([u, p, d]) => {
|
||||||
|
this.stat.undone = u.total || 0
|
||||||
|
this.stat.pending = p.total || 0
|
||||||
|
this.stat.done = d.total || 0
|
||||||
|
})
|
||||||
},
|
},
|
||||||
// 表单重置
|
dayDiff (date) {
|
||||||
reset () {
|
if (!date) return null
|
||||||
this.form = {
|
const d = new Date(date); d.setHours(0, 0, 0, 0)
|
||||||
trackId: undefined,
|
const t = new Date(); t.setHours(0, 0, 0, 0)
|
||||||
accessory: undefined,
|
return Math.floor((d - t) / 86400000)
|
||||||
scheduleId: undefined,
|
|
||||||
stepOrder: undefined,
|
|
||||||
stepName: undefined,
|
|
||||||
planStart: undefined,
|
|
||||||
planEnd: undefined,
|
|
||||||
actualStart: undefined,
|
|
||||||
actualEnd: undefined,
|
|
||||||
status: undefined,
|
|
||||||
createBy: undefined,
|
|
||||||
createTime: undefined,
|
|
||||||
updateBy: undefined,
|
|
||||||
updateTime: undefined,
|
|
||||||
delFlag: undefined,
|
|
||||||
header: undefined,
|
|
||||||
useFlag: undefined,
|
|
||||||
batchId: undefined,
|
|
||||||
tabNode: undefined,
|
|
||||||
firstLevelNode: undefined,
|
|
||||||
secondLevelNode: undefined,
|
|
||||||
startTime: undefined,
|
|
||||||
originalEndTime: undefined,
|
|
||||||
endTime: undefined,
|
|
||||||
nodeHeader: undefined,
|
|
||||||
relatedDocs: undefined,
|
|
||||||
relatedImages: undefined,
|
|
||||||
supplierId: undefined,
|
|
||||||
requirementFile: undefined,
|
|
||||||
other: undefined,
|
|
||||||
specification: undefined,
|
|
||||||
sortNum: undefined
|
|
||||||
};
|
|
||||||
this.resetForm("form");
|
|
||||||
},
|
},
|
||||||
/** 搜索按钮操作 */
|
statusLabel (s) {
|
||||||
handleQuery () {
|
return s === 2 ? '已完成' : (s === 1 ? '待验收' : '进行中')
|
||||||
this.queryParams.pageNum = 1;
|
|
||||||
this.getList();
|
|
||||||
},
|
},
|
||||||
/** 重置按钮操作 */
|
statusTagType (s) {
|
||||||
resetQuery () {
|
return s === 2 ? 'success' : (s === 1 ? 'warning' : 'info')
|
||||||
this.resetForm("queryForm");
|
|
||||||
this.handleQuery();
|
|
||||||
},
|
},
|
||||||
// 多选框选中数据
|
rowClassName ({ row }) {
|
||||||
handleSelectionChange (selection) {
|
if (row.status === 2) return ''
|
||||||
this.ids = selection.map(item => item.trackId)
|
if (row.planEnd && this.dayDiff(row.planEnd) < 0) return 'row-delayed'
|
||||||
this.single = selection.length !== 1
|
return ''
|
||||||
this.multiple = !selection.length
|
|
||||||
},
|
},
|
||||||
/** 新增按钮操作 */
|
goToProject (row) {
|
||||||
handleAdd () {
|
if (!row.scheduleId) return
|
||||||
this.reset();
|
this.$router.push({
|
||||||
this.open = true;
|
path: '/step/files',
|
||||||
this.title = "添加项目进度步骤跟踪";
|
query: { scheduleId: String(row.scheduleId), trackId: String(row.trackId || ''),
|
||||||
|
tabNode: row.tabNode || '', firstLevelNode: row.firstLevelNode || '' }
|
||||||
|
})
|
||||||
},
|
},
|
||||||
/** 修改按钮操作 */
|
|
||||||
handleUpdate (row) {
|
|
||||||
this.loading = true;
|
|
||||||
this.reset();
|
|
||||||
const trackId = row.trackId || this.ids
|
|
||||||
getProjectScheduleStep(trackId).then(response => {
|
|
||||||
this.loading = false;
|
|
||||||
this.form = response.data;
|
|
||||||
this.open = true;
|
|
||||||
this.title = "修改项目进度步骤跟踪";
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/** 提交按钮 */
|
|
||||||
submitForm () {
|
|
||||||
this.$refs["form"].validate(valid => {
|
|
||||||
if (valid) {
|
|
||||||
this.buttonLoading = true;
|
|
||||||
if (this.form.trackId != null) {
|
|
||||||
updateProjectScheduleStep(this.form).then(response => {
|
|
||||||
this.$modal.msgSuccess("修改成功");
|
|
||||||
this.open = false;
|
|
||||||
this.getList();
|
|
||||||
}).finally(() => {
|
|
||||||
this.buttonLoading = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
addProjectScheduleStep(this.form).then(response => {
|
|
||||||
this.$modal.msgSuccess("新增成功");
|
|
||||||
this.open = false;
|
|
||||||
this.getList();
|
|
||||||
}).finally(() => {
|
|
||||||
this.buttonLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/** 删除按钮操作 */
|
|
||||||
handleDelete (row) {
|
|
||||||
const trackIds = row.trackId || this.ids;
|
|
||||||
this.$modal.confirm('是否确认删除项目进度步骤跟踪编号为"' + trackIds + '"的数据项?').then(() => {
|
|
||||||
this.loading = true;
|
|
||||||
return delProjectScheduleStep(trackIds);
|
|
||||||
}).then(() => {
|
|
||||||
this.loading = false;
|
|
||||||
this.getList();
|
|
||||||
this.$modal.msgSuccess("删除成功");
|
|
||||||
}).catch(() => {
|
|
||||||
}).finally(() => {
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/** 导出按钮操作 */
|
|
||||||
handleExport () {
|
handleExport () {
|
||||||
this.download('oa/projectScheduleStep/export', {
|
this.download('oa/projectScheduleStep/export', this.buildQuery(),
|
||||||
...this.queryParams
|
`my_progress_${Date.now()}.xlsx`)
|
||||||
}, `projectScheduleStep_${new Date().getTime()}.xlsx`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.path { color: #909399; font-size: 11px; }
|
||||||
|
.step-name { color: #303133; font-weight: 600; margin-left: 4px; }
|
||||||
|
::v-deep .el-table .row-delayed > td.el-table__cell { background: #fff1f0 !important; }
|
||||||
|
::v-deep .el-table .row-delayed:hover > td.el-table__cell { background: #ffd8d6 !important; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,342 +1,254 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="100px">
|
<!-- 状态筛选 -->
|
||||||
<el-form-item label="节点负责人" prop="nodeHeader">
|
<el-tabs v-model="statusTab" @tab-click="onStatusTab" class="compact-tabs">
|
||||||
<el-select v-loading="userLoading" v-model="queryParams.nodeHeader" placeholder="请选择节点负责人" clearable filterable>
|
<el-tab-pane name="undone">
|
||||||
|
<span slot="label">未完成 ({{ stat.undone }})</span>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane name="pending">
|
||||||
|
<span slot="label">待验收 ({{ stat.pending }})</span>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane name="done">
|
||||||
|
<span slot="label">已完成 ({{ stat.done }})</span>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="全部" name="all" />
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-form :model="queryParams" ref="queryForm" size="mini" :inline="true" v-show="showSearch"
|
||||||
|
label-width="68px" class="compact-search">
|
||||||
|
<el-form-item label="项目" prop="projectName">
|
||||||
|
<el-input v-model="queryParams.projectName" placeholder="项目名" clearable style="width: 160px"
|
||||||
|
@keyup.enter.native="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="步骤" prop="stepName">
|
||||||
|
<el-input v-model="queryParams.stepName" placeholder="步骤名" clearable style="width: 140px"
|
||||||
|
@keyup.enter.native="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="负责人" prop="nodeHeader">
|
||||||
|
<el-select v-model="queryParams.nodeHeader" placeholder="选择负责人" clearable filterable style="width: 160px">
|
||||||
<el-option v-for="item in userList" :key="item.userId" :label="item.nickName" :value="item.nickName" />
|
<el-option v-for="item in userList" :key="item.userId" :label="item.nickName" :value="item.nickName" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<!-- <el-input v-model="queryParams.nodeHeader" placeholder="请输入节点负责人" clearable @keyup.enter.native="handleQuery" /> -->
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<!-- 状态 -->
|
<el-form-item label="计划完成" prop="planEnd">
|
||||||
<el-form-item label="状态" prop="status">
|
|
||||||
<el-select v-model="queryParams.status" placeholder="请选择状态">
|
|
||||||
<el-option label="进行中" value="0" />
|
|
||||||
<el-option label="待验收" value="1" />
|
|
||||||
<el-option label="已完成" value="2" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<!-- 计划结束时间 -->
|
|
||||||
<el-form-item label="计划结束时间" prop="planEnd">
|
|
||||||
<el-date-picker v-model="queryParams.planEndRange" type="daterange" value-format="yyyy-MM-dd"
|
<el-date-picker v-model="queryParams.planEndRange" type="daterange" value-format="yyyy-MM-dd"
|
||||||
placeholder="选择计划结束时间" />
|
start-placeholder="开始" end-placeholder="结束" style="width: 220px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
||||||
|
|
||||||
<el-row :gutter="10" class="mb8">
|
<el-table v-loading="loading" :data="sortedList" stripe size="small"
|
||||||
<!-- <el-col :span="1.5">
|
:row-class-name="rowClassName" :span-method="spanMethod" :row-key="rowKey"
|
||||||
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
|
@row-click="goToProject">
|
||||||
</el-col>
|
<el-table-column label="所属项目" prop="projectName" min-width="180" show-overflow-tooltip>
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single"
|
|
||||||
@click="handleUpdate">修改</el-button>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple"
|
|
||||||
@click="handleDelete">删除</el-button>
|
|
||||||
</el-col> -->
|
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出</el-button>
|
|
||||||
</el-col>
|
|
||||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="projectScheduleStepList" @selection-change="handleSelectionChange">
|
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
|
||||||
<!-- <el-table-column label="跟踪记录主键" align="center" prop="trackId" v-if="false" /> -->
|
|
||||||
<el-table-column label="所属项目" align="center" prop="projectName" />
|
|
||||||
<el-table-column label="步骤名称" align="center" prop="tabNode">
|
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span>{{ scope.row.tabNode }}/ {{ scope.row.firstLevelNode }} / {{ scope.row.secondLevelNode }}</span>
|
<span style="color:#409eff; cursor:pointer">{{ scope.row.projectName || '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<!-- <el-table-column label="一级节点" align="center" prop="firstLevelNode" />
|
<el-table-column label="步骤" prop="secondLevelNode" min-width="200" show-overflow-tooltip>
|
||||||
<el-table-column label="二级节点" align="center" prop="secondLevelNode" /> -->
|
|
||||||
<el-table-column label="计划完成" align="center" prop="planEnd" width="180">
|
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span>{{ parseTime(scope.row.planEnd, '{y}-{m}-{d}') }}</span>
|
<span class="path">{{ scope.row.tabNode }} · {{ scope.row.firstLevelNode }} ·</span>
|
||||||
|
<span class="step-name">{{ scope.row.secondLevelNode }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剩余时间" align="center" prop="endTime" width="180">
|
<el-table-column label="负责人" prop="nodeHeader" width="100" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span v-if="scope.row.status == 2" style="color: #36d399">{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}
|
<span v-if="scope.row.nodeHeader">{{ scope.row.nodeHeader }}</span>
|
||||||
已完成</span>
|
<span v-else style="color:#c0c4cc;">—</span>
|
||||||
<span v-else-if="scope.row.status == 1" style="color: #4096ff">待验收</span>
|
</template>
|
||||||
<span v-else>
|
</el-table-column>
|
||||||
<!-- 调用计算方法获取剩余天数 -->
|
<el-table-column label="计划完成" prop="planEnd" width="110" align="center">
|
||||||
<template v-if="scope.row.planEnd">
|
<template slot-scope="scope">{{ parseTime(scope.row.planEnd, '{y}-{m}-{d}') }}</template>
|
||||||
<span v-if="calcRemainingDays(scope.row.planEnd) < 0" style="color: #f56c6c">已逾期 {{
|
</el-table-column>
|
||||||
-calcRemainingDays(scope.row.planEnd) }} 天</span>
|
<el-table-column label="剩余" width="110" align="center">
|
||||||
<span v-else-if="calcRemainingDays(scope.row.planEnd) <= 3" style="color: #e6a23c">临期 | 还剩 {{
|
<template slot-scope="scope">
|
||||||
calcRemainingDays(scope.row.planEnd) }} 天</span>
|
<span v-if="scope.row.status === 2" style="color:#67c23a;">
|
||||||
<span v-else style="color: #67c23a">还剩 {{ calcRemainingDays(scope.row.planEnd) }} 天</span>
|
✓ {{ parseTime(scope.row.endTime, '{m}-{d}') }}
|
||||||
</template>
|
|
||||||
<span v-else>未设置计划日期</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="scope.row.status === 1" style="color:#409eff;">待验收</span>
|
||||||
|
<template v-else-if="scope.row.planEnd">
|
||||||
|
<span v-if="dayDiff(scope.row.planEnd) < 0" style="color:#f56c6c;">
|
||||||
|
逾 {{ -dayDiff(scope.row.planEnd) }}d
|
||||||
|
</span>
|
||||||
|
<span v-else-if="dayDiff(scope.row.planEnd) <= 3" style="color:#e6a23c;">
|
||||||
|
剩 {{ dayDiff(scope.row.planEnd) }}d
|
||||||
|
</span>
|
||||||
|
<span v-else style="color:#67c23a;">剩 {{ dayDiff(scope.row.planEnd) }}d</span>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color:#c0c4cc;">未设</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" align="center" prop="status">
|
<el-table-column label="状态" prop="status" width="90" align="center">
|
||||||
<!-- 0进行中,1待验收,2已完成 -->
|
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<span>{{ scope.row.status === 0 ? '进行中' : (scope.row.status === 1 ? '待验收' : '已完成') }}</span>
|
<el-tag size="mini" :type="statusTagType(scope.row.status)">
|
||||||
|
{{ statusLabel(scope.row.status) }}
|
||||||
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="负责人" align="center" prop="nodeHeader" />
|
|
||||||
<!-- <el-table-column label="相关资料" align="center" prop="relatedDocs" /> -->
|
|
||||||
<!-- <el-table-column label="相关图片" align="center" prop="relatedImages" width="100">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<image-preview :src="scope.row.relatedImages" :width="50" :height="50" />
|
|
||||||
</template>
|
|
||||||
</el-table-column> -->
|
|
||||||
<!-- <el-table-column label="供应商ID" align="center" prop="supplierName" /> -->
|
|
||||||
<!-- <el-table-column label="需求文件" align="center" prop="requirementFile" /> -->
|
|
||||||
<!-- <el-table-column label="规范说明" align="center" prop="specification" /> -->
|
|
||||||
<!-- <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
|
|
||||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column> -->
|
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
||||||
@pagination="getList" />
|
@pagination="getList" />
|
||||||
|
|
||||||
<!-- 添加或修改项目进度步骤跟踪对话框 -->
|
|
||||||
<el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
|
|
||||||
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
|
|
||||||
</el-form>
|
|
||||||
<div slot="footer" class="dialog-footer">
|
|
||||||
<el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
|
|
||||||
<el-button @click="cancel">取 消</el-button>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { addProjectScheduleStep, delProjectScheduleStep, getProjectScheduleStep, listPage as listProjectScheduleStep, updateProjectScheduleStep } from "@/api/oa/projectScheduleStep";
|
import { listPage as listProjectScheduleStep } from '@/api/oa/projectScheduleStep'
|
||||||
import { listUser } from "@/api/system/user";
|
import { listUser } from '@/api/system/user'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ProjectScheduleStep",
|
name: 'ProjectScheduleStepOverview',
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
// 按钮loading
|
loading: false,
|
||||||
buttonLoading: false,
|
showSearch: false,
|
||||||
// 用户列表loading
|
|
||||||
userLoading: false,
|
|
||||||
// 遮罩层
|
|
||||||
loading: true,
|
|
||||||
// 选中数组
|
|
||||||
ids: [],
|
|
||||||
// 非单个禁用
|
|
||||||
single: true,
|
|
||||||
// 非多个禁用
|
|
||||||
multiple: true,
|
|
||||||
// 显示搜索条件
|
|
||||||
showSearch: true,
|
|
||||||
// 总条数
|
|
||||||
total: 0,
|
total: 0,
|
||||||
// 项目进度步骤跟踪表格数据
|
statusTab: 'all',
|
||||||
|
stat: { undone: 0, pending: 0, done: 0 },
|
||||||
projectScheduleStepList: [],
|
projectScheduleStepList: [],
|
||||||
// 弹出层标题
|
userList: [],
|
||||||
title: "",
|
|
||||||
// 是否显示弹出层
|
|
||||||
open: false,
|
|
||||||
// 查询参数
|
|
||||||
queryParams: {
|
queryParams: {
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
pageSize: 10,
|
pageSize: 50,
|
||||||
scheduleId: undefined,
|
projectName: undefined,
|
||||||
stepName: undefined,
|
stepName: undefined,
|
||||||
planEnd: undefined,
|
|
||||||
status: undefined,
|
|
||||||
tabNode: undefined,
|
|
||||||
firstLevelNode: undefined,
|
|
||||||
secondLevelNode: undefined,
|
|
||||||
endTime: undefined,
|
|
||||||
nodeHeader: undefined,
|
nodeHeader: undefined,
|
||||||
relatedDocs: undefined,
|
status: undefined,
|
||||||
relatedImages: undefined,
|
planEndRange: undefined
|
||||||
supplierId: undefined,
|
}
|
||||||
requirementFile: undefined,
|
}
|
||||||
specification: undefined,
|
},
|
||||||
planEndRange: undefined,
|
computed: {
|
||||||
},
|
// 按项目分组排序:同一个项目的行紧挨在一起
|
||||||
// 表单参数
|
sortedList () {
|
||||||
form: {},
|
try {
|
||||||
// 表单校验
|
const list = [...(this.projectScheduleStepList || [])]
|
||||||
rules: {
|
list.sort((a, b) => {
|
||||||
},
|
const ka = String(a.scheduleId || a.projectId || '')
|
||||||
userList: [],
|
const kb = String(b.scheduleId || b.projectId || '')
|
||||||
};
|
if (ka !== kb) return ka.localeCompare(kb)
|
||||||
|
return (Number(a.sortNum) || 0) - (Number(b.sortNum) || 0)
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sortedList failed', e)
|
||||||
|
return this.projectScheduleStepList || []
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.getList();
|
this.getList()
|
||||||
this.getListUser();
|
this.fetchUsers()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
/** 查询项目进度步骤跟踪列表 */
|
fetchUsers () {
|
||||||
/** 计算剩余天数(当前日期 - 计划完成日期) */
|
listUser({ pageSize: 999 }).then(res => { this.userList = res.rows || [] })
|
||||||
calcRemainingDays (planEnd) {
|
|
||||||
if (!planEnd) return '无计划日期';
|
|
||||||
// 转换计划日期为时间戳(忽略时分秒,按日期当天结束计算)
|
|
||||||
const planEndTime = new Date(planEnd).setHours(23, 59, 59, 999);
|
|
||||||
const currentTime = new Date().getTime();
|
|
||||||
// 计算天数差值(向上取整)
|
|
||||||
const diffDays = Math.ceil((planEndTime - currentTime) / (1000 * 60 * 60 * 24));
|
|
||||||
return diffDays;
|
|
||||||
},
|
},
|
||||||
/** 查询用户列表 */
|
onStatusTab () {
|
||||||
getListUser () {
|
if (this.statusTab === 'all') this.queryParams.status = undefined
|
||||||
this.userLoading = true;
|
else if (this.statusTab === 'done') this.queryParams.status = 2
|
||||||
listUser({ pageSize: 999 }).then(response => {
|
else if (this.statusTab === 'pending') this.queryParams.status = 1
|
||||||
this.userList = response.rows;
|
else this.queryParams.status = 0
|
||||||
this.userLoading = false;
|
this.queryParams.pageNum = 1
|
||||||
});
|
this.getList()
|
||||||
|
},
|
||||||
|
handleQuery () { this.queryParams.pageNum = 1; this.getList() },
|
||||||
|
resetQuery () {
|
||||||
|
this.queryParams = {
|
||||||
|
pageNum: 1, pageSize: 50,
|
||||||
|
projectName: undefined, stepName: undefined, nodeHeader: undefined,
|
||||||
|
status: this.queryParams.status, planEndRange: undefined
|
||||||
|
}
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
buildQuery () {
|
||||||
|
const { planEndRange, ...rest } = this.queryParams
|
||||||
|
const startTime = planEndRange ? planEndRange[0] + ' 00:00:00' : undefined
|
||||||
|
const endTime = planEndRange ? planEndRange[1] + ' 23:59:59' : undefined
|
||||||
|
return { ...rest, startTime, endTime }
|
||||||
},
|
},
|
||||||
getList () {
|
getList () {
|
||||||
this.loading = true;
|
this.loading = true
|
||||||
const endTime = this.queryParams.planEndRange ? this.queryParams.planEndRange[1] + ' 23:59:59' : undefined;
|
listProjectScheduleStep(this.buildQuery()).then(res => {
|
||||||
const startTime = this.queryParams.planEndRange ? this.queryParams.planEndRange[0] + ' 00:00:00' : undefined;
|
this.projectScheduleStepList = res.rows || []
|
||||||
const { planEndRange, ...querys } = {
|
this.total = res.total || 0
|
||||||
...this.queryParams,
|
}).finally(() => { this.loading = false })
|
||||||
startTime,
|
this.refreshStat()
|
||||||
endTime,
|
|
||||||
}
|
|
||||||
listProjectScheduleStep(querys).then(response => {
|
|
||||||
this.projectScheduleStepList = response.rows;
|
|
||||||
this.total = response.total;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
// 取消按钮
|
refreshStat () {
|
||||||
cancel () {
|
// 异步刷新计数;失败/不支持都不阻塞主列表
|
||||||
this.open = false;
|
const base = { ...this.buildQuery(), pageNum: 1, pageSize: 1, status: undefined }
|
||||||
this.reset();
|
Promise.all([
|
||||||
|
listProjectScheduleStep({ ...base, status: 0 }).catch(() => ({ total: 0 })),
|
||||||
|
listProjectScheduleStep({ ...base, status: 1 }).catch(() => ({ total: 0 })),
|
||||||
|
listProjectScheduleStep({ ...base, status: 2 }).catch(() => ({ total: 0 }))
|
||||||
|
]).then(([u, p, d]) => {
|
||||||
|
this.stat.undone = u.total || 0
|
||||||
|
this.stat.pending = p.total || 0
|
||||||
|
this.stat.done = d.total || 0
|
||||||
|
})
|
||||||
},
|
},
|
||||||
// 表单重置
|
dayDiff (date) {
|
||||||
reset () {
|
if (!date) return null
|
||||||
this.form = {
|
const d = new Date(date); d.setHours(0, 0, 0, 0)
|
||||||
trackId: undefined,
|
const t = new Date(); t.setHours(0, 0, 0, 0)
|
||||||
accessory: undefined,
|
return Math.floor((d - t) / 86400000)
|
||||||
scheduleId: undefined,
|
|
||||||
stepOrder: undefined,
|
|
||||||
stepName: undefined,
|
|
||||||
planStart: undefined,
|
|
||||||
planEnd: undefined,
|
|
||||||
actualStart: undefined,
|
|
||||||
actualEnd: undefined,
|
|
||||||
status: undefined,
|
|
||||||
createBy: undefined,
|
|
||||||
createTime: undefined,
|
|
||||||
updateBy: undefined,
|
|
||||||
updateTime: undefined,
|
|
||||||
delFlag: undefined,
|
|
||||||
header: undefined,
|
|
||||||
useFlag: undefined,
|
|
||||||
batchId: undefined,
|
|
||||||
tabNode: undefined,
|
|
||||||
firstLevelNode: undefined,
|
|
||||||
secondLevelNode: undefined,
|
|
||||||
startTime: undefined,
|
|
||||||
originalEndTime: undefined,
|
|
||||||
endTime: undefined,
|
|
||||||
nodeHeader: undefined,
|
|
||||||
relatedDocs: undefined,
|
|
||||||
relatedImages: undefined,
|
|
||||||
supplierId: undefined,
|
|
||||||
requirementFile: undefined,
|
|
||||||
other: undefined,
|
|
||||||
specification: undefined,
|
|
||||||
sortNum: undefined
|
|
||||||
};
|
|
||||||
this.resetForm("form");
|
|
||||||
},
|
},
|
||||||
/** 搜索按钮操作 */
|
statusLabel (s) {
|
||||||
handleQuery () {
|
return s === 2 ? '已完成' : (s === 1 ? '待验收' : '进行中')
|
||||||
this.queryParams.pageNum = 1;
|
|
||||||
this.getList();
|
|
||||||
},
|
},
|
||||||
/** 重置按钮操作 */
|
statusTagType (s) {
|
||||||
resetQuery () {
|
return s === 2 ? 'success' : (s === 1 ? 'warning' : 'info')
|
||||||
this.resetForm("queryForm");
|
|
||||||
this.handleQuery();
|
|
||||||
},
|
},
|
||||||
// 多选框选中数据
|
rowClassName ({ row }) {
|
||||||
handleSelectionChange (selection) {
|
if (row.status === 2) return ''
|
||||||
this.ids = selection.map(item => item.trackId)
|
if (row.planEnd && this.dayDiff(row.planEnd) < 0) return 'row-delayed'
|
||||||
this.single = selection.length !== 1
|
return ''
|
||||||
this.multiple = !selection.length
|
|
||||||
},
|
},
|
||||||
/** 新增按钮操作 */
|
rowKey (row) { return row.trackId || (row.scheduleId + '_' + (row.sortNum || 0)) },
|
||||||
handleAdd () {
|
// 项目列合并相邻同项目的行
|
||||||
this.reset();
|
spanMethod ({ row, column, rowIndex }) {
|
||||||
this.open = true;
|
try {
|
||||||
this.title = "添加项目进度步骤跟踪";
|
if (!column || column.property !== 'projectName') return { rowspan: 1, colspan: 1 }
|
||||||
},
|
const list = this.sortedList
|
||||||
/** 修改按钮操作 */
|
if (!list || !list.length) return { rowspan: 1, colspan: 1 }
|
||||||
handleUpdate (row) {
|
const key = r => String(r.scheduleId || r.projectId || '')
|
||||||
this.loading = true;
|
if (rowIndex > 0 && key(list[rowIndex - 1]) === key(row)) {
|
||||||
this.reset();
|
return { rowspan: 0, colspan: 0 }
|
||||||
const trackId = row.trackId || this.ids
|
|
||||||
getProjectScheduleStep(trackId).then(response => {
|
|
||||||
this.loading = false;
|
|
||||||
this.form = response.data;
|
|
||||||
this.open = true;
|
|
||||||
this.title = "修改项目进度步骤跟踪";
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/** 提交按钮 */
|
|
||||||
submitForm () {
|
|
||||||
this.$refs["form"].validate(valid => {
|
|
||||||
if (valid) {
|
|
||||||
this.buttonLoading = true;
|
|
||||||
if (this.form.trackId != null) {
|
|
||||||
updateProjectScheduleStep(this.form).then(response => {
|
|
||||||
this.$modal.msgSuccess("修改成功");
|
|
||||||
this.open = false;
|
|
||||||
this.getList();
|
|
||||||
}).finally(() => {
|
|
||||||
this.buttonLoading = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
addProjectScheduleStep(this.form).then(response => {
|
|
||||||
this.$modal.msgSuccess("新增成功");
|
|
||||||
this.open = false;
|
|
||||||
this.getList();
|
|
||||||
}).finally(() => {
|
|
||||||
this.buttonLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
let span = 1
|
||||||
|
for (let i = rowIndex + 1; i < list.length; i++) {
|
||||||
|
if (key(list[i]) === key(row)) span++
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
return { rowspan: span, colspan: 1 }
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('spanMethod failed', e)
|
||||||
|
return { rowspan: 1, colspan: 1 }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
/** 删除按钮操作 */
|
goToProject (row) {
|
||||||
handleDelete (row) {
|
if (!row.scheduleId) return
|
||||||
const trackIds = row.trackId || this.ids;
|
this.$router.push({
|
||||||
this.$modal.confirm('是否确认删除项目进度步骤跟踪编号为"' + trackIds + '"的数据项?').then(() => {
|
path: '/step/files',
|
||||||
this.loading = true;
|
query: { scheduleId: String(row.scheduleId), trackId: String(row.trackId || ''),
|
||||||
return delProjectScheduleStep(trackIds);
|
tabNode: row.tabNode || '', firstLevelNode: row.firstLevelNode || '' }
|
||||||
}).then(() => {
|
})
|
||||||
this.loading = false;
|
|
||||||
this.getList();
|
|
||||||
this.$modal.msgSuccess("删除成功");
|
|
||||||
}).catch(() => {
|
|
||||||
}).finally(() => {
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
/** 导出按钮操作 */
|
|
||||||
handleExport () {
|
handleExport () {
|
||||||
this.download('oa/projectScheduleStep/export', {
|
this.download('oa/projectScheduleStep/export', this.buildQuery(),
|
||||||
...this.queryParams
|
`progress_overview_${Date.now()}.xlsx`)
|
||||||
}, `projectScheduleStep_${new Date().getTime()}.xlsx`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.path { color: #909399; font-size: 11px; }
|
||||||
|
.step-name { color: #303133; font-weight: 600; margin-left: 4px; }
|
||||||
|
::v-deep .el-table .row-delayed > td.el-table__cell { background: #fff1f0 !important; }
|
||||||
|
::v-deep .el-table .row-delayed:hover > td.el-table__cell { background: #ffd8d6 !important; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -141,6 +141,9 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
|
<el-button size="mini" type="text" icon="el-icon-truck" style="color:#409eff"
|
||||||
|
v-if="scope.row.status !== 2 && scope.row.status !== 3"
|
||||||
|
@click="handleGoToInbound(scope.row)">执行入库</el-button>
|
||||||
<el-button size="mini" type="text" icon="el-icon-check" @click="handleComplete(scope.row)"
|
<el-button size="mini" type="text" icon="el-icon-check" @click="handleComplete(scope.row)"
|
||||||
v-if="scope.row.status === 1">完成</el-button>
|
v-if="scope.row.status === 1">完成</el-button>
|
||||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
|
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
|
||||||
@@ -302,6 +305,13 @@ export default {
|
|||||||
this.getUsers();
|
this.getUsers();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 跳到入库明细页面,并预填该采购需求
|
||||||
|
handleGoToInbound (row) {
|
||||||
|
this.$router.push({
|
||||||
|
path: '/step/in',
|
||||||
|
query: { requirementId: String(row.requirementId), requirementTitle: row.title }
|
||||||
|
})
|
||||||
|
},
|
||||||
// 后端已联查 sys_oss 拼好字符串 "ossId|name|url,,ossId|name|url"
|
// 后端已联查 sys_oss 拼好字符串 "ossId|name|url,,ossId|name|url"
|
||||||
parseAccessoryFiles (raw) {
|
parseAccessoryFiles (raw) {
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
|
|||||||
177
ruoyi-ui/src/views/system/feedback/index.vue
Normal file
177
ruoyi-ui/src/views/system/feedback/index.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-tabs v-model="statusTab" @tab-click="onStatusTab" class="compact-tabs">
|
||||||
|
<el-tab-pane name="pending">
|
||||||
|
<span slot="label" :style="stat.pending > 0 ? 'color:#e6a23c;' : ''">
|
||||||
|
待处理 ({{ stat.pending }})
|
||||||
|
</span>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane :label="`已受理 (${stat.accepted})`" name="accepted" />
|
||||||
|
<el-tab-pane :label="`已完成 (${stat.finished})`" name="finished" />
|
||||||
|
<el-tab-pane label="已关闭" name="closed" />
|
||||||
|
<el-tab-pane label="全部" name="all" />
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-form :model="queryParams" size="mini" :inline="true" v-show="showSearch"
|
||||||
|
label-width="60px" class="compact-search">
|
||||||
|
<el-form-item label="标题" prop="title">
|
||||||
|
<el-input v-model="queryParams.title" placeholder="模糊匹配" clearable style="width: 180px"
|
||||||
|
@keyup.enter.native="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" prop="category">
|
||||||
|
<el-select v-model="queryParams.category" clearable style="width: 110px">
|
||||||
|
<el-option label="Bug" value="bug" />
|
||||||
|
<el-option label="新功能" value="feature" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||||
|
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="list" stripe size="small">
|
||||||
|
<el-table-column type="expand" width="36">
|
||||||
|
<template slot-scope="{ row }">
|
||||||
|
<div style="padding:8px 24px; background:#fafafa;">
|
||||||
|
<p style="white-space: pre-wrap; margin:0 0 8px; color:#303133;">{{ row.content }}</p>
|
||||||
|
<div style="font-size:11px; color:#909399;">
|
||||||
|
<span v-if="row.pagePath">页面:<code>{{ row.pagePath }}</code> </span>
|
||||||
|
<span v-if="row.acceptRemark">处理备注:{{ row.acceptRemark }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="提交人" prop="submitterName" width="90" />
|
||||||
|
<el-table-column label="类型" width="80" align="center">
|
||||||
|
<template slot-scope="{ row }">
|
||||||
|
<el-tag size="mini" :type="catTag(row.category)">{{ catLabel(row.category) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="优先级" width="80" align="center">
|
||||||
|
<template slot-scope="{ row }">
|
||||||
|
<el-tag size="mini" :type="priTag(row.priority)">{{ priLabel(row.priority) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="标题" prop="title" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template slot-scope="{ row }">
|
||||||
|
<el-tag size="mini" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="提交时间" prop="createTime" width="160">
|
||||||
|
<template slot-scope="{ row }">{{ parseTime(row.createTime, '{y}-{m}-{d} {h}:{i}') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="处理人" prop="handlerName" width="90" />
|
||||||
|
<el-table-column label="操作" align="center" width="200" class-name="small-padding fixed-width" v-if="isIt">
|
||||||
|
<template slot-scope="{ row }">
|
||||||
|
<el-button v-if="row.status === 0" type="text" size="mini" @click="onAccept(row)">受理</el-button>
|
||||||
|
<el-button v-if="row.status === 1" type="text" size="mini" style="color:#67c23a"
|
||||||
|
@click="onFinish(row)">完成</el-button>
|
||||||
|
<el-button v-if="row.status !== 3 && row.status !== 2" type="text" size="mini"
|
||||||
|
style="color:#f56c6c" @click="onClose(row)">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination v-show="total > 0" :total="total"
|
||||||
|
:page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
||||||
|
@pagination="getList" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { acceptSuggestion as acceptFeedback, closeSuggestion as closeFeedback,
|
||||||
|
finishSuggestion as finishFeedback, isItMember, listSuggestion as listFeedback } from '@/api/oa/suggestion'
|
||||||
|
|
||||||
|
const CAT = { bug: ['Bug', 'danger'], feature: ['新功能', 'success'], other: ['其他', 'info'] }
|
||||||
|
const PRI = { 1: ['高', 'danger'], 2: ['中', 'warning'], 3: ['低', 'info'] }
|
||||||
|
const STATUS = { 0: ['待处理', 'warning'], 1: ['已受理', 'primary'], 2: ['已完成', 'success'], 3: ['已关闭', 'info'] }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FeedbackManagement',
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
showSearch: true,
|
||||||
|
isIt: false,
|
||||||
|
total: 0,
|
||||||
|
statusTab: 'pending',
|
||||||
|
stat: { pending: 0, accepted: 0, finished: 0 },
|
||||||
|
list: [],
|
||||||
|
queryParams: { pageNum: 1, pageSize: 30, status: 0, title: undefined, category: undefined }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
isItMember().then(res => { this.isIt = !!(res && res.data) })
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
catLabel (c) { return (CAT[c] || [c])[0] },
|
||||||
|
catTag (c) { return (CAT[c] || [])[1] || 'info' },
|
||||||
|
priLabel (p) { return (PRI[p] || [p])[0] },
|
||||||
|
priTag (p) { return (PRI[p] || [])[1] || 'info' },
|
||||||
|
statusLabel (s) { return (STATUS[s] || [s])[0] },
|
||||||
|
statusTag (s) { return (STATUS[s] || [])[1] || 'info' },
|
||||||
|
onStatusTab () {
|
||||||
|
const map = { pending: 0, accepted: 1, finished: 2, closed: 3, all: undefined }
|
||||||
|
this.queryParams.status = map[this.statusTab]
|
||||||
|
this.queryParams.pageNum = 1
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
handleQuery () { this.queryParams.pageNum = 1; this.getList() },
|
||||||
|
resetQuery () {
|
||||||
|
this.queryParams = { pageNum: 1, pageSize: 30, status: this.queryParams.status }
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
getList () {
|
||||||
|
this.loading = true
|
||||||
|
listFeedback(this.queryParams).then(res => {
|
||||||
|
this.list = res.rows || []
|
||||||
|
this.total = res.total || 0
|
||||||
|
}).finally(() => { this.loading = false })
|
||||||
|
this.refreshStat()
|
||||||
|
},
|
||||||
|
refreshStat () {
|
||||||
|
const base = { pageNum: 1, pageSize: 1 }
|
||||||
|
Promise.all([
|
||||||
|
listFeedback({ ...base, status: 0 }).catch(() => ({ total: 0 })),
|
||||||
|
listFeedback({ ...base, status: 1 }).catch(() => ({ total: 0 })),
|
||||||
|
listFeedback({ ...base, status: 2 }).catch(() => ({ total: 0 }))
|
||||||
|
]).then(([p, a, f]) => {
|
||||||
|
this.stat.pending = p.total || 0
|
||||||
|
this.stat.accepted = a.total || 0
|
||||||
|
this.stat.finished = f.total || 0
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onAccept (row) {
|
||||||
|
this.$prompt('受理备注(可空)', '受理', { confirmButtonText: '确定受理',
|
||||||
|
cancelButtonText: '取消', inputType: 'textarea' }).then(({ value }) => {
|
||||||
|
acceptFeedback(row.feedbackId, value || '').then(() => {
|
||||||
|
this.$modal.msgSuccess('已受理,已通知提出者')
|
||||||
|
this.getList()
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
},
|
||||||
|
onFinish (row) {
|
||||||
|
this.$prompt('完成说明(可空)', '完成', { confirmButtonText: '标记完成',
|
||||||
|
cancelButtonText: '取消', inputType: 'textarea' }).then(({ value }) => {
|
||||||
|
finishFeedback(row.feedbackId, value || '').then(() => {
|
||||||
|
this.$modal.msgSuccess('已完成,已通知提出者')
|
||||||
|
this.getList()
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
},
|
||||||
|
onClose (row) {
|
||||||
|
this.$confirm('关闭后将不再处理,确认?', '关闭确认', { type: 'warning' }).then(() => {
|
||||||
|
closeFeedback(row.feedbackId, '').then(() => {
|
||||||
|
this.$modal.msgSuccess('已关闭')
|
||||||
|
this.getList()
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -79,6 +79,19 @@ module.exports = {
|
|||||||
config.plugins.delete('preload') // TODO: need test
|
config.plugins.delete('preload') // TODO: need test
|
||||||
config.plugins.delete('prefetch') // TODO: need test
|
config.plugins.delete('prefetch') // TODO: need test
|
||||||
|
|
||||||
|
// 修补 OpenIM SDK 里 webpack4 不识别的 import.meta.url 语法
|
||||||
|
// 把 import.meta.url 替换成 self.location.href(worker 模块加载时同样有效)
|
||||||
|
config.module
|
||||||
|
.rule('openim-patch')
|
||||||
|
.test(/@openim[\\/]wasm-client-sdk[\\/]lib[\\/].+\.js$/)
|
||||||
|
.use('string-replace-loader')
|
||||||
|
.loader('string-replace-loader')
|
||||||
|
.options({
|
||||||
|
multiple: [
|
||||||
|
{ search: 'import\\.meta\\.url', replace: 'self.location.href', flags: 'g' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
// set svg-sprite-loader
|
// set svg-sprite-loader
|
||||||
config.module
|
config.module
|
||||||
.rule('svg')
|
.rule('svg')
|
||||||
|
|||||||
28
sql/sys_oa_feedback.sql
Normal file
28
sql/sys_oa_feedback.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- 用户反馈/修改意见
|
||||||
|
DROP TABLE IF EXISTS `sys_oa_feedback`;
|
||||||
|
CREATE TABLE `sys_oa_feedback` (
|
||||||
|
`feedback_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '反馈ID',
|
||||||
|
`title` varchar(120) NOT NULL COMMENT '标题',
|
||||||
|
`content` text NOT NULL COMMENT '详细内容',
|
||||||
|
`category` varchar(30) DEFAULT 'feature' COMMENT '类型 bug/feature/other',
|
||||||
|
`priority` tinyint(1) DEFAULT 2 COMMENT '优先级 1高 2中 3低',
|
||||||
|
`page_path` varchar(200) DEFAULT NULL COMMENT '问题出现的页面路径',
|
||||||
|
`attachment` varchar(500) DEFAULT NULL COMMENT '附件 OSS ID(逗号分隔)',
|
||||||
|
`submitter_id` bigint(20) NOT NULL COMMENT '提出者ID',
|
||||||
|
`submitter_name` varchar(64) DEFAULT NULL COMMENT '提出者昵称(冗余)',
|
||||||
|
`status` tinyint(1) DEFAULT 0 COMMENT '状态 0待处理 1已受理 2已完成 3已关闭',
|
||||||
|
`handler_id` bigint(20) DEFAULT NULL COMMENT '处理人ID',
|
||||||
|
`handler_name` varchar(64) DEFAULT NULL COMMENT '处理人昵称',
|
||||||
|
`accept_remark` varchar(500) DEFAULT NULL COMMENT '处理备注',
|
||||||
|
`accept_time` datetime DEFAULT NULL COMMENT '受理时间',
|
||||||
|
`finish_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||||
|
`create_by` varchar(64) DEFAULT NULL,
|
||||||
|
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_by` varchar(64) DEFAULT NULL,
|
||||||
|
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`del_flag` tinyint(1) DEFAULT 0,
|
||||||
|
PRIMARY KEY (`feedback_id`),
|
||||||
|
KEY `idx_submitter` (`submitter_id`),
|
||||||
|
KEY `idx_handler` (`handler_id`),
|
||||||
|
KEY `idx_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户反馈/修改意见';
|
||||||
4
sql/warehouse_master_receipt_doc.sql
Normal file
4
sql/warehouse_master_receipt_doc.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- 出入库单据加"收货单"字段:CSV 形式的 sys_oss 主键
|
||||||
|
ALTER TABLE `sys_oa_warehouse_master`
|
||||||
|
ADD COLUMN `receipt_doc` varchar(500) DEFAULT NULL COMMENT '收货单/相关附件 OSS ID(逗号分隔,sys_oss.oss_id)'
|
||||||
|
AFTER `remark`;
|
||||||
Reference in New Issue
Block a user