职工福利模块:

1、福利项目管理:添加福利分组选择控制
2、pc端、移动端 选择福利、及选择情况(代选按钮):添加配送时间、备注字段。
3、选择情况:消息发送
4、评价列表及导出
This commit is contained in:
2026-08-28 18:43:49 +08:00
parent d6b66b1269
commit 35b233a0d8
31 changed files with 2893 additions and 119 deletions
+125
View File
@@ -37,4 +37,129 @@
- None required for the requested scope.
## 2026-08-28 工具栏样式修正复核
- Source visual truth: `C:\Users\mayn\AppData\Local\Temp\codex-clipboard-73344a5b-ced8-4cca-952c-84eb3ad96fdd.png`
- Pre-fix implementation screenshot: `C:\Users\mayn\AppData\Local\Temp\codex-clipboard-97abadfa-922a-4b9e-90e6-bd7d0d40e611.png`
- Revised implementation screenshot: unavailable because the local Java web service is not currently listening on a browser-accessible port.
- Viewport/state: desktop project edit page, group-required switch enabled.
- Full-view evidence: the pre-fix screenshot shows the controls stacked at the table's left edge, while the source places them horizontally at the upper-right edge.
- Focused region evidence: source order is `分组必选 → 开关 → 配置分组选择数量`; the pre-fix implementation wraps the button onto a second row.
### Findings and fixes
- [Fixed in source, pending rendered confirmation] P1 toolbar placement: moved the controls into an explicitly loaded flex toolbar with right alignment.
- [Fixed in source, pending rendered confirmation] P2 button styling: added the reference-like 40 px height, light-blue background, blue border, icon/text color and hover state.
- [Fixed in source, pending rendered confirmation] P2 table rhythm: removed the 20 px gap so the toolbar connects directly to the table.
- Typography: retains the project's existing Element UI typography and 14 px action text.
- Colors: uses the project's Element UI primary-blue family.
- Images/assets: no raster assets are involved; the existing Element UI setting icon is retained.
- Copy/content: all requested Chinese labels remain unchanged.
### Comparison history
1. User-provided pre-fix capture identified the invalid `/deep/` component selectors and vertically wrapped controls.
2. Replaced those selectors and added the effective page-level stylesheet used by this server-rendered Vue page.
3. JavaScript syntax and Git whitespace checks passed; post-fix browser capture remains unavailable until the local application is started or refreshed.
## 2026-08-28 卡片背景与圆角按钮复核
- Source visual truth: `C:\Users\mayn\AppData\Local\Temp\codex-clipboard-095c2fbf-eaaf-46a4-953b-9d1070638bdf.png`
- Revised implementation screenshot: unavailable because the local Java web service is not listening on a browser-accessible port.
- Viewport/state: desktop welfare-project edit page with the welfare-options section visible.
- Full-view evidence: the reference groups the toolbar, table and add action within one white elevated card.
- Focused region evidence: the reference uses a subtle card radius/shadow and a compact blue add button with rounded corners.
### Findings and fixes
- [Fixed in source, pending rendered confirmation] P2 card hierarchy: added a 1 px neutral border, 6 px radius and low-contrast shadow to the welfare-option card.
- [Fixed in source, pending rendered confirmation] P2 spacing rhythm: added 14 px horizontal/bottom card padding and a 10 px gap above the footer action.
- [Fixed in source, pending rendered confirmation] P2 button shape: added a dedicated 6 px radius to the add-welfare-option button while retaining Element UI primary states.
- Typography: unchanged and inherited from the existing Element UI design system.
- Colors: white card, `#ebeef5` border and existing primary-blue button match the source hierarchy.
- Images/assets: this style correction does not require new raster assets.
- Copy/content: `添加福利选项` and all existing labels remain unchanged.
### Comparison history
1. The supplied reference identified the missing soft card treatment and rounded add button.
2. Added semantic card/footer/button classes and matching page-loaded CSS.
3. JavaScript syntax and Git whitespace checks passed; post-fix browser capture remains unavailable until the local application is started or refreshed.
final result: blocked
# 2026-08-28 福利评价设计验收
- Source visual truth: `C:\Users\mayn\AppData\Local\Temp\codex-clipboard-c7cafe9d-913d-4ba3-acb4-f1190c14cd78.png``C:\Users\mayn\AppData\Local\Temp\codex-clipboard-bbaf6b8d-5f27-40bd-b2d4-fdcb0d5b9f22.png``C:\Users\mayn\AppData\Local\Temp\codex-clipboard-1f0c70c6-cd0f-4369-a82e-12345e61447b.png`
- Implementation screenshots: `F:\coding\zhgh_zjvtit_v4\target\codex-preview\welfare-evaluation-desktop-v2.png``F:\coding\zhgh_zjvtit_v4\target\codex-preview\welfare-evaluation-mobile-v5.png`
- Comparison images: `F:\coding\zhgh_zjvtit_v4\target\codex-preview\compare-evaluation-desktop-v2.png``F:\coding\zhgh_zjvtit_v4\target\codex-preview\compare-evaluation-mobile-v5.png`
- Viewports: desktop 1400 × 900 CSS pxmobile 420 × 938 CSS pxdevice scale factor 1.
- Density normalization: the 840 × 1875 mobile reference was normalized to 420 × 938 before side-by-side comparison.
- State: evaluation dialog/sheet open; three selected benefits; every score initially zero; remark empty.
## Findings
- No remaining P0/P1/P2 visual mismatch in the requested evaluation dialog and bottom sheet.
- Desktop: title, information prompt, two-column benefit table, five-star control, zero-score prompt, 200-character remark and footer actions match the reference hierarchy.
- Mobile: the existing hero/card design is retained; the evaluation action, bottom rounded sheet, drag handle, close action, benefit rows and bottom actions align with the supplied mobile reference.
- Typography and colors inherit the project's Element UI/Vant design systems; no unrelated visual tokens or custom raster assets were introduced.
- Copy includes the selected quantity for every benefit and consistently uses `评价(5星制)``备注``取消` and `提交评价`.
## Interaction Evidence
- The third star in the first mobile row was clicked and exposed `aria-checked=true`, confirming one-star-per-point selection behavior.
- The default state renders all items with zero stars and an empty remark.
- The PC and mobile production scripts passed JavaScript syntax checks during implementation; the Java backend passed an offline Maven compile.
- The local visual harness recorded no blocking render error. Database-backed API submission still requires the supplied SQL to be executed in the target environment before end-to-end testing.
## Comparison History
1. The first mobile pass produced an evaluation sheet that was visibly taller than the reference.
2. Reduced header margins, table row height and footer height, then restored the reference drag handle.
3. The final sheet begins at the same vertical position as the normalized reference and preserves all actions without page-level scrolling.
4. The PC zero-score state was corrected to explicitly display `请点击评分`, matching the reference.
## Follow-up Polish
- None required for the requested visual scope.
final result: passed
# 2026-08-28 移动端“去评价”按钮设计验收
- Source visual truth: `C:\Users\mayn\AppData\Local\Temp\codex-clipboard-fd28cb5c-c88c-4c99-8789-4b5083bc812c.png`
- Implementation screenshot: `F:\coding\zhgh_zjvtit_v4\target\codex-preview\welfare-evaluate-button-final.png`
- Full-view comparison: `F:\coding\zhgh_zjvtit_v4\target\codex-preview\compare-welfare-evaluate-button-final.png`
- Focused comparison: `F:\coding\zhgh_zjvtit_v4\target\codex-preview\compare-welfare-evaluate-button-focus-final.png`
- Viewport: 431 × 911 CSS pxdevice scale factor 1.
- Source pixels: 862 × 1822implementation pixels: 431 × 911source was normalized to 431 × 911 before comparison.
- State: “我的福利”卡片已显示,评价弹层关闭,评价入口处于默认可点击状态。
## Findings
- No remaining P0/P1/P2 mismatch in the requested evaluation-button region.
- Fonts and typography: `去评价` uses the existing Vant/project font stack at 14 px and matches the reference's compact action hierarchy.
- Spacing and layout rhythm: the implementation uses a 34 px button height, 17 px pill radius, 13 px horizontal padding and 6 px icon gap; the resulting width matches the normalized reference closely.
- Colors and visual tokens: border, icon and copy reuse the existing `#1989fa` primary blue on a white background.
- Image quality and assets: the icon uses Vant's real `star` font icon; no SVG, CSS drawing, emoji or raster placeholder was introduced.
- Copy and content: the label is `去评价`, matching the supplied reference.
## Interaction Evidence
- Clicking `去评价` opened the welfare-evaluation bottom sheet.
- The button remained visible and accessible as a semantic button.
- Browser console errors: 0.
- JavaScript syntax and Git whitespace checks passed.
## Comparison History
1. Initial implementation used `comment-o``评价` and a 6 px rounded rectangle, which differed from the supplied target.
2. Replaced the icon with the closest matching existing Vant `star` icon, changed the copy to `去评价`, and applied the pill treatment.
3. Focused comparison found the first 97 px implementation width slightly wider than the approximately 93 px normalized target; horizontal padding and icon size were reduced.
4. Final implementation matches the target size, border, color, copy and overall visual weight without an actionable P0/P1/P2 issue.
## Follow-up Polish
- P3: the reference star contains a small inner highlight while the closest existing Vant `star` glyph is solid; retaining the project icon library avoids introducing a custom asset for this minor difference.
final result: passed
@@ -0,0 +1,90 @@
package com.budwk.app.zhgh.welfare.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationDetail;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationManagePageForm;
import com.budwk.app.zhgh.welfare.service.WelfareUserEvaluationService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.util.Collections;
/**
* PC端福利评分管理。Controller只接收参数、执行基础校验并调用评价服务。
*/
@IocBean
@Ok("json:full")
@At("/platform/welfare/evaluation/manage")
@Api(tags = "福利评分管理")
public class WelfareEvaluationManageController {
@Inject
private WelfareUserEvaluationService welfareUserEvaluationService;
@At("")
@Ok("beetl:/platform/zhgh/welfare/evaluationManage/index.html")
@SaCheckPermission("welfare.evaluation.manage")
public void index() {
}
/**
* 分页查询项目福利名单的评分汇总。
*
* @param pageForm 年度、福利项目ID及分页排序参数
* @return JSON分页结果;每个人员只返回一行,综合评价为逐项福利评分平均值
*/
@At
@SaCheckPermission("welfare.evaluation.manage")
@ApiOperation("分页查询福利评分")
public Result pageData(@Valid @Param("pageForm") WelfareEvaluationManagePageForm pageForm) {
if (StrUtil.isBlank(pageForm.getProjectId())) {
return Result.success(new Pagination<>(1, pageForm.getPageSize(), 0, Collections.emptyList()));
}
return Result.success(welfareUserEvaluationService.managePageData(pageForm));
}
/**
* 查询指定人员在项目中的逐项福利评分。
*
* @param projectId 福利项目ID
* @param userId 福利名单人员ID
* @return JSON详情,items为纵向福利评分,remark为统一评价备注
*/
@At
@SaCheckPermission("welfare.evaluation.manage")
@ApiOperation("查看人员福利评价")
public Result detail(String projectId, String userId) {
if (StrUtil.isBlank(projectId) || StrUtil.isBlank(userId)) {
return Result.error("评价查询参数不完整");
}
WelfareEvaluationDetail detail = welfareUserEvaluationService.getManageEvaluationDetail(projectId, userId);
return Result.success(detail);
}
/**
* 导出项目评分明细。人员按项目和人员ID去重,项目全部福利选项动态生成评分列。
*
* @param projectId 福利项目ID
* @param response Excel文件响应
*/
@At
@Ok("void")
@SaCheckPermission("welfare.evaluation.manage")
@ApiOperation("导出福利评分")
public void exportXlsx(String projectId, HttpServletResponse response) {
if (StrUtil.isBlank(projectId)) {
return;
}
welfareUserEvaluationService.exportManageXlsx(projectId, response);
}
}
@@ -58,9 +58,10 @@ public class WelfareMineController {
WHEN wus.selectUserId IS NOT NULL THEN
1 ELSE 0
END AS isChoose,
GROUP_CONCAT(DISTINCT wuso.optionName ,'',wus.selectNum,'份)') AS gist_list,
MAX(wus.selectTime) AS selectTime,
wus.receiveAddress,
GROUP_CONCAT(DISTINCT wuso.optionName ,'',wus.selectNum,'份)') AS gist_list,
MAX(wus.selectTime) AS selectTime,
MAX(wus.deliveryDate) AS deliveryDate,
wus.receiveAddress,
wl.userId
FROM
welfare_list wl
@@ -97,6 +98,8 @@ public class WelfareMineController {
wpus.receiveAddress,
wpso.optionName,
wpus.selectNum,
wpus.deliveryDate,
wpus.remark,
wpus.userSign,
wpus.courierNumber,
wpus.selectTime,
@@ -69,6 +69,30 @@ public class WelfareSelectionSituationController {
return Result.success(pagination);
}
/**
* 按选择情况页面查询条件发送钉钉消息。
*
* @param pageForm 页面当前查询条件,后台会据此重新查询全部符合权限范围的人员
* @param messageContent 前端已去除富文本样式的纯文本消息内容
* @return JSON结果;成功消息中包含实际发送人数,失败时返回具体原因
*/
@At
@Aop(TransAop.READ_COMMITTED)
@SLog(type = "welfare", tag = "选择情况", msg = "发送钉钉消息")
@SaCheckPermission("welfare.selection.situation")
@ApiOperation("根据查询条件发送钉钉消息")
public Result sendMessage(@Valid @Param("pageForm") WelfareSelectionSituationPageForm pageForm,
@Param("messageContent") String messageContent) {
if (StrUtil.isBlank(pageForm.getProjectId())) {
return Result.error("请选择福利项目");
}
if (StrUtil.isBlank(messageContent)) {
return Result.error("消息内容不能为空");
}
int receiverCount = situationService.sendDingTalkMessage(pageForm, messageContent);
return Result.success("消息发送成功,共发送" + receiverCount + "");
}
@At
@SaCheckPermission("welfare.selection.situation")
@@ -89,17 +113,29 @@ public class WelfareSelectionSituationController {
return Result.error("此用户没有选择的权限");
}
Date selectTime = new Date();
String additionalInfoValidationMessage = welfareProjectService.validateSelectionAdditionalInfo(selections, selectTime);
if (additionalInfoValidationMessage != null) {
return Result.error(additionalInfoValidationMessage);
}
String defaultOptionValidationMessage = welfareProjectService.validateSystemDefaultOptionSelection(projectId, selections);
if (defaultOptionValidationMessage != null) {
return Result.error(defaultOptionValidationMessage);
}
// 代选与用户自选使用同一套分组份数规则,校验失败时不覆盖原有选择记录。
String groupQuantityValidationMessage = welfareProjectService.validateGroupSelectionQuantity(projectId, selections);
if (groupQuantityValidationMessage != null) {
return Result.error(groupQuantityValidationMessage);
}
//删除上次选择的
dao.clear(WelfareUserSelection.class, Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId).and(WelfareUserSelection::getSelectUserId, "=", userId));
for (WelfareUserSelection welfareUserSelection : selections) {
welfareUserSelection.setWelfareId(projectId);
welfareUserSelection.setSelectUserId(userId);
welfareUserSelection.setSelectTime(new Date());
welfareUserSelection.setSelectTime(selectTime);
}
dao.insert(selections);
return Result.success("选择成功");
@@ -0,0 +1,70 @@
package com.budwk.app.zhgh.welfare.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationDetail;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationForm;
import com.budwk.app.zhgh.welfare.service.WelfareUserEvaluationService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
/**
* 福利用户评价接口,供PC“选择福利”和移动端“我的福利”共同调用。
*/
@IocBean
@Ok("json:full")
@At("/platform/welfare/evaluation")
@Api(tags = "福利用户评价")
public class WelfareUserEvaluationController {
@Inject
private WelfareUserEvaluationService welfareUserEvaluationService;
/**
* 获取当前登录用户在指定福利项目中的评价回显数据。
*
* @param projectId 福利项目ID
* @return 当前项目全部福利选择和评分
*/
@At
@SaCheckPermission(value = {"welfare.user.select", "welfare.mine"}, mode = SaMode.OR)
@ApiOperation("获取福利评价")
public Result getEvaluation(String projectId) {
String userId = SecurityUtil.getUserId();
String availableValidationMessage = welfareUserEvaluationService.validateEvaluationAvailable(projectId, userId);
if (availableValidationMessage != null) {
return Result.error(availableValidationMessage);
}
WelfareEvaluationDetail detail = welfareUserEvaluationService.getEvaluationDetail(projectId, userId);
return Result.success(detail);
}
/**
* 保存当前登录用户对项目中全部已选福利的评价。
*
* @param evaluationForm 福利评价表单JSON
* @return 保存结果
*/
@At
@Aop(TransAop.READ_COMMITTED)
@SLog(type = "welfare", tag = "福利评价", msg = "提交福利评价")
@SaCheckPermission(value = {"welfare.user.select", "welfare.mine"}, mode = SaMode.OR)
@ApiOperation("保存福利评价")
public Result saveEvaluation(@Param("evaluationForm") WelfareEvaluationForm evaluationForm) {
String validationMessage = welfareUserEvaluationService.saveEvaluation(evaluationForm, SecurityUtil.getUserId());
if (validationMessage != null) {
return Result.error(validationMessage);
}
return Result.success("评价提交成功");
}
}
@@ -57,6 +57,8 @@ public class WelfareUserSelectController {
ELSE 0
END AS isChoose,
wus.selectTime,
MAX(wus.deliveryDate) AS deliveryDate,
MAX(wus.remark) AS remark,
GROUP_CONCAT(DISTINCT wuso.optionName ,'',wus.selectNum,'份)') AS gist_list,
wus.receiveAddress
FROM
@@ -93,11 +95,23 @@ public class WelfareUserSelectController {
}
WelfareProject project = dao.fetch(WelfareProject.class, projectId);
Date selectTime = new Date();
String additionalInfoValidationMessage = welfareProjectService.validateSelectionAdditionalInfo(selections, selectTime);
if (additionalInfoValidationMessage != null) {
return Result.error(additionalInfoValidationMessage);
}
String defaultOptionValidationMessage = welfareProjectService.validateSystemDefaultOptionSelection(projectId, selections);
if (defaultOptionValidationMessage != null) {
return Result.error(defaultOptionValidationMessage);
}
// 后端统一校验各分组累计份数,防止 PC 或移动端绕过前端限制提交。
String groupQuantityValidationMessage = welfareProjectService.validateGroupSelectionQuantity(projectId, selections);
if (groupQuantityValidationMessage != null) {
return Result.error(groupQuantityValidationMessage);
}
int sum = Arrays.stream(selections).mapToInt(selection -> Objects.requireNonNullElse(selection.getSelectNum(), 0)).sum();
if (sum > project.getMultiSelectNum()) {
return Result.error("选择的数量不能超过" + project.getMultiSelectNum());
@@ -109,7 +123,7 @@ public class WelfareUserSelectController {
for (WelfareUserSelection welfareUserSelection : selections) {
welfareUserSelection.setWelfareId(projectId);
welfareUserSelection.setSelectUserId(SecurityUtil.getUserId());
welfareUserSelection.setSelectTime(new Date());
welfareUserSelection.setSelectTime(selectTime);
}
dao.insert(selections);
return Result.success("选择成功");
@@ -42,7 +42,7 @@ public class WelfareProjectSubject extends BaseModel implements Serializable {
@Column
@Comment("题目类型")
@ColDefine(type = ColType.VARCHAR, width = 10)
@ColDefine(type = ColType.VARCHAR, width = 32)
private String subjectType;
@Column
@@ -0,0 +1,69 @@
package com.budwk.app.zhgh.welfare.model;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Index;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableIndexes;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.io.Serializable;
import java.util.Date;
/**
* 福利用户评价。每条记录关联一条福利人员选择记录,一颗星对应一分。
*/
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = true)
@Table("welfare_user_evaluation")
@Comment("福利用户评价")
@TableIndexes({
@Index(name = "uk_welfare_user_evaluation_selection", fields = {"selectionId"}, unique = true),
@Index(name = "idx_welfare_user_evaluation_project_user", fields = {"projectId", "userId"}, unique = false)
})
public class WelfareUserEvaluation extends BaseModel implements Serializable {
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("福利人员选择记录ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String selectionId;
@Column
@Comment("福利项目ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String projectId;
@Column
@Comment("评价用户ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
@Comment("评分,范围1至5")
@ColDefine(type = ColType.INT)
private Integer score;
@Column
@Comment("评价备注")
@ColDefine(type = ColType.VARCHAR, width = 200)
private String remark;
@Column
@Comment("评价时间")
@ColDefine(type = ColType.DATETIME)
private Date evaluatedAt;
}
@@ -60,6 +60,16 @@ public class WelfareUserSelection extends BaseModel implements Serializable {
@ColDefine(type = ColType.DATETIME)
private Date selectTime;
@Column
@Comment("配送时间")
@ColDefine(type = ColType.DATE)
private Date deliveryDate;
@Column
@Comment("选择备注")
@ColDefine(type = ColType.VARCHAR, width = 200)
private String remark;
@Column
@Comment("收货地址")
@ColDefine(type = ColType.VARCHAR, width = 100)
@@ -0,0 +1,47 @@
package com.budwk.app.zhgh.welfare.param;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 福利评价回显数据。
*/
@Data
public class WelfareEvaluationDetail {
/** 福利项目ID。 */
private String projectId;
/** 已保存的统一评价备注。 */
private String remark;
/** 是否已经完整评价当前项目的全部已选福利。 */
private Boolean evaluated;
/** 当前用户在项目中的福利选择及评分。 */
private List<EvaluationItem> items = new ArrayList<>();
/**
* 福利评价回显明细。
*/
@Data
public static class EvaluationItem {
/** 福利人员选择记录ID。 */
private String selectionId;
/** 福利选项ID。 */
private String optionId;
/** 福利名称。 */
private String optionName;
/** 用户选择的福利份数。 */
private Integer selectNum;
/** 星级评分,未评价时为0。 */
private Integer score;
}
}
@@ -0,0 +1,34 @@
package com.budwk.app.zhgh.welfare.param;
import lombok.Data;
import java.util.List;
/**
* 福利评价提交参数。
*/
@Data
public class WelfareEvaluationForm {
/** 福利项目ID。 */
private String projectId;
/** 本次福利评价的统一备注,最多200字。 */
private String remark;
/** 当前项目全部已选福利的评分明细。 */
private List<EvaluationItem> items;
/**
* 单条福利选择的评分参数。
*/
@Data
public static class EvaluationItem {
/** 福利人员选择记录ID。 */
private String selectionId;
/** 星级评分,一颗星为一分,取值范围1至5。 */
private Integer score;
}
}
@@ -0,0 +1,24 @@
package com.budwk.app.zhgh.welfare.param;
import com.budwk.app.base.param.PageForm;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 福利评分管理分页查询参数。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel("福利评分管理分页参数")
public class WelfareEvaluationManagePageForm extends PageForm {
/** 用于前端联动福利项目列表的年度。 */
@ApiModelProperty("年度")
private Integer year;
/** 当前查询的福利项目ID。 */
@ApiModelProperty("福利项目ID")
private String projectId;
}
@@ -4,6 +4,7 @@ import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.welfare.model.WelfareProject;
import com.budwk.app.zhgh.welfare.model.WelfareUserSelection;
import java.util.Date;
import java.util.List;
public interface WelfareProjectService extends BaseService<WelfareProject> {
@@ -33,6 +34,25 @@ public interface WelfareProjectService extends BaseService<WelfareProject> {
*/
String validateSystemDefaultOptionSelection(String projectId, WelfareUserSelection[] selections);
/**
* 按福利选项分组累计每项选择份数,并校验项目配置的最小、最大选择数量。
*
* @param projectId 福利项目 ID,用于查询分组开关、分组配置及选项所属分组
* @param selections 用户提交的福利选择数组,每项份数通过 selectNum 累计到对应分组
* @return 校验通过返回 {@code null};不满足分组数量限制时返回具体错误提示
*/
String validateGroupSelectionQuantity(String projectId, WelfareUserSelection[] selections);
/**
* 校验福利选择附加信息。配送时间为必填项,必须按日期粒度不早于本次选择时间;
* 同一次提交的全部福利项必须使用相同配送时间和备注。
*
* @param selections 用户提交的福利选择数组,每项需包含 deliveryDate,可选包含 remark
* @param selectTime 后端生成的本次选择时间,用于防止客户端伪造日期
* @return 校验通过返回 {@code null},否则返回可直接展示的错误提示
*/
String validateSelectionAdditionalInfo(WelfareUserSelection[] selections, Date selectTime);
/**
* 保存项目信息
* @param project
@@ -11,6 +11,15 @@ public interface WelfareSelectionSituationService extends BaseService<WelfareLis
Pagination pageData(WelfareSelectionSituationPageForm pageForm);
/**
* 根据选择情况页面的全部查询条件发送钉钉消息。
*
* @param pageForm 查询条件,包含项目、福利选项、人员信息、组织范围及选择状态
* @param messageContent 纯文本消息内容,不应包含HTML标签或富文本样式
* @return 实际提交到钉钉消息渠道的去重人员数量
*/
int sendDingTalkMessage(WelfareSelectionSituationPageForm pageForm, String messageContent);
void exportXlsx(WelfareSelectionSituationPageForm pageForm, HttpServletResponse response);
void receiveXlsx(WelfareSelectionSituationPageForm pageForm, HttpServletResponse response);
@@ -0,0 +1,69 @@
package com.budwk.app.zhgh.welfare.service;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.welfare.model.WelfareUserEvaluation;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationDetail;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationForm;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationManagePageForm;
import javax.servlet.http.HttpServletResponse;
/**
* 福利用户评价服务。
*/
public interface WelfareUserEvaluationService extends BaseService<WelfareUserEvaluation> {
/**
* 校验当前用户是否已到福利配送日期。只有存在选择记录、全部记录已设置配送日期,且当前日期
* 不早于配送日期时才允许打开或提交评价。
*
* @param projectId 福利项目ID
* @param userId 当前登录用户ID
* @return 允许评价返回{@code null},否则返回可直接展示的错误提示
*/
String validateEvaluationAvailable(String projectId, String userId);
/**
* 查询指定用户在福利项目中的全部选择及评价,供PC弹窗和移动端底部弹层回显。
*
* @param projectId 福利项目ID
* @param userId 当前登录用户ID
* @return 当前选择记录、福利名称、份数和已保存评分
*/
WelfareEvaluationDetail getEvaluationDetail(String projectId, String userId);
/**
* 校验并保存当前项目全部已选福利的评分。重复提交时按选择记录更新评价。
*
* @param form 福利评价表单,必须包含当前项目全部选择记录
* @param userId 当前登录用户ID,用于校验选择记录归属
* @return 保存成功返回{@code null},失败返回可直接展示的错误提示
*/
String saveEvaluation(WelfareEvaluationForm form, String userId);
/**
* 分页查询福利项目名单中的人员评分,一个人员只返回一条汇总记录。
*
* @param pageForm 项目ID及分页排序条件
* @return 人员分页数据,包含基础信息、综合评价和评价备注
*/
Pagination managePageData(WelfareEvaluationManagePageForm pageForm);
/**
* 查询管理端指定人员的逐项福利评分,并校验当前管理员的数据范围。
*
* @param projectId 福利项目ID
* @param userId 福利名单人员ID
* @return 逐项福利评分、选择份数、综合评价所需明细及统一备注
*/
WelfareEvaluationDetail getManageEvaluationDetail(String projectId, String userId);
/**
* 按项目和人员去重导出福利评分,每个人员一行,项目全部福利选项动态生成评分列。
*
* @param projectId 福利项目ID
* @param response Excel下载响应
*/
void exportManageXlsx(String projectId, HttpServletResponse response);
}
@@ -103,6 +103,126 @@ public class WelfareProjectServiceImpl extends BaseServiceImpl<WelfareProject> i
return "系统默认福利“" + String.join("", missingOptionNames) + "”不可取消";
}
/**
* 校验用户在每个分组中选择的总份数。分组总份数为该分组下所有已选项 selectNum 的累计值,
* 最大值为 0 时表示不限制;分组为空的福利选项不参与校验。
*
* @param projectId 福利项目 ID
* @param selections 当前提交的福利选择数据
* @return 校验通过返回 {@code null},否则返回包含分组和当前份数的错误提示
*/
@Override
public String validateGroupSelectionQuantity(String projectId, WelfareUserSelection[] selections) {
WelfareProject project = fetch(projectId);
if (project == null) {
return "福利项目不存在";
}
if (!Boolean.TRUE.equals(project.getGroupRequired())) {
return null;
}
WelfareProjectSubject configSubject = dao().fetch(WelfareProjectSubject.class,
Cnd.where(WelfareProjectSubject::getProjectId, "=", projectId)
.and(WelfareProjectSubject::getSubjectType, "=", GROUP_CONFIG_SUBJECT_TYPE));
List<WelfareProjectSubject.GroupSelectionConfig> configs = configSubject == null
|| configSubject.getGroupSelectionConfigs() == null
? Collections.emptyList()
: configSubject.getGroupSelectionConfigs();
if (configs.isEmpty()) {
return null;
}
// 选项所属分组必须以后端数据为准,禁止通过提交参数伪造或绕过分组限制。
Map<String, WelfareProjectSubjectOption> optionMap = dao().query(WelfareProjectSubjectOption.class,
Cnd.where(WelfareProjectSubjectOption::getWelfareId, "=", projectId))
.stream()
.collect(Collectors.toMap(WelfareProjectSubjectOption::getId, option -> option, (left, right) -> left));
Map<String, Long> groupQuantityMap = new HashMap<>();
WelfareUserSelection[] submittedSelections = selections == null ? new WelfareUserSelection[0] : selections;
for (WelfareUserSelection selection : submittedSelections) {
if (selection == null) {
continue;
}
int selectNum = Objects.requireNonNullElse(selection.getSelectNum(), 0);
if (selectNum < 0) {
return "福利选择份数不能小于0";
}
if (selectNum == 0) {
continue;
}
WelfareProjectSubjectOption option = optionMap.get(selection.getSelectOptionId());
if (option == null) {
return "提交数据中存在不属于当前项目的福利选项";
}
if (StrUtil.isBlank(option.getGroupName())) {
continue;
}
groupQuantityMap.merge(option.getGroupName().trim(), (long) selectNum, Long::sum);
}
// 逐个配置分组校验累计份数,未选择的分组按 0 份处理。
for (WelfareProjectSubject.GroupSelectionConfig config : configs) {
if (config == null || StrUtil.isBlank(config.getGroupName())) {
continue;
}
String groupName = config.getGroupName().trim();
long selectedQuantity = groupQuantityMap.getOrDefault(groupName, 0L);
int minSelectNum = Objects.requireNonNullElse(config.getMinSelectNum(), 1);
int maxSelectNum = Objects.requireNonNullElse(config.getMaxSelectNum(), 0);
if (selectedQuantity < minSelectNum) {
return "分组“" + groupName + "”至少需要选择" + minSelectNum
+ "份,当前已选择" + selectedQuantity + "";
}
if (maxSelectNum > 0 && selectedQuantity > maxSelectNum) {
return "分组“" + groupName + "”最多可选择" + maxSelectNum
+ "份,当前已选择" + selectedQuantity + "";
}
}
return null;
}
/**
* 校验一次选择提交中的配送日期和备注。日期比较统一截断到当天零点,允许选择当天配送;
* 多项福利的附加信息必须一致,避免同一次选择产生互相冲突的评价开放时间。
*
* @param selections 当前提交的福利选择数据
* @param selectTime 后端生成的选择时间
* @return 校验通过返回 {@code null},否则返回错误提示
*/
@Override
public String validateSelectionAdditionalInfo(WelfareUserSelection[] selections, Date selectTime) {
if (selections == null || selections.length == 0) {
return "请至少选择一项福利";
}
Date selectionDate = DateUtil.beginOfDay(selectTime == null ? new Date() : selectTime);
Date submittedDeliveryDate = null;
String submittedRemark = null;
for (WelfareUserSelection selection : selections) {
if (selection == null || selection.getDeliveryDate() == null) {
return "请选择配送时间";
}
Date deliveryDate = DateUtil.beginOfDay(selection.getDeliveryDate());
if (deliveryDate.before(selectionDate)) {
return "配送时间不能早于选择时间";
}
if (submittedDeliveryDate == null) {
submittedDeliveryDate = deliveryDate;
submittedRemark = StrUtil.trim(selection.getRemark());
} else if (!submittedDeliveryDate.equals(deliveryDate)) {
return "同一次选择的配送时间必须一致";
} else if (!Objects.equals(submittedRemark, StrUtil.trim(selection.getRemark()))) {
return "同一次选择的备注必须一致";
}
String remark = StrUtil.trim(selection.getRemark());
if (remark != null && remark.length() > 200) {
return "备注不能超过200字";
}
selection.setDeliveryDate(deliveryDate);
selection.setRemark(remark);
}
return null;
}
private void fillOptionRank(String projectId, List<WelfareProjectSubjectOption> options) {
if (options == null || options.isEmpty()) {
return;
@@ -7,9 +7,12 @@ import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.afterturn.easypoi.excel.export.ExcelExportService;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HtmlUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.sms.SmsService;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.sys.models.Sys_file;
@@ -29,6 +32,8 @@ import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
@@ -51,6 +56,9 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
@Inject
private SysFileService sysFileService;
@Inject
private SmsService smsService;
@Override
public Pagination pageData(WelfareSelectionSituationPageForm pageForm) {
Sql sql = Sqls.create("""
@@ -64,6 +72,7 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
GROUP_CONCAT(DISTINCT t3.optionName ,'',t2.selectNum,'份)') AS selectedOptions,
GROUP_CONCAT(DISTINCT t2.mobile) AS mobile,
GROUP_CONCAT(DISTINCT t2.receiveAddress) AS receiveAddress,
MAX(t2.deliveryDate) AS deliveryDate,
t4.username AS userName,
t4.loginname AS loginName,
t4.sex,
@@ -75,33 +84,7 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
LEFT JOIN sys_user t4 ON t4.id = t1.userId
$condition
""");
Cnd cnd = Cnd.NEW();
if (AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name()) || AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ADMIN.name()) || AuthUtil.hasRole(RoleConstant.BRANCH_UNION_WENTI_SPORTS.name())) {
cnd.and("t1.welfareUnionId", "=", SecurityUtil.getUnionId());
}
cnd.and("t1.projectId", "=", pageForm.getProjectId());
cnd.andEX("t2.selectOptionId", "=", pageForm.getWelfareOptionId());
cnd.andEX("t4.aidFundMemberUserType", "=", pageForm.getAidFundMemberUserType());
cnd.andEX("t4.userAttribute", "=", pageForm.getUserAttribute());
if (StrUtil.isNotBlank(pageForm.getUserName())) {
cnd.where().andLike("t4.username", pageForm.getUserName());
}
if (StrUtil.isNotBlank(pageForm.getLoginName())) {
cnd.where().andLike("t4.loginname", pageForm.getLoginName());
}
cnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds());
cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
if (pageForm.getIsSelect() != null) {
if (pageForm.getIsSelect()) {
cnd.and("t2.id", "IS NOT", null);
} else {
cnd.and("t2.id", "IS", null);
}
}
Cnd cnd = buildSituationCondition(pageForm);
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
cnd.asc("welfareUnionName");
@@ -115,6 +98,88 @@ public class WelfareSelectionSituationServiceImpl extends BaseServiceImpl<Welfar
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
}
/**
* 按页面查询条件查询所有人员并发送钉钉文本消息。
* 接收人由后台重新查询,且沿用列表的数据权限,防止通过前端参数越权指定人员。
*
* @param pageForm 页面完整查询条件
* @param messageContent 前端提交的纯文本消息内容
* @return 去空并去重后的实际接收人数
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public int sendDingTalkMessage(WelfareSelectionSituationPageForm pageForm, String messageContent) {
String plainContent = HtmlUtil.unescape(HtmlUtil.cleanHtmlTag(StrUtil.blankToDefault(messageContent, ""))).trim();
if (StrUtil.isBlank(plainContent)) {
throw new BaseException("消息内容不能为空");
}
WelfareProject project = dao().fetch(WelfareProject.class, pageForm.getProjectId());
if (project == null) {
throw new BaseException("福利项目不存在");
}
Sql sql = Sqls.create("""
SELECT DISTINCT
t4.loginname AS loginName
FROM
welfare_list t1
LEFT JOIN welfare_project_user_selection t2 ON t2.welfareId = t1.projectId AND t2.selectUserId = t1.userId
LEFT JOIN welfare_project_subject_option t3 ON t3.id = t2.selectOptionId
LEFT JOIN sys_user t4 ON t4.id = t1.userId
$condition
""");
sql.setCondition(buildSituationCondition(pageForm));
List<String> loginNames = listMap(sql).stream()
.map(item -> item.getString("loginName"))
.filter(StrUtil::isNotBlank)
.distinct()
.toList();
if (loginNames.isEmpty()) {
throw new BaseException("当前查询条件下没有可发送消息的人员");
}
String title = project.getName() + "-福利通知";
boolean success = smsService.sendMsg("6", loginNames, null, title, plainContent, null, null);
if (!success) {
throw new BaseException("钉钉消息发送失败,请检查消息发送配置或稍后重试");
}
return loginNames.size();
}
/**
* 构建选择情况列表与消息接收人共用的筛选条件。
* 除页面条件外会主动叠加当前登录人的分工会数据范围。
*
* @param pageForm 页面查询条件
* @return 可用于选择情况关联查询的Nutz条件对象
*/
private Cnd buildSituationCondition(WelfareSelectionSituationPageForm pageForm) {
Cnd cnd = Cnd.NEW();
if (AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_WENTI_SPORTS.name())) {
cnd.and("t1.welfareUnionId", "=", SecurityUtil.getUnionId());
}
cnd.and("t1.projectId", "=", pageForm.getProjectId());
cnd.andEX("t2.selectOptionId", "=", pageForm.getWelfareOptionId());
cnd.andEX("t4.aidFundMemberUserType", "=", pageForm.getAidFundMemberUserType());
cnd.andEX("t4.userAttribute", "=", pageForm.getUserAttribute());
if (StrUtil.isNotBlank(pageForm.getUserName())) {
cnd.where().andLike("t4.username", pageForm.getUserName());
}
if (StrUtil.isNotBlank(pageForm.getLoginName())) {
cnd.where().andLike("t4.loginname", pageForm.getLoginName());
}
cnd.andEX("t1.welfareUnitId", "in", pageForm.getUnitIds());
cnd.andEX("t1.welfareUnionId", "=", pageForm.getUnionId());
if (pageForm.getIsSelect() != null) {
cnd.and("t2.id", pageForm.getIsSelect() ? "IS NOT" : "IS", null);
}
return cnd;
}
@Override
public void exportXlsx(WelfareSelectionSituationPageForm pageForm, HttpServletResponse response) {
Sql sql = Sqls.create("""
@@ -0,0 +1,445 @@
package com.budwk.app.zhgh.welfare.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.constant.RoleConstant;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.base.utils.PageUtil;
import com.budwk.app.web.commons.auth.utils.AuthUtil;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.welfare.model.WelfareList;
import com.budwk.app.zhgh.welfare.model.WelfareProject;
import com.budwk.app.zhgh.welfare.model.WelfareProjectSubjectOption;
import com.budwk.app.zhgh.welfare.model.WelfareUserEvaluation;
import com.budwk.app.zhgh.welfare.model.WelfareUserSelection;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationDetail;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationForm;
import com.budwk.app.zhgh.welfare.param.WelfareEvaluationManagePageForm;
import com.budwk.app.zhgh.welfare.service.WelfareUserEvaluationService;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Workbook;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 福利用户评价服务实现。
*/
@IocBean(args = {"refer:dao"})
@Slf4j
public class WelfareUserEvaluationServiceImpl extends BaseServiceImpl<WelfareUserEvaluation> implements WelfareUserEvaluationService {
public WelfareUserEvaluationServiceImpl(Dao dao) {
super(dao);
}
/**
* 评价开放时间以后端福利选择记录为准,避免客户端隐藏按钮后仍可直接调用评价接口。
* 配送时间按日期粒度比较,到达配送日期当天即可评价。
*
* @param projectId 福利项目ID
* @param userId 当前登录用户ID
* @return 允许评价返回{@code null},否则返回错误提示
*/
@Override
public String validateEvaluationAvailable(String projectId, String userId) {
if (StrUtil.isBlank(projectId) || StrUtil.isBlank(userId)) {
return "评价参数不完整";
}
List<WelfareUserSelection> selections = dao().query(WelfareUserSelection.class,
Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId)
.and(WelfareUserSelection::getSelectUserId, "=", userId));
if (selections.isEmpty()) {
return "当前项目暂无可评价的福利选择";
}
Date currentDate = DateUtil.beginOfDay(new Date());
for (WelfareUserSelection selection : selections) {
if (selection.getDeliveryDate() == null) {
return "当前福利尚未设置配送时间,暂不能评价";
}
if (currentDate.before(DateUtil.beginOfDay(selection.getDeliveryDate()))) {
return "配送时间未到,暂不能评价";
}
}
return null;
}
/**
* 查询当前用户在项目中的福利选择,并合并已保存的星级与备注。
*
* @param projectId 福利项目ID
* @param userId 当前登录用户ID
* @return 可直接供评价弹窗回显的数据
*/
@Override
public WelfareEvaluationDetail getEvaluationDetail(String projectId, String userId) {
WelfareEvaluationDetail detail = new WelfareEvaluationDetail();
detail.setProjectId(projectId);
detail.setRemark("");
if (StrUtil.isBlank(projectId) || StrUtil.isBlank(userId)) {
detail.setEvaluated(false);
return detail;
}
List<WelfareUserSelection> selections = dao().query(WelfareUserSelection.class,
Cnd.where(WelfareUserSelection::getWelfareId, "=", projectId)
.and(WelfareUserSelection::getSelectUserId, "=", userId)
.asc(WelfareUserSelection::getSelectTime));
if (selections.isEmpty()) {
detail.setEvaluated(false);
return detail;
}
Set<String> optionIds = selections.stream()
.map(WelfareUserSelection::getSelectOptionId)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
Map<String, WelfareProjectSubjectOption> optionMap = optionIds.isEmpty()
? Collections.emptyMap()
: dao().query(WelfareProjectSubjectOption.class, Cnd.where(WelfareProjectSubjectOption::getId, "in", optionIds))
.stream()
.collect(Collectors.toMap(WelfareProjectSubjectOption::getId, Function.identity(), (left, right) -> left));
Map<String, WelfareUserEvaluation> evaluationMap = dao().query(WelfareUserEvaluation.class,
Cnd.where(WelfareUserEvaluation::getProjectId, "=", projectId)
.and(WelfareUserEvaluation::getUserId, "=", userId))
.stream()
.collect(Collectors.toMap(WelfareUserEvaluation::getSelectionId, Function.identity(), (left, right) -> left));
boolean evaluated = true;
for (WelfareUserSelection selection : selections) {
WelfareProjectSubjectOption option = optionMap.get(selection.getSelectOptionId());
WelfareUserEvaluation evaluation = evaluationMap.get(selection.getId());
WelfareEvaluationDetail.EvaluationItem item = new WelfareEvaluationDetail.EvaluationItem();
item.setSelectionId(selection.getId());
item.setOptionId(selection.getSelectOptionId());
item.setOptionName(option == null ? "未知福利" : option.getOptionName());
item.setSelectNum(Objects.requireNonNullElse(selection.getSelectNum(), 0));
item.setScore(evaluation == null ? 0 : Objects.requireNonNullElse(evaluation.getScore(), 0));
detail.getItems().add(item);
if (evaluation == null || item.getScore() < 1 || item.getScore() > 5) {
evaluated = false;
} else if (StrUtil.isBlank(detail.getRemark()) && StrUtil.isNotBlank(evaluation.getRemark())) {
// 统一备注会写入每条评价记录,回显时读取首个非空值即可。
detail.setRemark(evaluation.getRemark());
}
}
detail.setEvaluated(evaluated);
return detail;
}
/**
* 保存福利评价。服务端通过当前选择记录集合校验提交项,避免评价他人或伪造选择ID。
*
* @param form 福利评价提交数据
* @param userId 当前登录用户ID
* @return 保存成功返回{@code null},校验失败返回提示
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public String saveEvaluation(WelfareEvaluationForm form, String userId) {
if (form == null || StrUtil.isBlank(form.getProjectId()) || StrUtil.isBlank(userId)) {
return "评价参数不完整";
}
String availableValidationMessage = validateEvaluationAvailable(form.getProjectId(), userId);
if (availableValidationMessage != null) {
return availableValidationMessage;
}
String remark = StrUtil.trim(form.getRemark());
if (remark != null && remark.length() > 200) {
return "评价备注不能超过200字";
}
List<WelfareUserSelection> selections = dao().query(WelfareUserSelection.class,
Cnd.where(WelfareUserSelection::getWelfareId, "=", form.getProjectId())
.and(WelfareUserSelection::getSelectUserId, "=", userId));
if (selections.isEmpty()) {
return "当前项目暂无可评价的福利选择";
}
List<WelfareEvaluationForm.EvaluationItem> submittedItems = form.getItems() == null
? Collections.emptyList()
: form.getItems();
if (submittedItems.size() != selections.size()) {
return "请为本次选择的每项福利完成评分";
}
Map<String, WelfareEvaluationForm.EvaluationItem> submittedItemMap = new HashMap<>();
for (WelfareEvaluationForm.EvaluationItem item : submittedItems) {
if (item == null || StrUtil.isBlank(item.getSelectionId()) || item.getScore() == null
|| item.getScore() < 1 || item.getScore() > 5) {
return "每项福利都需要选择1至5星评分";
}
if (submittedItemMap.put(item.getSelectionId(), item) != null) {
return "评价数据中存在重复的福利选择记录";
}
}
Set<String> currentSelectionIds = selections.stream().map(WelfareUserSelection::getId).collect(Collectors.toSet());
if (!currentSelectionIds.equals(new HashSet<>(submittedItemMap.keySet()))) {
return "评价数据与当前福利选择不一致,请刷新后重试";
}
Map<String, WelfareUserEvaluation> existingEvaluationMap = dao().query(WelfareUserEvaluation.class,
Cnd.where(WelfareUserEvaluation::getSelectionId, "in", currentSelectionIds))
.stream()
.collect(Collectors.toMap(WelfareUserEvaluation::getSelectionId, Function.identity(), (left, right) -> left));
Date evaluatedAt = new Date();
for (WelfareUserSelection selection : selections) {
WelfareEvaluationForm.EvaluationItem submittedItem = submittedItemMap.get(selection.getId());
WelfareUserEvaluation evaluation = existingEvaluationMap.get(selection.getId());
if (evaluation == null) {
evaluation = new WelfareUserEvaluation()
.setSelectionId(selection.getId())
.setProjectId(form.getProjectId())
.setUserId(userId)
.setScore(submittedItem.getScore())
.setRemark(remark)
.setEvaluatedAt(evaluatedAt);
dao().insert(evaluation);
} else {
// 重复评价只更新业务字段,selectionId、projectId和userId始终保持首次校验后的归属关系。
evaluation.setScore(submittedItem.getScore());
evaluation.setRemark(remark);
evaluation.setEvaluatedAt(evaluatedAt);
dao().update(evaluation);
}
}
return null;
}
/**
* 管理端评分列表以福利名单为人员范围,并按项目和人员ID分组,确保人员选择多个福利时只显示一行。
* 综合评价只统计已保存的逐项评分,未评价人员返回空值。
*
* @param pageForm 项目ID及分页排序条件
* @return 去重后的人员评分分页数据
*/
@Override
public Pagination managePageData(WelfareEvaluationManagePageForm pageForm) {
Sql sql = Sqls.create("""
SELECT
MIN(wl.id) AS id,
wl.projectId,
wl.userId,
u.loginname AS loginName,
u.username AS userName,
wl.welfareUnitName,
wl.welfareUnionName,
wl.userState,
wl.personType,
ROUND(AVG(e.score), 1) AS comprehensiveScore,
CASE
WHEN COUNT(e.id) = 0 THEN '未评价'
WHEN MAX(NULLIF(e.remark, '')) IS NULL THEN '已评价'
ELSE MAX(e.remark)
END AS evaluation
FROM welfare_list wl
LEFT JOIN sys_user u ON u.id = wl.userId
LEFT JOIN welfare_user_evaluation e ON e.projectId = wl.projectId AND e.userId = wl.userId
$condition
""");
Cnd cnd = buildManageCondition(pageForm.getProjectId());
cnd.groupBy("wl.projectId", "wl.userId");
if (StrUtil.isAllBlank(pageForm.getPageOrderName(), pageForm.getPageOrderBy())) {
cnd.asc("wl.welfareUnionName");
cnd.asc("wl.welfareUnitName");
cnd.asc("u.loginname");
} else {
cnd.orderBy(pageForm.getPageOrderName(), PageUtil.getOrder(pageForm.getPageOrderBy()));
}
sql.setCondition(cnd);
return listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
}
/**
* 管理端查看评价前先确认人员属于项目福利名单及当前管理员可访问的分工会范围。
*
* @param projectId 福利项目ID
* @param userId 福利名单人员ID
* @return 逐项福利评分详情
*/
@Override
public WelfareEvaluationDetail getManageEvaluationDetail(String projectId, String userId) {
Cnd accessCnd = Cnd.where(WelfareList::getProjectId, "=", projectId)
.and(WelfareList::getUserId, "=", userId);
if (hasBranchUnionDataScope()) {
accessCnd.and(WelfareList::getWelfareUnionId, "=", SecurityUtil.getUnionId());
}
if (dao().count(WelfareList.class, accessCnd) == 0) {
throw new BaseException("人员不在当前福利项目或无权查看该评价");
}
return getEvaluationDetail(projectId, userId);
}
/**
* 导出项目人员的福利评分。名单按项目和人员去重,每个人员占一行,项目全部福利选项
* 按排序动态生成评分列;已选择未评价显示“未评价”,未选择的福利列留空。
*
* @param projectId 福利项目ID
* @param response Excel下载响应
*/
@Override
public void exportManageXlsx(String projectId, HttpServletResponse response) {
WelfareProject project = dao().fetch(WelfareProject.class, projectId);
if (project == null) {
throw new BaseException("福利项目不存在");
}
// 当前选项通过 welfareId 直接归属项目,同时兼容历史数据中仅通过 subjectId 关联项目的记录。
Sql optionSql = Sqls.create("""
SELECT DISTINCT
optionInfo.id,
optionInfo.optionName,
optionInfo.optionSort
FROM welfare_project_subject_option optionInfo
LEFT JOIN welfare_project_subject subjectInfo ON subjectInfo.id = optionInfo.subjectId
WHERE optionInfo.welfareId = @projectId OR subjectInfo.projectId = @projectId
ORDER BY optionInfo.optionSort, optionInfo.id
""");
optionSql.setParam("projectId", projectId);
List<NutMap> options = listMap(optionSql);
Sql personSql = Sqls.create("""
SELECT
project.name AS projectName,
wl.projectId,
wl.userId,
u.loginname AS loginName,
u.username AS userName,
wl.welfareUnitName,
wl.welfareUnionName,
evaluationInfo.comprehensiveScore,
COALESCE(evaluationInfo.evaluation, '未评价') AS evaluation
FROM welfare_list wl
INNER JOIN (
SELECT MIN(id) AS id
FROM welfare_list
GROUP BY projectId, userId
) uniqueList ON uniqueList.id = wl.id
INNER JOIN welfare_project project ON project.id = wl.projectId
LEFT JOIN sys_user u ON u.id = wl.userId
LEFT JOIN (
SELECT
projectId,
userId,
ROUND(AVG(score), 1) AS comprehensiveScore,
CASE
WHEN COUNT(id) = 0 THEN '未评价'
WHEN MAX(NULLIF(remark, '')) IS NULL THEN '已评价'
ELSE MAX(remark)
END AS evaluation
FROM welfare_user_evaluation
GROUP BY projectId, userId
) evaluationInfo ON evaluationInfo.projectId = wl.projectId AND evaluationInfo.userId = wl.userId
$condition
""");
Cnd cnd = buildManageCondition(projectId);
cnd.asc("wl.welfareUnionName");
cnd.asc("wl.welfareUnitName");
cnd.asc("u.loginname");
personSql.setCondition(cnd);
List<NutMap> rows = listMap(personSql);
Sql scoreSql = Sqls.create("""
SELECT
selectionInfo.welfareId AS projectId,
selectionInfo.selectUserId AS userId,
selectionInfo.selectOptionId AS optionId,
MAX(evaluation.score) AS score,
COUNT(evaluation.id) AS evaluationCount
FROM welfare_project_user_selection selectionInfo
LEFT JOIN welfare_user_evaluation evaluation ON evaluation.selectionId = selectionInfo.id
WHERE selectionInfo.welfareId = @projectId
GROUP BY selectionInfo.welfareId, selectionInfo.selectUserId, selectionInfo.selectOptionId
""");
scoreSql.setParam("projectId", projectId);
List<NutMap> scores = listMap(scoreSql);
Map<String, NutMap> personMap = rows.stream().collect(Collectors.toMap(
row -> row.getString("projectId") + "::" + row.getString("userId"),
Function.identity(),
(left, right) -> left));
for (NutMap score : scores) {
String personKey = score.getString("projectId") + "::" + score.getString("userId");
NutMap person = personMap.get(personKey);
if (person != null && StrUtil.isNotBlank(score.getString("optionId"))) {
String optionKey = "optionScore_" + score.getString("optionId");
Object scoreValue = score.getInt("evaluationCount", 0) > 0 ? score.get("score") : "未评价";
person.put(optionKey, scoreValue);
}
}
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("项目名称", "projectName", 30));
entities.add(new ExcelExportEntity("工号", "loginName", 18));
entities.add(new ExcelExportEntity("姓名", "userName", 18));
entities.add(new ExcelExportEntity("单位", "welfareUnitName", 30));
entities.add(new ExcelExportEntity("工会", "welfareUnionName", 25));
for (NutMap option : options) {
entities.add(new ExcelExportEntity(option.getString("optionName"),
"optionScore_" + option.getString("id"), 18));
}
entities.add(new ExcelExportEntity("综合评价", "comprehensiveScore", 15));
entities.add(new ExcelExportEntity("评价", "evaluation", 40));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
try (Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, rows)) {
CommonDownloadUtil.download(project.getName() + "评分列表.xlsx", workbook, response);
} catch (Exception e) {
log.error("导出福利评分列表失败,projectId={}", projectId, e);
throw new BaseException("导出福利评分列表失败");
}
}
/**
* 构建管理列表和导出共用的数据范围条件。
*
* @param projectId 福利项目ID
* @return 包含项目范围和分工会角色数据权限的查询条件
*/
private Cnd buildManageCondition(String projectId) {
Cnd cnd = Cnd.where("wl.projectId", "=", projectId);
if (hasBranchUnionDataScope()) {
cnd.and("wl.welfareUnionId", "=", SecurityUtil.getUnionId());
}
return cnd;
}
/**
* 判断当前用户是否只能查看所属分工会的数据。
*
* @return 分工会主席、管理员或文体委员返回true
*/
private boolean hasBranchUnionDataScope() {
return AuthUtil.hasRole(RoleConstant.BRANCH_UNION_CHAIRMAN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_ADMIN.name())
|| AuthUtil.hasRole(RoleConstant.BRANCH_UNION_WENTI_SPORTS.name());
}
}
@@ -0,0 +1,21 @@
-- 福利评价按人员选择记录逐项保存,一颗星对应一分。
CREATE TABLE IF NOT EXISTS `welfare_user_evaluation` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`selectionId` varchar(32) NOT NULL COMMENT '福利人员选择记录ID',
`projectId` varchar(32) NOT NULL COMMENT '福利项目ID',
`userId` varchar(32) NOT NULL COMMENT '评价用户ID',
`score` int NOT NULL COMMENT '评分,范围1至5',
`remark` varchar(200) DEFAULT NULL COMMENT '评价备注',
`evaluatedAt` datetime DEFAULT NULL COMMENT '评价时间',
`createdBy` varchar(32) DEFAULT NULL COMMENT '创建人',
`createdAt` bigint DEFAULT NULL COMMENT '创建时间',
`updatedBy` varchar(32) DEFAULT NULL COMMENT '修改人',
`updatedAt` bigint DEFAULT NULL COMMENT '修改时间',
`delFlag` tinyint(1) DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_welfare_user_evaluation_selection` (`selectionId`),
KEY `idx_welfare_user_evaluation_project_user` (`projectId`, `userId`),
CONSTRAINT `fk_welfare_user_evaluation_selection`
FOREIGN KEY (`selectionId`) REFERENCES `welfare_project_user_selection` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_welfare_user_evaluation_score` CHECK (`score` BETWEEN 1 AND 5)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利用户评价';
@@ -0,0 +1,296 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
<!--#include("/platform/zhgh/welfare/include/fullHeightList.css"){}#-->
.evaluation-score {
color: var(--color-primary);
font-weight: 600;
}
.evaluation-empty {
color: #909399;
}
.evaluation-detail-dialog {
border-radius: 6px;
}
.evaluation-detail-dialog .el-dialog__body {
padding-top: 12px;
}
.evaluation-detail-tip {
display: flex;
align-items: center;
margin-bottom: 16px;
color: #606266;
line-height: 22px;
}
.evaluation-detail-tip i {
margin-right: 8px;
color: var(--color-primary);
font-size: 17px;
}
.evaluation-detail-rate {
display: flex;
align-items: center;
justify-content: center;
}
.evaluation-detail-rate .el-rate {
height: 22px;
line-height: 22px;
}
.evaluation-detail-rate__text {
min-width: 52px;
margin-left: 12px;
color: #909399;
font-size: 12px;
text-align: left;
}
.evaluation-detail-remark {
margin-top: 18px;
}
.evaluation-detail-remark__label {
margin-bottom: 8px;
color: #303133;
font-weight: 600;
}
</style>
<div id="app" v-cloak>
<guava :edit_scroll="false">
<div class="welfare-full-height-page">
<el-card class="welfare-search-card" shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
v-model="pageForm.year"
:clearable="false"
type="year"
value-format="yyyy"
placeholder="请选择年度"
style="width: 100%"
@change="getProjectList">
</el-date-picker>
</search-item>
<search-item label="福利项目">
<el-select
v-model="pageForm.projectId"
:clearable="false"
filterable
placeholder="请选择福利项目"
style="width: 100%"
@change="doSearch">
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.name"
:value="item.id">
</el-option>
</el-select>
</search-item>
</search>
</el-card>
<el-card class="mt10 welfare-list-card" shadow="never">
<table-tool :app="this" label="评分列表">
<el-button
type="primary"
size="small"
icon="el-icon-download"
:disabled="!pageForm.projectId"
@click="exportXlsx">
导出
</el-button>
</table-tool>
<el-table
ref="tableRef"
v-loading="tableLoading"
:data="tableData"
:size="tableSize"
class="welfare-full-height-table"
height="100%"
row-key="id"
style="width: 100%"
@sort-change="pageOrder">
<el-table-column type="index" label="序号" :index="indexMethod" width="70" align="center"></el-table-column>
<el-table-column prop="loginName" label="工号" min-width="120" align="center" show-overflow-tooltip></el-table-column>
<el-table-column prop="userName" label="姓名" min-width="100" align="center" show-overflow-tooltip></el-table-column>
<el-table-column prop="welfareUnitName" label="单位" min-width="180" align="center" show-overflow-tooltip></el-table-column>
<el-table-column prop="welfareUnionName" label="工会" min-width="150" align="center" show-overflow-tooltip></el-table-column>
<el-table-column prop="userState" label="在职状态" min-width="100" align="center" show-overflow-tooltip></el-table-column>
<el-table-column prop="personType" label="人员类型" min-width="120" align="center" show-overflow-tooltip></el-table-column>
<el-table-column prop="comprehensiveScore" label="综合评价" min-width="110" align="center">
<template slot-scope="{row}">
<span class="evaluation-score" v-if="row.comprehensiveScore !== null && row.comprehensiveScore !== undefined">
{{row.comprehensiveScore}}分
</span>
<span class="evaluation-empty" v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="evaluation" label="评价" min-width="180" align="center" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" fixed="right" width="110" align="center">
<template slot-scope="{row}">
<el-button type="text" @click="openEvaluationDetail(row)">查看评价</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</div>
<el-dialog
title="福利评价"
:visible.sync="detailVisible"
:close-on-click-modal="false"
custom-class="evaluation-detail-dialog"
width="600px"
append-to-body>
<div v-loading="detailLoading">
<div class="evaluation-detail-tip">
<i class="el-icon-info"></i>
<span>以下为该人员本次选择的福利评价内容。</span>
</div>
<el-table :data="evaluationDetail.items" border max-height="320" size="small" style="width: 100%">
<el-table-column label="福利名称" min-width="210" align="center">
<template slot-scope="{row}">{{row.optionName}}{{row.selectNum}}份)</template>
</el-table-column>
<el-table-column label="评价(5星制)" min-width="280" align="center">
<template slot-scope="{row}">
<div class="evaluation-detail-rate">
<el-rate :value="Number(row.score) || 0" disabled></el-rate>
<span class="evaluation-detail-rate__text">
{{row.score ? row.score + "分" : "未评价"}}
</span>
</div>
</template>
</el-table-column>
</el-table>
<div class="evaluation-detail-remark">
<div class="evaluation-detail-remark__label">备注</div>
<el-input
:value="evaluationDetail.remark || ''"
type="textarea"
:rows="5"
readonly
placeholder="暂无评价备注">
</el-input>
</div>
</div>
</el-dialog>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
pageForm: {
year: new Date().getFullYear().toString(),
projectId: null
},
projectOptions: [],
detailVisible: false,
detailLoading: false,
evaluationDetail: {
projectId: "",
remark: "",
evaluated: false,
items: []
}
}
},
methods: {
// 按年度加载项目并默认选中第一项;无项目时清空列表。
getProjectList() {
this.$axios.post("/platform/welfare/common/list", {year: this.pageForm.year})
.then((res) => {
this.projectOptions = res.data || []
this.$set(this.pageForm, "projectId", this.projectOptions.length ? this.projectOptions[0].id : null)
this.doSearch()
})
},
// 分页数据按人员去重,逐项福利评分只在详情弹窗中纵向展示。
pageData() {
this.tableLoading = true
this.$axios.post("/platform/welfare/evaluation/manage/pageData", {
pageForm: JSON.stringify(this.pageForm)
}).then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
this.$nextTick(() => {
if (this.$refs.tableRef) {
this.$refs.tableRef.doLayout()
}
})
} else {
this.$message.error(res.msg)
}
}).finally(() => {
this.tableLoading = false
})
},
/**
* 查询指定人员的只读评价详情。
* @param row 评分列表人员行,必须包含projectId和userId
*/
openEvaluationDetail(row) {
this.$set(this, "detailVisible", true)
this.$set(this, "detailLoading", true)
this.$set(this, "evaluationDetail", {
projectId: row.projectId,
remark: "",
evaluated: false,
items: []
})
this.$axios.post("/platform/welfare/evaluation/manage/detail", {
projectId: row.projectId,
userId: row.userId
}).then((res) => {
if (res.code === 0 && res.data) {
this.$set(this, "evaluationDetail", res.data)
} else {
this.$message.error(res.msg || "评价详情加载失败")
}
}).finally(() => {
this.$set(this, "detailLoading", false)
})
},
// 导出按项目和人员去重,每个人员一行,项目福利选项动态生成评分列。
exportXlsx() {
if (!this.pageForm.projectId) {
this.$message.warning("请先选择福利项目")
return
}
this.$downLoad("/platform/welfare/evaluation/manage/exportXlsx", {
projectId: this.pageForm.projectId
})
}
},
created() {
this.getProjectList()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,41 @@
/* 福利列表页占满主内容区,禁止数据量增大时撑出整页纵向滚动条。 */
.welfare-full-height-page {
display: flex;
flex-direction: column;
height: calc(100vh - 84px);
min-height: 0;
overflow: hidden;
}
.welfare-full-height-page .welfare-search-card {
flex: 0 0 auto;
}
.welfare-full-height-page .welfare-list-card {
flex: 1 1 0;
min-height: 0;
}
/* 列表卡片内部使用纵向弹性布局,为表格计算工具栏和分页之外的剩余高度。 */
.welfare-full-height-page .welfare-list-card > .el-card__body {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
}
.welfare-full-height-page .welfare-full-height-table {
flex: 1 1 0;
min-height: 0;
}
/* 数据超出可视区域时,仅允许 Element UI 表格内容区域纵向滚动。 */
.welfare-full-height-page .welfare-full-height-table .el-table__body-wrapper {
overflow-y: auto;
}
.welfare-full-height-page .el-pagination-container {
flex: 0 0 auto;
}
@@ -2,10 +2,15 @@
layout("/layouts/platform.html"){
#-->
<style>
<!--#include("/platform/zhgh/welfare/include/fullHeightList.css"){}#-->
</style>
<div id="app" v-cloak>
<guava :edit_scroll="false" ref="guava">
<template>
<el-card shadow="never">
<div class="welfare-full-height-page">
<el-card class="welfare-search-card" shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
@@ -20,14 +25,15 @@ layout("/layouts/platform.html"){
</search>
</el-card>
<el-card class="mt10" shadow="never">
<el-card class="mt10 welfare-list-card" shadow="never">
<table-tool label="我的福利"></table-tool>
<el-table
:data="tableData"
:size="tableSize"
@sort-change="pageOrder"
class="vi-table"
class="vi-table welfare-full-height-table"
height="100%"
ref="table"
row-key="id"
style="width: 100%"
@@ -83,6 +89,7 @@ layout("/layouts/platform.html"){
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</div>
</template>
</guava>
@@ -2,10 +2,97 @@
layout("/layouts/platform.html"){
#-->
<style>
<!--#include("/platform/zhgh/welfare/include/fullHeightList.css"){}#-->
/* 福利选项分组工具栏:固定在表格右上方,保持开关与配置按钮横向排列。 */
/* 福利选项整体使用浅边框、圆角和低对比度阴影,形成柔和卡片层次。 */
.welfare-option .welfare-option-card {
overflow: hidden;
border: 1px solid #ebeef5;
border-radius: 6px;
background-color: #ffffff;
box-shadow: 0 4px 16px rgba(31, 45, 61, 0.08);
}
.welfare-option .welfare-option-card > .el-card__body {
padding: 0 14px 14px;
}
.welfare-option .welfare-option-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 40px;
min-height: 60px;
padding: 0 28px;
margin: 0 -14px;
box-sizing: border-box;
}
.welfare-option .group-required-control {
display: flex;
align-items: center;
gap: 14px;
white-space: nowrap;
}
/* 配置按钮采用设计稿中的浅蓝背景、蓝色边框和操作按钮高度。 */
.welfare-option .group-config-button {
height: 40px;
padding: 0 22px;
border-color: #8cc5ff;
background-color: #ecf5ff;
color: #409eff;
font-size: 14px;
font-weight: 500;
}
.welfare-option .group-config-button:hover,
.welfare-option .group-config-button:focus {
border-color: #409eff;
background-color: #d9ecff;
color: #409eff;
}
/* 分组必选关闭时保留配置按钮,并使用清晰的不可操作状态。 */
.welfare-option .group-config-button.is-disabled,
.welfare-option .group-config-button.is-disabled:hover,
.welfare-option .group-config-button.is-disabled:focus {
border-color: #dcdfe6;
background-color: #f5f7fa;
color: #c0c4cc;
cursor: not-allowed;
}
.welfare-option .welfare-option-table {
margin-top: 0;
overflow: hidden;
border-radius: 4px;
}
.welfare-option .welfare-option-footer {
padding-top: 10px;
}
/* 添加福利选项按钮使用适度圆角,不改变项目原有主色和交互状态。 */
.welfare-option .add-welfare-option-button {
border-radius: 6px;
}
@media (max-width: 900px) {
.welfare-option .welfare-option-toolbar {
gap: 18px;
padding: 0 16px;
}
}
</style>
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<div class="welfare-full-height-page">
<el-card class="welfare-search-card" shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
@@ -24,7 +111,7 @@ layout("/layouts/platform.html"){
</search>
</el-card>
<el-card class="mt10" shadow="never">
<el-card class="mt10 welfare-list-card" shadow="never">
<table-tool :app="this" label="福利项目">
<el-button @click="openAdd" icon="el-icon-plus" size="small" type="primary">新建福利项目</el-button>
</table-tool>
@@ -33,7 +120,8 @@ layout("/layouts/platform.html"){
:data="tableData"
:size="tableSize"
@sort-change="pageOrder"
class="vi-table"
class="vi-table welfare-full-height-table"
height="100%"
ref="table"
row-key="id"
style="width: 100%"
@@ -85,6 +173,7 @@ layout("/layouts/platform.html"){
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</div>
</template>
<template #edit>
@@ -1,7 +1,7 @@
const welfareOption = {
template: /*language=HTML*/ `
<div class="welfare-option">
<el-card shadow="never">
<el-card class="welfare-option-card" shadow="never">
<div class="welfare-option-toolbar">
<div class="group-required-control">
<span>分组必选</span>
@@ -11,16 +11,17 @@ const welfareOption = {
</el-switch>
</div>
<el-button
v-if="localGroupRequired"
class="group-config-button"
type="primary"
plain
icon="el-icon-setting"
:disabled="!localGroupRequired"
@click="openGroupConfigDialog">
配置分组选择数量
</el-button>
</div>
<el-table :data="welfareList" border style="width: 100%; margin-top: 20px;">
<el-table :data="welfareList" border class="welfare-option-table" style="width: 100%;">
<el-table-column align="center" header-align="center" label="福利名称" min-width="520">
<template slot-scope="{row}">
<el-input
@@ -85,7 +86,15 @@ const welfareOption = {
</template>
</el-table-column>
</el-table>
<el-button type="primary" size="small" @click="addOption">添加福利选项</el-button>
<div class="welfare-option-footer">
<el-button
class="add-welfare-option-button"
type="primary"
size="small"
@click="addOption">
添加福利选项
</el-button>
</div>
</el-card>
<el-dialog
@@ -246,7 +255,11 @@ const welfareOption = {
});
},
// 分组必选关闭时禁止进入配置流程,避免非鼠标方式误触发弹窗。
openGroupConfigDialog() {
if (!this.localGroupRequired) {
return;
}
this.groupConfigDialog.configs = this.buildCurrentGroupConfigs();
this.groupConfigDialog.visible = true;
},
@@ -347,65 +360,124 @@ const welfareOption = {
},
style: /*language=CSS*/ `
/deep/ .welfare-option {
/* 福利选项使用柔和卡片承载工具栏、表格和底部操作区。 */
.welfare-option .welfare-option-card {
overflow: hidden;
border: 1px solid #ebeef5;
border-radius: 6px;
background-color: #ffffff;
box-shadow: 0 4px 16px rgba(31, 45, 61, 0.08);
}
/deep/ .welfare-option .el-card__body{
padding: 0;
.welfare-option .welfare-option-card > .el-card__body {
padding: 0 14px 14px;
}
/deep/ .welfare-option .welfare-option-toolbar {
/* 分组配置工具栏固定在福利选项表格右上方,并与参考页面保持横向排列。 */
.welfare-option .welfare-option-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 28px;
min-height: 44px;
padding: 10px 16px 0;
gap: 40px;
min-height: 60px;
padding: 0 28px;
margin: 0 -14px;
box-sizing: border-box;
}
/deep/ .welfare-option .group-required-control {
.welfare-option .group-required-control {
display: flex;
align-items: center;
gap: 12px;
gap: 14px;
white-space: nowrap;
}
/deep/ .welfare-option .imgUrl .el-upload--picture-card i {
/* 配置按钮采用浅蓝底、蓝色描边,尺寸与设计稿中的操作按钮一致。 */
.welfare-option .group-config-button {
height: 40px;
padding: 0 22px;
border-color: #8cc5ff;
background-color: #ecf5ff;
color: #409eff;
font-size: 14px;
font-weight: 500;
}
.welfare-option .group-config-button:hover,
.welfare-option .group-config-button:focus {
border-color: #409eff;
background-color: #d9ecff;
color: #409eff;
}
/* 分组必选未开启时保留配置入口,但通过禁用态阻止打开配置弹窗。 */
.welfare-option .group-config-button.is-disabled,
.welfare-option .group-config-button.is-disabled:hover,
.welfare-option .group-config-button.is-disabled:focus {
border-color: #dcdfe6;
background-color: #f5f7fa;
color: #c0c4cc;
cursor: not-allowed;
}
.welfare-option .welfare-option-table {
margin-top: 0;
overflow: hidden;
border-radius: 4px;
}
.welfare-option .welfare-option-footer {
padding-top: 10px;
}
/* 添加按钮使用适度圆角,与卡片的柔和视觉保持一致。 */
.welfare-option .add-welfare-option-button {
border-radius: 6px;
}
.welfare-option .imgUrl .el-upload--picture-card i {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/deep/ .welfare-option .el-radio__label {
.welfare-option .el-radio__label {
padding-left: 0;
}
/deep/ .preview-image {
.preview-image {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
/deep/ .group-config-table .el-input-number {
.group-config-table .el-input-number {
width: 180px;
}
/deep/ .welfare-group-config-dialog {
.welfare-group-config-dialog {
display: flex;
flex-direction: column;
min-height: 560px;
}
/deep/ .welfare-group-config-dialog .el-dialog__body {
.welfare-group-config-dialog .el-dialog__body {
flex: 1;
}
/deep/ .group-config-empty {
.group-config-empty {
padding-top: 20px;
color: #909399;
text-align: center;
}
@media (max-width: 900px) {
.welfare-option .welfare-option-toolbar {
gap: 18px;
padding: 0 16px;
}
}
`
};
@@ -2,10 +2,61 @@
layout("/layouts/platform.html"){
#-->
<style>
<!--#include("/platform/zhgh/welfare/include/fullHeightList.css"){}#-->
/* 福利评价弹窗按效果图使用紧凑表格、柔和边框和固定底部操作区。 */
.welfare-evaluation-dialog {
border-radius: 6px;
}
.welfare-evaluation-dialog .el-dialog__body {
padding: 18px 24px 10px;
}
.welfare-evaluation-tip {
display: flex;
margin-bottom: 16px;
align-items: center;
color: #606266;
font-size: 14px;
}
.welfare-evaluation-tip i {
margin-right: 8px;
color: #409eff;
font-size: 18px;
}
.welfare-evaluation-rate {
display: flex;
align-items: center;
justify-content: center;
}
.welfare-evaluation-rate__text {
min-width: 70px;
margin-left: 12px;
color: #909399;
font-size: 12px;
}
.welfare-evaluation-remark {
margin-top: 18px;
}
.welfare-evaluation-remark__label {
margin-bottom: 10px;
color: #303133;
font-weight: 600;
}
</style>
<div id="app" v-cloak>
<guava :edit_scroll="false" ref="guava">
<template>
<el-card shadow="never">
<div class="welfare-full-height-page">
<el-card class="welfare-search-card" shadow="never">
<div class="search">
<div class="search-item">
<div class="search-item-label">年度:</div>
@@ -26,7 +77,7 @@ layout("/layouts/platform.html"){
</div>
</el-card>
<el-card class="mt10" shadow="never">
<el-card class="mt10 welfare-list-card" shadow="never">
<table-tool :app="this" label="福利项目">
<template #func></template>
</table-tool>
@@ -35,6 +86,8 @@ layout("/layouts/platform.html"){
:data="tableData"
:size="tableSize"
@sort-change="pageOrder"
class="welfare-full-height-table"
height="100%"
ref="table"
row-key="id"
style="width: 100%"
@@ -81,22 +134,81 @@ layout("/layouts/platform.html"){
<span v-if="row.receiveAddress">{{row.receiveAddress}}</span>
<span v-else>暂无</span>
</template>
<template scope="{row}" v-else-if="column.prop=='deliveryDate'">
<span v-if="row.deliveryDate">{{$moment(row.deliveryDate).format('YYYY-MM-DD')}}</span>
<span v-else>暂无</span>
</template>
<template scope="{row}" v-else-if="column.prop=='remark'">
<span v-if="row.remark">{{row.remark}}</span>
<span v-else>暂无</span>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作" prop="userOnline" width="150px">
<el-table-column align="center" fixed="right" header-align="center" label="操作" prop="userOnline" width="190px">
<template scope="{row}">
<el-button @click="openChoose(row)" size="mini" type="primary">{{confirmButtonInfo(row).text}}</el-button>
<el-button @click="openEvaluation(row)" size="mini" type="primary" plain v-if="canEvaluate(row)">评价</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</div>
</template>
<template #edit>
<option-select ref="optionSelectRef" @refresh="doSearch();$refs.guava.index();"></option-select>
</template>
</guava>
<el-dialog
:close-on-click-modal="false"
:visible.sync="evaluationDialogVisible"
custom-class="welfare-evaluation-dialog"
title="福利评价"
width="600px"
>
<div v-loading="evaluationLoading">
<div class="welfare-evaluation-tip">
<i class="el-icon-info"></i>
<span>请对您本次选择的福利进行评价,您的反馈将帮助我们做得更好!</span>
</div>
<el-table :data="evaluationForm.items" border max-height="320px" size="small" style="width: 100%">
<el-table-column align="center" label="福利名称" min-width="200px">
<template scope="{row}">{{row.optionName}}{{row.selectNum}}份)</template>
</el-table-column>
<el-table-column align="center" label="评价(5星制)" min-width="280px">
<template scope="{row}">
<div class="welfare-evaluation-rate">
<el-rate v-model="row.score"></el-rate>
<span class="welfare-evaluation-rate__text">
{{row.score ? row.score + "分" : "请点击评分"}}
</span>
</div>
</template>
</el-table-column>
</el-table>
<div class="welfare-evaluation-remark">
<div class="welfare-evaluation-remark__label">备注</div>
<el-input
v-model="evaluationForm.remark"
type="textarea"
:rows="5"
maxlength="200"
placeholder="请输入备注(选填)"
show-word-limit
></el-input>
</div>
</div>
<span slot="footer">
<el-button @click="evaluationDialogVisible=false">取消</el-button>
<el-button :loading="evaluationSubmitting" @click="submitEvaluation" type="primary">提交评价</el-button>
</span>
</el-dialog>
</div>
<script nonce="${cspNonce!}">
@@ -111,9 +223,19 @@ layout("/layouts/platform.html"){
{ prop: "name", label: "项目名称" },
{ prop: "choiceTime", label: "选择时间" },
{ prop: "isChoose", label: "是否选择" },
{ prop: "gist_list", label: "所选福利" }
{ prop: "gist_list", label: "所选福利" },
{ prop: "deliveryDate", label: "配送时间" },
{ prop: "remark", label: "备注" }
// { prop: "receiveAddress", label: "收货地址" }
]
],
evaluationDialogVisible: false,
evaluationLoading: false,
evaluationSubmitting: false,
evaluationForm: {
projectId: "",
remark: "",
items: []
}
}
},
components: {
@@ -164,6 +286,91 @@ layout("/layouts/platform.html"){
this.$refs.guava.edit(() => {
this.$refs.optionSelectRef.onOpen(row.id)
})
},
/**
* 配送日期到达后才显示评价入口;没有配送日期的历史数据不允许评价。
* @param row 福利项目列表行,deliveryDate 为 yyyy-MM-dd 日期
* @return {boolean} 当前日期达到配送日期时返回 true
*/
canEvaluate(row) {
if (!row.isChoose || !row.deliveryDate) {
return false
}
return this.$moment().startOf("day").valueOf() >= this.$moment(row.deliveryDate).startOf("day").valueOf()
},
/**
* 打开当前福利项目的评价弹窗,并回显每条福利选择已有的星级和统一备注。
* @param row 当前福利项目列表行
*/
openEvaluation(row) {
this.evaluationDialogVisible = true
this.evaluationLoading = true
this.evaluationForm = {
projectId: row.id,
remark: "",
items: []
}
this.$axios.post("/platform/welfare/evaluation/getEvaluation", {projectId: row.id})
.then((res) => {
if (res.code === 0 && res.data) {
this.evaluationForm = {
projectId: res.data.projectId,
remark: res.data.remark || "",
items: (res.data.items || []).map((item) => ({
selectionId: item.selectionId,
optionName: item.optionName,
selectNum: item.selectNum,
score: Number(item.score) || 0
}))
}
} else {
this.$message.error(res.msg || "评价信息加载失败")
}
})
.finally(() => {
this.evaluationLoading = false
})
},
/**
* 提交福利评价。每项福利必须选择1至5星,备注由后端统一写入各评价记录。
*/
submitEvaluation() {
if (!this.evaluationForm.items.length) {
this.$message.warning("当前项目暂无可评价的福利")
return
}
const unratedItem = this.evaluationForm.items.find((item) => item.score < 1 || item.score > 5)
if (unratedItem) {
this.$message.warning("请为“" + unratedItem.optionName + "”选择星级")
return
}
if (this.evaluationSubmitting) {
return
}
this.evaluationSubmitting = true
const evaluationForm = {
projectId: this.evaluationForm.projectId,
remark: this.evaluationForm.remark,
items: this.evaluationForm.items.map((item) => ({
selectionId: item.selectionId,
score: item.score
}))
}
this.$axios.post("/platform/welfare/evaluation/saveEvaluation", {
evaluationForm: JSON.stringify(evaluationForm)
}).then((res) => {
if (res.code === 0) {
this.$message.success("评价提交成功")
this.evaluationDialogVisible = false
} else {
this.$message.error(res.msg)
}
}).finally(() => {
this.evaluationSubmitting = false
})
}
},
async created() {
@@ -32,10 +32,6 @@ const optionSelect = {
<div class="info-label">项目名称</div>
<div class="info-value">{{ projectInfo.name }}</div>
</div>
<div class="info-item">
<div class="info-label">节日信息</div>
<div class="info-value">{{ projectInfo.festival }} · {{ projectInfo.year }}年</div>
</div>
<div class="info-item">
<div class="info-label">选择时间</div>
<div class="info-value">{{ formatTimeRange(projectInfo.choiceTimeStart,
@@ -48,10 +44,6 @@ const optionSelect = {
projectInfo.provideTimeEnd) }}
</div>
</div>
<div class="info-item">
<div class="info-label">发放地点</div>
<div class="info-value">{{ projectInfo.provideAddress }}</div>
</div>
</div>
</el-card>
@@ -141,7 +133,7 @@ const optionSelect = {
<div class="confirm-content">
<!-- 联系电话输入 -->
<div class="confirm-mobile-section">
<div class="confirm-section-title">联系信息</div>
<div class="confirm-section-title">{{ showReceivingContact ? '联系信息' : '选择信息' }}</div>
<el-form :model="contactForm" ref="contactForm" :rules="contactRules" label-width="80px">
<el-row :gutter="10">
<el-col :span="20" v-if="projectInfo.provideMode == 3">
@@ -161,7 +153,7 @@ const optionSelect = {
<el-col :span="4" v-if="projectInfo.provideMode == 3">
<el-button @click="openAddress" type="primary" size="small">添加地址</el-button>
</el-col>
<el-col :span="24" v-if="projectInfo.provideMode == 3">
<el-col :span="24" v-if="showReceivingContact">
<el-form-item prop="userName" label="收货人">
<el-input
v-model="contactForm.userName"
@@ -170,7 +162,7 @@ const optionSelect = {
</el-input>
</el-form-item>
</el-col>
<el-col :span="24">
<el-col :span="24" v-if="showReceivingContact">
<el-form-item prop="mobile" label="联系电话">
<el-input
v-model="contactForm.mobile"
@@ -180,6 +172,31 @@ const optionSelect = {
</el-input>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item prop="deliveryDate" label="配送时间">
<el-date-picker
v-model="contactForm.deliveryDate"
type="date"
value-format="yyyy-MM-dd"
format="yyyy-MM-dd"
placeholder="请选择配送时间"
:picker-options="deliveryDatePickerOptions"
style="width: 100%">
</el-date-picker>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item prop="remark" label="备注">
<el-input
v-model="contactForm.remark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入备注(选填)">
</el-input>
</el-form-item>
</el-col>
<el-col :span="24" v-if="projectInfo.signMode === 2">
<el-form-item prop="sign" label="签字">
<pc-signature v-model="contactForm.userSign"></pc-signature>
@@ -267,7 +284,9 @@ const optionSelect = {
mobile: "", // 联系电话
receiveAddress: "",
userName: "", // 收货人
userSign: ""
userSign: "",
deliveryDate: "", // 配送日期,按 yyyy-MM-dd 提交
remark: "" // 用户自选和管理员代选共用的选择备注
},
contactRules: {
userName: [
@@ -280,8 +299,15 @@ const optionSelect = {
message: "请输入正确的手机号码",
trigger: "blur"
}
],
deliveryDate: [
{required: true, message: "请选择配送时间", trigger: "change"}
]
},
deliveryDatePickerOptions: {
// 配送日期按天校验,选择当天允许提交。
disabledDate: (time) => time.getTime() < new Date().setHours(0, 0, 0, 0)
},
addressOptions: []
}
},
@@ -322,6 +348,11 @@ const optionSelect = {
return this.projectInfo.options.reduce((sum, option) => sum + (option.selectNum || 0), 0)
},
// 意向选择不采集收货人、联系电话和收货地址,其他发放形式保留收货信息。
showReceivingContact() {
return Number(this.projectInfo.provideMode) !== 2
},
// 截止时间快了
isDeadlineSoon() {
if (!this.projectInfo.choiceTimeEnd) return false
@@ -415,7 +446,9 @@ const optionSelect = {
mobile: "",
receiveAddress: "",
userName: "",
userSign: ""
userSign: "",
deliveryDate: "",
remark: ""
}
this.getProjectInfo()
@@ -466,15 +499,26 @@ const optionSelect = {
this.hasSubmittedBefore = this.userSelection.length > 0
if (this.userSelection && this.userSelection.length > 0) {
this.contactForm.mobile = this.userSelection[0]?.mobile
const firstSelection = this.userSelection[0]
const currentUser = this.$store.state.user || {}
// 本人选择按字段回退到登录人信息;代选只使用目标人员已有选择数据。
this.$set(this.contactForm, "mobile", firstSelection.mobile || (!this.isProxySelect ? currentUser.mobile || "" : ""))
this.$set(this.contactForm, "userName", firstSelection.userName || (!this.isProxySelect ? currentUser.username || "" : ""))
this.$set(this.contactForm, "deliveryDate", this.userSelection[0].deliveryDate
? this.$moment(this.userSelection[0].deliveryDate).format("YYYY-MM-DD") : "")
this.$set(this.contactForm, "remark", this.userSelection[0].remark || "")
} else {
if (this.isProxySelect) {
// 否则使用默认手机号
$.post("/platform/welfare/selection/situation/getMobileByUserId", {userId: this.userId}).then((res) => {
if (res.code === 0) {
this.contactForm.mobile = res.data
this.$set(this.contactForm, "mobile", res.data || "")
}
})
} else {
const currentUser = this.$store.state.user || {}
this.$set(this.contactForm, "userName", currentUser.username || "")
this.$set(this.contactForm, "mobile", currentUser.mobile || "")
}
// 首次确认选择且没有历史记录时,尝试自动带出默认收货地址。
if (this.projectInfo.provideMode === 3) {
@@ -488,8 +532,8 @@ const optionSelect = {
this.contactForm.receiveAddress = this.userSelection[0].receiveAddress
// this.contactForm.userName = this.userSelection[0].userName;
// this.contactForm.mobile = this.userSelection[0].mobile;
this.$set(this.contactForm, "userName", this.userSelection[0].userName)
this.$set(this.contactForm, "mobile", this.userSelection[0].mobile)
this.$set(this.contactForm, "userName", this.userSelection[0].userName || this.contactForm.userName)
this.$set(this.contactForm, "mobile", this.userSelection[0].mobile || this.contactForm.mobile)
}
}
@@ -524,8 +568,56 @@ const optionSelect = {
})
},
/**
* 按分组累计已选福利的份数,并校验项目配置的分组最小、最大选择数量。
* 最大选择数量为 0 时表示不限制;未配置分组的福利不参与分组数量校验。
*
* @return {string} 校验通过返回空字符串,否则返回具体的分组限制提示
*/
getGroupSelectionQuantityValidationMessage() {
if (!this.projectInfo.groupRequired) {
return ""
}
const configs = this.projectInfo.groupSelectionConfigs || []
const groupQuantityMap = {}
this.selectedOptions.forEach((option) => {
const groupName = (option.groupName || "").trim()
if (!groupName) {
return
}
// 分组限制按福利份数统计,不按已选择的福利项数量统计。
groupQuantityMap[groupName] = (groupQuantityMap[groupName] || 0) + (Number(option.selectNum) || 0)
})
for (let index = 0; index < configs.length; index++) {
const config = configs[index]
if (!config || !config.groupName) {
continue
}
const groupName = config.groupName.trim()
if (!groupName) {
continue
}
const selectedQuantity = groupQuantityMap[groupName] || 0
const minSelectNum = config.minSelectNum == null ? 1 : Number(config.minSelectNum)
const maxSelectNum = config.maxSelectNum == null ? 0 : Number(config.maxSelectNum)
if (selectedQuantity < minSelectNum) {
return "分组“" + groupName + "”至少需要选择" + minSelectNum + "份,当前已选择" + selectedQuantity + "份"
}
if (maxSelectNum > 0 && selectedQuantity > maxSelectNum) {
return "分组“" + groupName + "”最多可选择" + maxSelectNum + "份,当前已选择" + selectedQuantity + "份"
}
}
return ""
},
// 执行提交
doSubmit() {
// 提交前再次校验,防止确认弹窗打开后用户修改选择数量绕过前端校验。
const groupValidationMessage = this.getGroupSelectionQuantityValidationMessage()
if (groupValidationMessage) {
this.$message.error(groupValidationMessage)
return
}
// 验证手机号
this.$refs.contactForm.validate((valid) => {
if (!valid) {
@@ -544,10 +636,12 @@ const optionSelect = {
{
selectOptionId: this.selectedRadioId,
selectNum: 1,
mobile: this.contactForm.mobile,
userName: this.contactForm.userName,
mobile: this.showReceivingContact ? this.contactForm.mobile : "",
userName: this.showReceivingContact ? this.contactForm.userName : "",
userSign: this.contactForm.userSign,
receiveAddress: this.contactForm.receiveAddress
receiveAddress: this.showReceivingContact ? this.contactForm.receiveAddress : "",
deliveryDate: this.contactForm.deliveryDate,
remark: this.contactForm.remark
}
]
}
@@ -557,10 +651,12 @@ const optionSelect = {
selections = this.selectedOptions.map((option) => ({
selectOptionId: option.id,
selectNum: option.selectNum,
mobile: this.contactForm.mobile,
userName: this.contactForm.userName,
mobile: this.showReceivingContact ? this.contactForm.mobile : "",
userName: this.showReceivingContact ? this.contactForm.userName : "",
userSign: this.contactForm.userSign,
receiveAddress: this.contactForm.receiveAddress
receiveAddress: this.showReceivingContact ? this.contactForm.receiveAddress : "",
deliveryDate: this.contactForm.deliveryDate,
remark: this.contactForm.remark
}))
}
@@ -577,8 +673,6 @@ const optionSelect = {
this.$axios
.post(url, formData)
.then((res) => {
this.isSubmitting = false
if (res.code === 0) {
this.showConfirmDialog = false
this.$message.success("选择成功")
@@ -588,9 +682,11 @@ const optionSelect = {
}
})
.catch(() => {
this.isSubmitting = false
this.$message.error("网络错误,请重试")
})
.finally(() => {
this.isSubmitting = false
})
})
},
@@ -602,9 +698,9 @@ const optionSelect = {
const endDate = this.$moment(end).format("YYYY-MM-DD")
if (startDate === endDate) {
return startDate + " " + this.$moment(start).format("HH:mm") + "~" + this.$moment(end).format("HH:mm")
return startDate + " " + this.$moment(start).format("HH:mm:ss") + "~" + this.$moment(end).format("HH:mm:ss")
} else {
return startDate + " ~ " + endDate
return this.$moment(start).format("YYYY-MM-DD HH:mm:ss") + " ~ " + this.$moment(end).format("YYYY-MM-DD HH:mm:ss")
}
},
@@ -615,6 +711,12 @@ const optionSelect = {
return
}
const groupValidationMessage = this.getGroupSelectionQuantityValidationMessage()
if (groupValidationMessage) {
this.$message.error(groupValidationMessage)
return
}
this.showConfirmDialog = true
},
@@ -2,9 +2,14 @@
layout("/layouts/platform.html"){
#-->
<style>
<!--#include("/platform/zhgh/welfare/include/fullHeightList.css"){}#-->
</style>
<div id="app" v-cloak>
<guava>
<el-card shadow="never">
<guava :edit_scroll="false">
<div class="welfare-full-height-page">
<el-card class="welfare-search-card" shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
@@ -72,7 +77,7 @@ layout("/layouts/platform.html"){
</search>
</el-card>
<el-card class="mt10" shadow="never">
<el-card class="mt10 welfare-list-card" shadow="never">
<table-tool :app="this" label="选择情况">
<el-radio-group v-model="pageForm.isSelect" size="small" @change="doSearch">
<el-radio-button :label="null">全部</el-radio-button>
@@ -80,6 +85,10 @@ layout("/layouts/platform.html"){
<el-radio-button :label="false">未选择</el-radio-button>
</el-radio-group>
<el-button type="primary" size="small" icon="el-icon-message" style="margin-left: 10px" @click="openMessageDialog">
消息发送
</el-button>
<el-button type="primary" size="small" icon="el-icon-download" style="margin-left: 10px" @click="exportXlsx">
导出选择情况表
</el-button>
@@ -93,6 +102,8 @@ layout("/layouts/platform.html"){
:data="tableData"
:size="tableSize"
@sort-change="pageOrder"
class="welfare-full-height-table"
height="100%"
ref="tableRef"
row-key="id"
style="width: 100%"
@@ -122,10 +133,35 @@ layout("/layouts/platform.html"){
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</div>
<el-dialog title="代选" :visible.sync="optionSelectVisible" width="60%">
<option-select ref="optionSelectRef" @refresh="optionSelectVisible=false;doSearch();"></option-select>
</el-dialog>
<el-dialog
title="消息发送"
:visible.sync="messageDialog.visible"
:before-close="closeMessageDialog"
:close-on-click-modal="false"
append-to-body
width="60%">
<el-form ref="messageForm" :model="messageDialog" :rules="messageRules" label-width="90px">
<el-form-item label="消息内容" prop="messageContent">
<el-input
v-model="messageDialog.messageContent"
type="textarea"
:rows="10"
resize="vertical"
placeholder="请输入消息内容">
</el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="closeMessageDialog">取消</el-button>
<el-button type="primary" :loading="messageSending" @click="sendMessage">确认</el-button>
</span>
</el-dialog>
</guava>
</div>
@@ -156,9 +192,18 @@ layout("/layouts/platform.html"){
{ prop: "welfareUnionName", label: "所属工会", sortable: true },
{ prop: "welfareUnitName", label: "所属单位", sortable: true },
{ prop: "selectedOptions", label: "所选福利", sortable: true },
{ prop: "deliveryDate", label: "配送时间", sortable: true },
{ prop: "mobile", label: "联系电话", sortable: true }
],
optionSelectVisible: false
optionSelectVisible: false,
messageSending: false,
messageDialog: {
visible: false,
messageContent: ""
},
messageRules: {
messageContent: [{ required: true, message: "请输入消息内容", trigger: ["blur", "change"] }]
}
}
},
computed: {
@@ -195,6 +240,12 @@ layout("/layouts/platform.html"){
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
// 状态切换会替换表格数据,完成渲染后重新计算内部滚动区域。
this.$nextTick(() => {
if (this.$refs.tableRef) {
this.$refs.tableRef.doLayout()
}
})
}
})
.finally(() => {
@@ -212,6 +263,55 @@ layout("/layouts/platform.html"){
this.$downLoad("/platform/welfare/selection/situation/receiveXlsx", { pageForm: JSON.stringify(this.pageForm) })
},
// 打开发送窗口时清空上次编辑内容,实际接收人由后台按当前查询条件重新计算。
openMessageDialog() {
if (!this.pageForm.projectId) {
this.$message.warning("请先选择福利项目")
return
}
this.$set(this.messageDialog, "messageContent", "")
this.$set(this.messageDialog, "visible", true)
this.$nextTick(() => {
if (this.$refs.messageForm) {
this.$refs.messageForm.clearValidate()
}
})
},
closeMessageDialog(done) {
this.$set(this.messageDialog, "visible", false)
if (typeof done === "function") {
done()
}
},
sendMessage() {
this.$refs.messageForm.validate((valid) => {
if (!valid) {
return
}
const messageContent = (this.messageDialog.messageContent || "").trim()
if (!messageContent) {
this.$message.warning("请输入有效的消息内容")
return
}
this.messageSending = true
this.$axios.post("/platform/welfare/selection/situation/sendMessage", {
pageForm: JSON.stringify(this.pageForm),
messageContent: messageContent
}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.closeMessageDialog()
} else {
this.$message.error(res.msg)
}
}).finally(() => {
this.messageSending = false
})
})
},
// 管理员待选
proxySelect(row) {
this.optionSelectVisible = true
@@ -2,10 +2,15 @@
layout("/layouts/platform.html"){
#-->
<style>
<!--#include("/platform/zhgh/welfare/include/fullHeightList.css"){}#-->
</style>
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<div class="welfare-full-height-page">
<el-card class="welfare-search-card" shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
@@ -141,7 +146,7 @@ layout("/layouts/platform.html"){
</search>
</el-card>
<el-card class="mt10" shadow="never">
<el-card class="mt10 welfare-list-card" shadow="never">
<table-tool :app="this" label="福利名单">
<el-button :disabled="!pageForm.projectId" :loading="batchDeleteLoading" @click="deleteSearchUsers" icon="el-icon-delete" size="small" type="danger">
删除人员
@@ -170,6 +175,8 @@ layout("/layouts/platform.html"){
:data="tableData"
:size="tableSize"
@sort-change="pageOrder"
class="welfare-full-height-table"
height="100%"
ref="tableRef"
row-key="id"
style="width: 100%"
@@ -207,6 +214,7 @@ layout("/layouts/platform.html"){
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</div>
</template>
</guava>
@@ -717,6 +717,162 @@ layout("/layouts/platform_h5.html"){
font-size: 12px;
line-height: 18px;
}
/* 评价按钮使用蓝色描边胶囊造型,并通过实心星形图标突出评价入口。 */
.welfare-list-card__evaluation-button {
display: inline-flex;
height: 34px;
padding: 0 13px;
flex: none;
align-items: center;
justify-content: center;
border: 1px solid #1989fa;
border-radius: 17px;
color: #1989fa;
background-color: #fff;
font-size: 14px;
line-height: 32px;
box-sizing: border-box;
}
.welfare-list-card__evaluation-button .van-icon {
margin-right: 6px;
font-size: 17px;
}
/* 移动端福利评价使用底部圆角弹层,内容过高时仅在弹层内部滚动。 */
.welfare-evaluation-sheet {
max-height: 88vh;
padding: 16px 16px 14px;
overflow-y: auto;
box-sizing: border-box;
}
.welfare-evaluation-sheet__handle {
width: 44px;
height: 4px;
margin: -8px auto 8px;
border-radius: 2px;
background-color: #e5e7eb;
}
.welfare-evaluation-sheet__title {
padding-right: 34px;
color: #1f2d3d;
font-size: 19px;
line-height: 26px;
font-weight: 600;
}
.welfare-evaluation-sheet__tip {
display: flex;
margin: 10px 0;
align-items: flex-start;
color: #46566d;
font-size: 13px;
line-height: 18px;
}
.welfare-evaluation-sheet__tip .van-icon {
margin: 1px 7px 0 0;
flex: none;
color: #1989fa;
font-size: 19px;
}
.welfare-evaluation-sheet__loading,
.welfare-evaluation-sheet__empty {
padding: 44px 0;
color: #98a5b6;
font-size: 13px;
text-align: center;
}
.welfare-evaluation-table {
width: 100%;
border-spacing: 0;
border-collapse: separate;
overflow: hidden;
border: 1px solid #e1e8f0;
border-radius: 9px;
color: #4c5d73;
table-layout: fixed;
}
.welfare-evaluation-table th,
.welfare-evaluation-table td {
height: 48px;
padding: 6px;
border-right: 1px solid #e1e8f0;
border-bottom: 1px solid #e1e8f0;
box-sizing: border-box;
text-align: center;
}
.welfare-evaluation-table th {
height: 40px;
color: #263548;
background-color: #fbfcfe;
font-size: 14px;
font-weight: 600;
}
.welfare-evaluation-table th:first-child,
.welfare-evaluation-table td:first-child {
width: 40%;
}
.welfare-evaluation-table th:last-child,
.welfare-evaluation-table td:last-child {
border-right: 0;
}
.welfare-evaluation-table tr:last-child td {
border-bottom: 0;
}
.welfare-evaluation-table__name {
font-size: 13px;
line-height: 20px;
word-break: break-word;
}
.welfare-evaluation-table .van-rate {
display: flex;
justify-content: center;
}
.welfare-evaluation-sheet__remark-title {
margin: 16px 0 8px;
color: #263548;
font-size: 16px;
line-height: 24px;
font-weight: 600;
}
.welfare-evaluation-sheet__remark {
overflow: hidden;
border: 1px solid #e1e8f0;
border-radius: 8px;
}
.welfare-evaluation-sheet__remark .van-field__control {
min-height: 80px;
}
.welfare-evaluation-sheet__footer {
display: flex;
margin-top: 12px;
gap: 10px;
}
.welfare-evaluation-sheet__footer .van-button {
height: 40px;
flex: 1;
border-radius: 7px;
font-size: 15px;
font-weight: 600;
}
</style>
<div id="app" v-cloak>
@@ -791,6 +947,10 @@ layout("/layouts/platform_h5.html"){
<span>选择时间:{{ formatDate(row.selectTime) }}</span>
</div>
</div>
<button class="welfare-list-card__evaluation-button" type="button" @click.stop="openEvaluation(row)" v-if="canEvaluate(row)">
<van-icon name="star"></van-icon>
<span>去评价</span>
</button>
</div>
<div class="welfare-list-card__choices-wrap">
@@ -883,6 +1043,68 @@ layout("/layouts/platform_h5.html"){
<div class="welfare-list-empty__hint">当前年度暂未发布福利项目</div>
</div>
</div>
<van-popup
v-model="evaluationVisible"
class="welfare-evaluation-sheet"
position="bottom"
round
closeable
close-icon="cross"
@close="handleHistoryLayerComponentClose('evaluation')"
>
<div class="welfare-evaluation-sheet__handle"></div>
<div class="welfare-evaluation-sheet__title">福利评价</div>
<div class="welfare-evaluation-sheet__tip">
<van-icon name="info-o"></van-icon>
<span>请对您本次选择的福利进行评价,您的反馈将帮助我们做得更好!</span>
</div>
<div class="welfare-evaluation-sheet__loading" v-if="evaluationLoading">
<van-loading color="#1989fa" size="22px">加载中...</van-loading>
</div>
<template v-else-if="evaluationForm.items.length > 0">
<table class="welfare-evaluation-table">
<thead>
<tr>
<th>福利名称</th>
<th>评价(5星制)</th>
</tr>
</thead>
<tbody>
<tr v-for="item in evaluationForm.items" :key="item.selectionId">
<td class="welfare-evaluation-table__name">{{item.optionName}}{{item.selectNum}}份)</td>
<td>
<van-rate
v-model="item.score"
:size="22"
:gutter="4"
color="#ffb320"
void-color="#d5dde8"
></van-rate>
</td>
</tr>
</tbody>
</table>
<div class="welfare-evaluation-sheet__remark-title">备注</div>
<van-field
v-model="evaluationForm.remark"
class="welfare-evaluation-sheet__remark"
type="textarea"
rows="3"
maxlength="200"
placeholder="请输入备注(选填)"
show-word-limit
></van-field>
<div class="welfare-evaluation-sheet__footer">
<van-button plain type="default" @click="closeHistoryLayer('evaluation')">取消</van-button>
<van-button :loading="evaluationSubmitting" type="info" @click="submitEvaluation">提交评价</van-button>
</div>
</template>
<div class="welfare-evaluation-sheet__empty" v-else>当前项目暂无可评价的福利</div>
</van-popup>
</div>
</div>
@@ -896,6 +1118,16 @@ layout("/layouts/platform_h5.html"){
finished: false,
loading: false,
filterOpened: false,
evaluationVisible: false,
evaluationLoading: false,
evaluationSubmitting: false,
evaluationRow: null,
historyLayerPageKey: "welfare-mine-list",
evaluationForm: {
projectId: "",
remark: "",
items: []
},
pageForm: {
pageNumber: 1,
pageSize: 5,
@@ -907,7 +1139,82 @@ layout("/layouts/platform_h5.html"){
}
},
methods: {
/**
* 注册“我的福利”页面弹层历史。
* historyLayerPageKey 为当前页面稳定标识;回调参数 stack 为按打开顺序排列的弹层名称数组;无返回值。
*/
registerHistoryLayers() {
if (!window.h5HistoryLayerManager) return
window.h5HistoryLayerManager.register(this.historyLayerPageKey, (stack) => {
this.syncHistoryLayerStack(stack)
})
},
/**
* 根据全局历史栈同步评价弹层状态。
* stack 应传全局管理器当前弹层名称数组;数组包含 evaluation 时显示评价弹层,否则关闭;无返回值。
*/
syncHistoryLayerStack(stack) {
this.$set(this, "evaluationVisible", stack.includes("evaluation"))
},
/**
* 打开指定弹层并同步增加浏览器历史记录。
* layerName 传弹层唯一名称;全局管理器不可用时降级为直接显示;无返回值。
*/
openHistoryLayer(layerName) {
if (window.h5HistoryLayerManager && window.h5HistoryLayerManager.open(layerName)) return
this.setHistoryLayerVisible(layerName, true)
},
/**
* 关闭指定栈顶弹层并回退对应浏览器历史。
* layerName 传待关闭的弹层名称,afterClose 为历史同步完成后的可选回调;返回值由页面忽略。
*/
closeHistoryLayer(layerName, afterClose = null) {
if (window.h5HistoryLayerManager && window.h5HistoryLayerManager.close(layerName, afterClose)) return
this.setHistoryLayerVisible(layerName, false)
if (typeof afterClose === "function") afterClose()
},
/**
* 清理当前页面的全部弹层历史。
* afterClose 为全部历史记录回退完成后的可选回调;全局管理器不可用时直接同步为空栈;返回值由页面忽略。
*/
closeAllHistoryLayers(afterClose = null) {
if (window.h5HistoryLayerManager && window.h5HistoryLayerManager.closeAll(afterClose)) return
this.syncHistoryLayerStack([])
if (typeof afterClose === "function") afterClose()
},
/**
* 处理 Vant 关闭图标或遮罩触发的关闭事件。
* 仅当 layerName 仍位于全局栈顶时回退历史,避免 popstate 同步关闭时发生二次返回。
*/
handleHistoryLayerComponentClose(layerName) {
if (!window.h5HistoryLayerManager) return
const stack = window.h5HistoryLayerManager.stack
if (stack[stack.length - 1] === layerName) {
window.h5HistoryLayerManager.close(layerName)
}
},
/**
* 全局历史管理器不可用时同步本页弹层状态。
* layerName 传弹层名称,visible 传目标显示状态;无返回值。
*/
setHistoryLayerVisible(layerName, visible) {
if (layerName === "evaluation") {
this.$set(this, "evaluationVisible", visible)
}
},
// NavBar 返回时优先关闭最上层弹框,没有弹框时保持原有返回首页逻辑。
goBack() {
if (window.h5HistoryLayerManager && window.h5HistoryLayerManager.stack.length) {
window.h5HistoryLayerManager.close()
return
}
this.$pjaxReplace("/platform/h5/home")
},
doSearch() {
@@ -1043,6 +1350,18 @@ layout("/layouts/platform_h5.html"){
return dateTime ? this.$moment(dateTime).format("YYYY-MM-DD HH:mm") : "时间未知"
},
/**
* 配送日期到达后才显示移动端评价入口;历史记录未设置配送日期时保持隐藏。
* @param row 我的福利列表行,deliveryDate 为 yyyy-MM-dd 日期
* @return {boolean} 当前日期达到配送日期时返回 true
*/
canEvaluate(row) {
if (!row || !row.deliveryDate) {
return false
}
return this.$moment().startOf("day").valueOf() >= this.$moment(row.deliveryDate).startOf("day").valueOf()
},
/**
* 根据福利项目选择模式展示与福利列表一致的选择数量标签。
* @param row 福利项目数据
@@ -1055,9 +1374,95 @@ layout("/layouts/platform_h5.html"){
return "单选"
},
/**
* 打开福利评价底部弹层,并加载当前项目全部已选福利的评分回显数据。
* @param row 当前福利项目卡片
*/
openEvaluation(row) {
const projectId = row.projectId || row.id
this.$set(this, "evaluationRow", row)
this.openHistoryLayer("evaluation")
this.$set(this, "evaluationLoading", true)
this.$set(this, "evaluationForm", {
projectId: projectId,
remark: "",
items: []
})
this.$axios.post("/platform/welfare/evaluation/getEvaluation", {projectId: projectId})
.then((res) => {
if (res.code === 0 && res.data) {
this.$set(this, "evaluationForm", {
projectId: res.data.projectId,
remark: res.data.remark || "",
items: (res.data.items || []).map((item) => ({
selectionId: item.selectionId,
optionName: item.optionName,
selectNum: item.selectNum,
score: Number(item.score) || 0
}))
})
} else {
this.$toast.fail(res.msg || "评价信息加载失败")
}
})
.finally(() => {
this.$set(this, "evaluationLoading", false)
})
},
/**
* 提交当前项目的逐项星级评价,每项福利必须选择1至5星。
*/
submitEvaluation() {
if (!this.evaluationForm.items.length) {
this.$toast.fail("当前项目暂无可评价的福利")
return
}
const unratedItem = this.evaluationForm.items.find((item) => item.score < 1 || item.score > 5)
if (unratedItem) {
this.$toast.fail("请为“" + unratedItem.optionName + "”选择星级")
return
}
if (this.evaluationSubmitting) {
return
}
this.$set(this, "evaluationSubmitting", true)
const evaluationForm = {
projectId: this.evaluationForm.projectId,
remark: this.evaluationForm.remark,
items: this.evaluationForm.items.map((item) => ({
selectionId: item.selectionId,
score: item.score
}))
}
this.$axios.post("/platform/welfare/evaluation/saveEvaluation", {
evaluationForm: JSON.stringify(evaluationForm)
}).then((res) => {
if (res.code === 0) {
this.$toast.success("评价提交成功")
if (this.evaluationRow) {
this.$set(this.evaluationRow, "evaluated", true)
}
// 提交成功后清理本页全部弹层历史,避免再次返回时重新出现已关闭弹层。
this.closeAllHistoryLayers()
} else {
this.$toast.fail(res.msg)
}
}).finally(() => {
this.$set(this, "evaluationSubmitting", false)
})
},
},
created() {
this.registerHistoryLayers()
this.pageData()
},
beforeDestroy() {
// PJAX 切换页面前注销当前页面弹层历史,避免旧回调和栈状态影响后续页面。
if (window.h5HistoryLayerManager) {
window.h5HistoryLayerManager.unregister(this.historyLayerPageKey)
}
}
})
</script>
@@ -880,7 +880,7 @@ layout("/layouts/platform_h5.html"){
<div class="confirm-card">
<div class="confirm-card-header">
<div class="confirm-card-title">
<span>收货信息</span>
<span>{{ showReceivingContact ? '收货信息' : '选择信息' }}</span>
</div>
</div>
@@ -900,6 +900,7 @@ layout("/layouts/platform_h5.html"){
<div class="confirm-receipt-fields">
<van-field
v-if="showReceivingContact"
v-model="formData.userName"
label="收货人"
placeholder="请输入收货人"
@@ -908,6 +909,7 @@ layout("/layouts/platform_h5.html"){
required
></van-field>
<van-field
v-if="showReceivingContact"
v-model="formData.mobile"
label="联系电话"
placeholder="请输入手机号码"
@@ -916,6 +918,26 @@ layout("/layouts/platform_h5.html"){
maxlength="11"
required
></van-field>
<van-field
v-model="formData.deliveryDate"
label="配送时间"
placeholder="请选择配送时间"
readonly
clickable
is-link
@click="openDeliveryCalendar"
required
></van-field>
<van-field
v-model="formData.remark"
label="备注"
type="textarea"
rows="2"
maxlength="200"
show-word-limit
autosize
placeholder="请输入备注(选填)"
></van-field>
</div>
</div>
@@ -968,6 +990,20 @@ layout("/layouts/platform_h5.html"){
</div>
</van-popup>
<!-- 配送日期使用 Vant 日历快捷选择,点击日期后立即确认;弹层同步接入全局历史栈。 -->
<van-calendar
v-model="showDeliveryCalendar"
title="选择配送时间"
type="single"
color="#1989fa"
:show-confirm="false"
:close-on-click-overlay="false"
:min-date="deliveryCalendarMinDate"
:default-date="deliveryCalendarDefaultDate"
@confirm="confirmDeliveryDate"
@close="handleHistoryLayerComponentClose('delivery-calendar')">
</van-calendar>
<!-- 选项详情弹窗 -->
<van-action-sheet v-model="showOptionDetailDialog" class="welfare-detail-sheet"
:style="{ height: '78%' }" :close-on-click-overlay="true"
@@ -1015,6 +1051,8 @@ layout("/layouts/platform_h5.html"){
projectId: "",
selectedRadioId: "", // 单选模式下选中的选项ID
showConfirmDialog: false,
showDeliveryCalendar: false, // 配送日期日历弹层
deliveryCalendarDefaultDate: new Date(), // 日历打开时默认定位的日期
isSubmitting: false,
hasSubmittedBefore: false, // 是否之前提交过
showOptionDetailDialog: false, // 选项详情弹窗
@@ -1025,7 +1063,10 @@ layout("/layouts/platform_h5.html"){
userSign: "", // 用户签名
mobile: "", // 手机号码
address: "",// 收货地址
receiveAddress: "", // 确认后的完整收货地址
userName: "",//收货人
deliveryDate: "", // 配送日期,按 yyyy-MM-dd 提交
remark: "" // 用户选择备注
},
showAddressPopup: false, // 收货地址全屏 Popup
@@ -1036,6 +1077,11 @@ layout("/layouts/platform_h5.html"){
computed: {
// Vant 日历最小可选日期为当天零点,允许用户选择当天配送。
deliveryCalendarMinDate() {
return this.$moment().startOf("day").toDate()
},
// 系统默认福利必须被保留,管理端限制每个项目最多配置一个。
systemDefaultOption() {
if (!this.projectInfo.options) return null
@@ -1096,6 +1142,11 @@ layout("/layouts/platform_h5.html"){
return Math.max(0, this.selectionLimit - this.selectedCount)
},
// 意向选择不采集收货人、联系电话和收货地址,配送时间及备注仍按原业务要求保留。
showReceivingContact() {
return Number(this.projectInfo.provideMode) !== 2
},
isDeadlineSoon() {
if (!this.projectInfo.choiceTimeEnd) return false
@@ -1123,6 +1174,7 @@ layout("/layouts/platform_h5.html"){
// 根据浏览器历史中的弹层栈统一显示或关闭弹框,保证返回键与页面状态一致。
syncHistoryLayerStack(stack) {
this.showConfirmDialog = stack.includes("confirm")
this.$set(this, "showDeliveryCalendar", stack.includes("delivery-calendar"))
this.showAddressPopup = stack.includes("address")
this.showOptionDetailDialog = stack.includes("detail")
},
@@ -1160,10 +1212,36 @@ layout("/layouts/platform_h5.html"){
setHistoryLayerVisible(layerName, visible) {
if (layerName === "confirm") this.showConfirmDialog = visible
if (layerName === "delivery-calendar") this.$set(this, "showDeliveryCalendar", visible)
if (layerName === "address") this.showAddressPopup = visible
if (layerName === "detail") this.showOptionDetailDialog = visible
},
/**
* 打开配送日期日历。已有 deliveryDate 时定位到该日期,否则默认定位当天;无返回值。
* 日历作为确认选择弹层的下一层历史记录,系统返回键只关闭日历。
*/
openDeliveryCalendar() {
const selectedDate = this.formData.deliveryDate
? this.$moment(this.formData.deliveryDate, "YYYY-MM-DD").toDate()
: new Date()
// 历史配送日期早于今天时定位到今天,避免默认日期超出日历可选范围。
const defaultDate = this.$moment(selectedDate).startOf("day").valueOf()
< this.$moment().startOf("day").valueOf() ? new Date() : selectedDate
this.$set(this, "deliveryCalendarDefaultDate", defaultDate)
this.openHistoryLayer("delivery-calendar")
},
/**
* 快捷选中配送日期。date 为 van-calendar 返回的 Date,写入 yyyy-MM-dd 字符串后同步回退日历历史;无返回值。
*
* @param date Vant 日历当前选中的日期
*/
confirmDeliveryDate(date) {
this.$set(this.formData, "deliveryDate", this.$moment(date).format("YYYY-MM-DD"))
this.closeHistoryLayer("delivery-calendar")
},
optionRankText(option) {
if (option.rankNo) {
return "本次福利排行榜第" + option.rankNo + "名"
@@ -1309,20 +1387,28 @@ layout("/layouts/platform_h5.html"){
this.userSelection = resp.data
this.hasSubmittedBefore = this.userSelection.length > 0
// 如果有用户选择数据,从中获取手机号
if (this.userSelection && this.userSelection.length > 0 && this.userSelection[0].mobile) {
this.formData.mobile = this.userSelection[0].mobile
this.formData.userName = this.userSelection[0].userName
// 配送时间和备注属于整次选择,统一从首条选择记录回显。
if (this.userSelection && this.userSelection.length > 0) {
this.$set(this.formData, "deliveryDate", this.userSelection[0].deliveryDate
? this.$moment(this.userSelection[0].deliveryDate).format("YYYY-MM-DD") : "")
this.$set(this.formData, "remark", this.userSelection[0].remark || "")
}
// 姓名和电话分别回显:历史值优先,单个字段为空时回退到当前登录人信息。
if (this.userSelection && this.userSelection.length > 0) {
const firstSelection = this.userSelection[0]
const currentUser = this.$store.state.user || {}
this.$set(this.formData, "mobile", firstSelection.mobile || currentUser.mobile || "")
this.$set(this.formData, "userName", firstSelection.userName || currentUser.username || "")
// 获取签名信息(如果有)
if (this.userSelection[0].userSign) {
this.formData.userSign = this.userSelection[0].userSign
if (firstSelection.userSign) {
this.formData.userSign = firstSelection.userSign
}
} else if (this.$store.state.user && this.$store.state.user.mobile) {
// 如果没有选择过,使用store中的默认手机号
this.formData.mobile = this.$store.state.user.mobile
}else if (this.$store.state.user && this.$store.state.user.username){
this.formData.userName = this.$store.state.user.username
} else {
const currentUser = this.$store.state.user || {}
this.$set(this.formData, "mobile", currentUser.mobile || "")
this.$set(this.formData, "userName", currentUser.username || "")
}
// 地址回显
@@ -1377,8 +1463,56 @@ layout("/layouts/platform_h5.html"){
})
},
/**
* 按分组累计已选福利的份数,并校验项目配置的分组最小、最大选择数量。
* 最大选择数量为 0 时表示不限制;未配置分组的福利不参与分组数量校验。
*
* @return {string} 校验通过返回空字符串,否则返回具体的分组限制提示
*/
getGroupSelectionQuantityValidationMessage() {
if (!this.projectInfo.groupRequired) {
return ""
}
const configs = this.projectInfo.groupSelectionConfigs || []
const groupQuantityMap = {}
this.selectedOptions.forEach((option) => {
const groupName = (option.groupName || "").trim()
if (!groupName) {
return
}
// 分组限制按福利份数统计,不按已选择的福利项数量统计。
groupQuantityMap[groupName] = (groupQuantityMap[groupName] || 0) + (Number(option.selectNum) || 0)
})
for (let index = 0; index < configs.length; index++) {
const config = configs[index]
if (!config || !config.groupName) {
continue
}
const groupName = config.groupName.trim()
if (!groupName) {
continue
}
const selectedQuantity = groupQuantityMap[groupName] || 0
const minSelectNum = config.minSelectNum == null ? 1 : Number(config.minSelectNum)
const maxSelectNum = config.maxSelectNum == null ? 0 : Number(config.maxSelectNum)
if (selectedQuantity < minSelectNum) {
return "分组“" + groupName + "”至少需要选择" + minSelectNum + "份,当前已选择" + selectedQuantity + "份"
}
if (maxSelectNum > 0 && selectedQuantity > maxSelectNum) {
return "分组“" + groupName + "”最多可选择" + maxSelectNum + "份,当前已选择" + selectedQuantity + "份"
}
}
return ""
},
// 执行提交
doSubmit() {
// 提交前再次校验,防止确认弹层打开后用户修改选择数量绕过前端校验。
const groupValidationMessage = this.getGroupSelectionQuantityValidationMessage()
if (groupValidationMessage) {
this.$toast.fail(groupValidationMessage)
return
}
// 再次检查截止时间
const now = new Date()
const deadline = new Date(this.projectInfo.choiceTimeEnd)
@@ -1388,13 +1522,12 @@ layout("/layouts/platform_h5.html"){
return
}
// 验证收货人
if (!this.validateUserName()) {
// 非意向选择才校验收货人和联系电话,意向选择不采集收货信息。
if (this.showReceivingContact && !this.validateUserName()) {
return;
}
// 验证手机号
if (!this.validateMobile()) {
if (this.showReceivingContact && !this.validateMobile()) {
return
}
@@ -1410,6 +1543,11 @@ layout("/layouts/platform_h5.html"){
return
}
// 配送时间是评价开放依据,提交前必须确保不早于当前选择日期。
if (!this.validateDeliveryDate()) {
return
}
if (this.isSubmitting) return
this.isSubmitting = true
@@ -1428,8 +1566,10 @@ layout("/layouts/platform_h5.html"){
{
selectOptionId: this.selectedRadioId,
selectNum: 1,
mobile: this.formData.mobile,
userName: this.formData.userName
mobile: this.showReceivingContact ? this.formData.mobile : "",
userName: this.showReceivingContact ? this.formData.userName : "",
deliveryDate: this.formData.deliveryDate,
remark: this.formData.remark
}
]
}
@@ -1439,8 +1579,10 @@ layout("/layouts/platform_h5.html"){
selections = this.selectedOptions.map((option) => ({
selectOptionId: option.id,
selectNum: option.selectNum,
mobile: this.formData.mobile,
userName: this.formData.userName
mobile: this.showReceivingContact ? this.formData.mobile : "",
userName: this.showReceivingContact ? this.formData.userName : "",
deliveryDate: this.formData.deliveryDate,
remark: this.formData.remark
}))
}
@@ -1511,6 +1653,26 @@ layout("/layouts/platform_h5.html"){
return true;
},
/**
* 校验配送日期。deliveryDate 应传 yyyy-MM-dd,返回布尔值表示是否允许继续提交。
* 日期按天比较,配送日期等于选择当天时允许提交。
*
* @return {boolean} 配送日期有效返回 true,否则提示错误并返回 false
*/
validateDeliveryDate() {
if (!this.formData.deliveryDate) {
this.$toast.fail("请选择配送时间")
return false
}
const deliveryDate = this.$moment(this.formData.deliveryDate).startOf("day").valueOf()
const currentDate = this.$moment().startOf("day").valueOf()
if (deliveryDate < currentDate) {
this.$toast.fail("配送时间不能早于选择时间")
return false
}
return true
},
// 提交选择
submitSelection() {
if (!this.hasSelection) {
@@ -1518,6 +1680,12 @@ layout("/layouts/platform_h5.html"){
return
}
const groupValidationMessage = this.getGroupSelectionQuantityValidationMessage()
if (groupValidationMessage) {
this.$toast.fail(groupValidationMessage)
return
}
// 检查是否已过截止时间
const now = new Date()
const deadline = new Date(this.projectInfo.choiceTimeEnd)
@@ -505,6 +505,14 @@ layout("/layouts/platform_h5.html"){
<van-icon name="clock-o"></van-icon>
<span>选择时间:未设置</span>
</div>
<div class="welfare-list-card__time" v-if="Number(row.isChoose) === 1">
<van-icon name="logistics"></van-icon>
<span>配送时间:{{ row.deliveryDate ? $moment(row.deliveryDate).format('YYYY-MM-DD') : '暂无' }}</span>
</div>
<div class="welfare-list-card__time" v-if="Number(row.isChoose) === 1 && row.remark">
<van-icon name="notes-o"></van-icon>
<span>备注:{{ row.remark }}</span>
</div>
<div class="welfare-list-card__footer">
<div class="welfare-list-card__selected-summary" v-if="Number(row.isChoose) === 1">
{{ row.gist_list || row.gistList || '已完成福利选择' }}