| | |
| | | import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO; |
| | | import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.WxCpSignupPublicReqVO; |
| | | import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO; |
| | | import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityFieldDO; |
| | | import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum; |
| | | import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityTypeEnum; |
| | | import cn.iocoder.yudao.module.wxcp.service.activity.WxCpActivityService; |
| | | import io.swagger.v3.oas.annotations.Operation; |
| | | import io.swagger.v3.oas.annotations.Parameter; |
| | |
| | | import java.net.URI; |
| | | import java.time.LocalDateTime; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| | |
| | | if (activity == null) { |
| | | return renderMessagePage("活动不存在", "活动可能已被删除,请联系活动发起人确认。"); |
| | | } |
| | | if (!isSignupOpen(activity)) { |
| | | return renderMessagePage("报名已结束", |
| | | "该活动当前不可报名(可能未发布、已结束或不在报名时间内)。"); |
| | | if (!WxCpActivityStatusEnum.PUBLISHED.getStatus().equals(activity.getStatus())) { |
| | | return renderMessagePage("活动未开放", |
| | | "该活动尚未发布(或已结束/取消),请联系活动发起人确认。"); |
| | | } |
| | | // 只有数据采集模板有表单;其它模板只展示详情内容 |
| | | if (WxCpActivityTypeEnum.needSignup(activity.getActivityType()) && !isInSignupPeriod(activity)) { |
| | | return renderMessagePage("报名已结束", "该活动当前不在报名时间内,请联系活动发起人确认。"); |
| | | } |
| | | return renderSignupPage(activity, wxUserId); |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 报名是否开放:活动需为已发布,且处于报名时间窗口内 |
| | | * 是否处于报名时间窗口内 |
| | | */ |
| | | private boolean isSignupOpen(WxCpActivityDO activity) { |
| | | if (!WxCpActivityStatusEnum.PUBLISHED.getStatus().equals(activity.getStatus())) { |
| | | return false; |
| | | } |
| | | private boolean isInSignupPeriod(WxCpActivityDO activity) { |
| | | LocalDateTime now = LocalDateTime.now(); |
| | | if (activity.getSignupStartTime() != null && now.isBefore(activity.getSignupStartTime())) { |
| | | return false; |
| | |
| | | .append("background:#f5f6f8;color:#1f2329;font-size:15px;line-height:1.6}") |
| | | .append(".card{background:#fff;border-radius:12px;padding:18px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.06)}") |
| | | .append(".cover{display:block;width:100%;max-height:260px;object-fit:cover;border-radius:8px;margin-bottom:14px;background:#f2f3f5}") |
| | | // 详情图必须按原图比例完整展示:二维码/海报被裁边就会失效,所以这里不能用 object-fit:cover |
| | | .append(".detail-img{display:block;margin:12px 0 0}") |
| | | .append(".detail-img img{display:block;width:100%;height:auto;border-radius:8px;background:#f2f3f5}") |
| | | .append("h1{font-size:19px;margin:0 0 14px;font-weight:600;word-break:break-all}") |
| | | .append(".meta{color:#646a73;font-size:14px}") |
| | | .append(".meta div{margin-bottom:6px;display:flex}") |
| | |
| | | .append(".desc{white-space:pre-wrap;word-break:break-word;color:#3f444d}") |
| | | .append("label{display:block;font-size:14px;color:#646a73;margin:14px 0 6px}") |
| | | .append("input,textarea{width:100%;padding:11px 12px;border:1px solid #dee0e3;border-radius:8px;font-size:15px;outline:none;background:#fff;font-family:inherit}") |
| | | .append("input:focus,textarea:focus{border-color:#3370ff}") |
| | | .append("input:focus,textarea:focus,select:focus{border-color:#3370ff}") |
| | | .append("select{width:100%;padding:11px 12px;border:1px solid #dee0e3;border-radius:8px;font-size:15px;outline:none;background:#fff;font-family:inherit}") |
| | | .append("textarea{resize:vertical;min-height:84px}") |
| | | .append(".file-title{font-weight:600;margin-bottom:8px}") |
| | | .append(".file-list a{color:#3370ff;text-decoration:none;word-break:break-all}") |
| | | .append(".choice-row{display:flex;flex-wrap:wrap;gap:14px;margin-top:4px}") |
| | | .append(".choice{display:inline-flex;align-items:center;gap:5px;margin:0;color:#1f2329;font-size:15px}") |
| | | .append(".file-row{margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}") |
| | | .append(".file-btn{display:inline-block;padding:8px 14px;border:1px dashed #c9cdd4;border-radius:8px;color:#3370ff;font-size:14px;background:#fff}") |
| | | .append(".file-list{margin-top:8px;font-size:13px;color:#646a73;word-break:break-all}") |
| | |
| | | if (StrUtil.isNotBlank(activity.getContent())) { |
| | | sb.append("<div class=\"desc\">").append(esc(activity.getContent())).append("</div>"); |
| | | } |
| | | // 详情图片:紧跟在活动详情文字下方(同一张卡片内),按原图比例全宽展示, |
| | | // 点一下用浏览器打开原图,方便成员放大看二维码/海报 |
| | | for (String detailImgUrl : resolveDetailImageUrls(activity.getId())) { |
| | | sb.append("<a class=\"detail-img\" href=\"").append(esc(detailImgUrl)) |
| | | .append("\" target=\"_blank\" rel=\"noopener\">") |
| | | .append("<img src=\"").append(esc(detailImgUrl)).append("\" alt=\"活动详情图片\">") |
| | | .append("</a>"); |
| | | } |
| | | sb.append("</div>"); |
| | | |
| | | // 图片通知模板:展示上传的图片,按原图比例完整展示 |
| | | appendImageGallery(sb, activity); |
| | | // 附件通知模板:列出附件,成员点开即可下载 |
| | | appendFileList(sb, activity); |
| | | |
| | | // 只有「数据采集/评价」模板才有填写表单 |
| | | if (WxCpActivityTypeEnum.needSignup(activity.getActivityType())) { |
| | | appendSignupForm(sb, activity, wxUserId); |
| | | } |
| | | return sb.toString(); |
| | | } |
| | | |
| | | /** |
| | | * 图片通知模板的图片展示区 |
| | | */ |
| | | private void appendImageGallery(StringBuilder sb, WxCpActivityDO activity) { |
| | | List<SystemStorageBlobRespVO> images = activityService.getActivityImageAttachments(activity.getId()); |
| | | if (CollUtil.isEmpty(images)) { |
| | | return; |
| | | } |
| | | sb.append("<div class=\"card\">"); |
| | | for (SystemStorageBlobRespVO image : images) { |
| | | String raw = StrUtil.blankToDefault(image.getPreviewURL(), image.getUrl()); |
| | | if (StrUtil.isBlank(raw)) { |
| | | continue; |
| | | } |
| | | String url = toSameOriginUrl(raw); |
| | | sb.append("<a class=\"detail-img\" href=\"").append(esc(url)) |
| | | .append("\" target=\"_blank\" rel=\"noopener\">") |
| | | .append("<img src=\"").append(esc(url)).append("\" alt=\"活动图片\">") |
| | | .append("</a>"); |
| | | } |
| | | sb.append("</div>"); |
| | | } |
| | | |
| | | /** |
| | | * 附件通知模板的附件列表区 |
| | | */ |
| | | private void appendFileList(StringBuilder sb, WxCpActivityDO activity) { |
| | | List<SystemStorageBlobRespVO> files = activityService.getActivityFileAttachments(activity.getId()); |
| | | if (CollUtil.isEmpty(files)) { |
| | | return; |
| | | } |
| | | sb.append("<div class=\"card\"><div class=\"file-title\">附件下载</div>") |
| | | .append("<div class=\"file-list\">"); |
| | | for (SystemStorageBlobRespVO file : files) { |
| | | String raw = StrUtil.blankToDefault(file.getDownloadURL(), |
| | | StrUtil.blankToDefault(file.getUrl(), file.getPreviewURL())); |
| | | if (StrUtil.isBlank(raw)) { |
| | | continue; |
| | | } |
| | | String url = toSameOriginUrl(raw); |
| | | String name = StrUtil.blankToDefault(file.getOriginalFilename(), |
| | | StrUtil.blankToDefault(file.getName(), "附件")); |
| | | sb.append("<div><a href=\"").append(esc(url)).append("\" target=\"_blank\" rel=\"noopener\">") |
| | | .append(esc(name)).append("</a></div>"); |
| | | } |
| | | sb.append("</div></div>"); |
| | | } |
| | | |
| | | /** |
| | | * 数据采集/评价模板的填写表单 |
| | | */ |
| | | private void appendSignupForm(StringBuilder sb, WxCpActivityDO activity, String wxUserId) { |
| | | // 报名表单:姓名、手机号均为选填,只填了手机号才校验格式 |
| | | sb.append("<div class=\"card\">") |
| | | .append("<label>姓名</label>") |
| | |
| | | .append("<label>手机号</label>") |
| | | .append("<input id=\"mobile\" type=\"tel\" inputmode=\"numeric\" placeholder=\"请输入 11 位手机号(选填)\" maxlength=\"11\" autocomplete=\"tel\">") |
| | | .append("<label>参与说明</label>") |
| | | .append("<textarea id=\"content\" placeholder=\"可填写备注、需求或其他说明(选填)\" maxlength=\"1000\"></textarea>") |
| | | .append("<label>图片 / 附件</label>") |
| | | .append("<textarea id=\"content\" placeholder=\"可填写备注、需求或其他说明(选填)\" maxlength=\"1000\"></textarea>"); |
| | | appendCollectFields(sb, activity); |
| | | sb.append("<label>图片 / 附件</label>") |
| | | .append("<div class=\"file-row\">") |
| | | .append("<label class=\"file-btn\" for=\"files\">+ 选择图片或文件</label>") |
| | | .append("<input id=\"files\" type=\"file\" multiple accept=\"image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt\" style=\"display:none\">") |
| | | .append("</div><div class=\"file-list\" id=\"fileList\"></div>") |
| | | .append("<button id=\"submit\">提交报名</button>") |
| | | .append("<button id=\"submit\">提交</button>") |
| | | .append("<div class=\"tip\">提交后如需修改,请联系活动发起人</div>") |
| | | .append("</div><div class=\"toast\" id=\"toast\"></div>"); |
| | | |
| | |
| | | sb.append("<script>(function(){") |
| | | .append("var AID=").append(activity.getId()).append(";") |
| | | .append("var WXUID=").append(wxUserId == null ? "null" : "\"" + esc(jsStr(wxUserId)) + "\"").append(";") |
| | | .append("var FIELDS=").append(buildFieldsJson(activity)).append(";") |
| | | .append("var RATING_ENABLED=").append(Boolean.TRUE.equals(activity.getRatingEnabled())).append(";") |
| | | .append("var btn=document.getElementById('submit'),toast=document.getElementById('toast');") |
| | | .append("var fileInput=document.getElementById('files'),fileList=document.getElementById('fileList');") |
| | | .append("function tip(m){toast.textContent=m;toast.style.display='block';setTimeout(function(){toast.style.display='none'},2200)}") |
| | | .append("function reset(){btn.disabled=false;btn.textContent='提交报名'}") |
| | | .append("function reset(){btn.disabled=false;btn.textContent='提交'}") |
| | | .append("function collectFields(){") |
| | | .append("var arr=[],bad=null;") |
| | | .append("for(var i=0;i<FIELDS.length;i++){var f=FIELDS[i],v='';") |
| | | .append("if(f.type==='checkbox'){var cs=document.querySelectorAll('input[name=\"f_'+f.id+'\"]:checked'),vs=[];") |
| | | .append("for(var j=0;j<cs.length;j++){vs.push(cs[j].value)}v=vs.join(',')}") |
| | | .append("else if(f.type==='radio'){var r=document.querySelector('input[name=\"f_'+f.id+'\"]:checked');v=r?r.value:''}") |
| | | .append("else{var el=document.getElementById('f_'+f.id);v=el?el.value.trim():''}") |
| | | .append("if(f.required&&!v){bad=f.label;break}") |
| | | .append("if(v){arr.push({fieldId:f.id,fieldValue:v})}}") |
| | | .append("if(bad){tip('【'+bad+'】为必填项,请填写后再提交');return null}return arr}") |
| | | .append("function getRating(){") |
| | | .append("if(!RATING_ENABLED){return 0}") |
| | | .append("var r=document.querySelector('input[name=\"rating\"]:checked');") |
| | | .append("if(!r){tip('请先完成评价打分后再提交');return -1}") |
| | | .append("return parseInt(r.value,10)}") |
| | | .append("fileInput.addEventListener('change',function(){") |
| | | .append("var fs=fileInput.files,h='';") |
| | | .append("for(var i=0;i<fs.length;i++){h+='<div>· '+fs[i].name+'</div>'}") |
| | |
| | | .append("var m=document.getElementById('mobile').value.trim();") |
| | | .append("var c=document.getElementById('content').value.trim();") |
| | | .append("if(m&&!/^1[3-9]\\d{9}$/.test(m)){tip('手机号格式不正确,请填 11 位手机号或留空');return}") |
| | | .append("var fv=collectFields();if(fv===null){return}") |
| | | .append("var rs=getRating();if(rs<0){return}") |
| | | .append("btn.disabled=true;") |
| | | .append("var fs=fileInput.files;") |
| | | .append("var upload=Promise.resolve([]);") |
| | |
| | | .append("if(c){body.content=c}") |
| | | .append("if(WXUID){body.wxUserId=WXUID}") |
| | | .append("if(blobIds.length>0){body.blobIds=blobIds}") |
| | | .append("if(fv.length>0){body.fieldValues=fv}") |
| | | .append("if(rs>0){body.ratingScore=rs}") |
| | | .append("return fetch('signup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})") |
| | | .append(".then(function(r){return r.json()})") |
| | | .append(".then(function(r){") |
| | |
| | | .append("else{tip(r.msg||'报名失败');reset()}})}") |
| | | .append(".catch(function(e){tip((e&&e.message)||'网络异常,请稍后重试');reset()})") |
| | | .append("})})();</script></body></html>"); |
| | | return sb.toString(); |
| | | } |
| | | |
| | | /** |
| | | * 渲染数据采集模板的自定义字段与评价 |
| | | */ |
| | | private void appendCollectFields(StringBuilder sb, WxCpActivityDO activity) { |
| | | String base = "f_"; |
| | | for (WxCpActivityFieldDO field : activityService.getActivityFields(activity.getId())) { |
| | | String id = base + field.getId(); |
| | | String label = esc(field.getFieldLabel()); |
| | | String required = Boolean.TRUE.equals(field.getRequired()) ? " *" : ""; |
| | | String placeholder = esc(StrUtil.blankToDefault(field.getPlaceholder(), |
| | | "请输入" + field.getFieldLabel())); |
| | | String type = StrUtil.blankToDefault(field.getFieldType(), WxCpActivityFieldDO.TYPE_TEXT); |
| | | switch (type) { |
| | | case WxCpActivityFieldDO.TYPE_TEXTAREA -> |
| | | sb.append("<label>").append(label).append(required).append("</label>") |
| | | .append("<textarea id=\"").append(id).append("\" placeholder=\"") |
| | | .append(placeholder).append("\" maxlength=\"2000\"></textarea>"); |
| | | case WxCpActivityFieldDO.TYPE_NUMBER -> |
| | | sb.append("<label>").append(label).append(required).append("</label>") |
| | | .append("<input id=\"").append(id) |
| | | .append("\" type=\"number\" placeholder=\"").append(placeholder).append("\">"); |
| | | case WxCpActivityFieldDO.TYPE_DATE -> |
| | | sb.append("<label>").append(label).append(required).append("</label>") |
| | | .append("<input id=\"").append(id).append("\" type=\"date\">"); |
| | | case WxCpActivityFieldDO.TYPE_SELECT -> { |
| | | sb.append("<label>").append(label).append(required).append("</label>") |
| | | .append("<select id=\"").append(id).append("\"><option value=\"\">请选择</option>"); |
| | | for (String option : splitOptions(field.getFieldOptions())) { |
| | | sb.append("<option value=\"").append(esc(option)).append("\">") |
| | | .append(esc(option)).append("</option>"); |
| | | } |
| | | sb.append("</select>"); |
| | | } |
| | | case WxCpActivityFieldDO.TYPE_RADIO -> { |
| | | sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">"); |
| | | for (String option : splitOptions(field.getFieldOptions())) { |
| | | sb.append("<label class=\"choice\"><input type=\"radio\" name=\"") |
| | | .append(id).append("\" value=\"").append(esc(option)).append("\">") |
| | | .append(esc(option)).append("</label>"); |
| | | } |
| | | sb.append("</div>"); |
| | | } |
| | | case WxCpActivityFieldDO.TYPE_CHECKBOX -> { |
| | | sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">"); |
| | | for (String option : splitOptions(field.getFieldOptions())) { |
| | | sb.append("<label class=\"choice\"><input type=\"checkbox\" name=\"") |
| | | .append(id).append("\" value=\"").append(esc(option)).append("\">") |
| | | .append(esc(option)).append("</label>"); |
| | | } |
| | | sb.append("</div>"); |
| | | } |
| | | case WxCpActivityFieldDO.TYPE_RATING -> { |
| | | sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">"); |
| | | int max = activity.getRatingMax() != null && activity.getRatingMax() > 0 ? activity.getRatingMax() : 5; |
| | | for (int i = 1; i <= max; i++) { |
| | | sb.append("<label class=\"choice\"><input type=\"radio\" name=\"") |
| | | .append(id).append("\" value=\"").append(i).append("\">") |
| | | .append(i).append(" 星</label>"); |
| | | } |
| | | sb.append("</div>"); |
| | | } |
| | | default -> |
| | | sb.append("<label>").append(label).append(required).append("</label>") |
| | | .append("<input id=\"").append(id).append("\" type=\"text\" placeholder=\"") |
| | | .append(placeholder).append("\" maxlength=\"255\">"); |
| | | } |
| | | } |
| | | // 独立评价项:开启后必须选择星级 |
| | | if (Boolean.TRUE.equals(activity.getRatingEnabled())) { |
| | | int max = activity.getRatingMax() != null && activity.getRatingMax() > 0 |
| | | ? activity.getRatingMax() : 5; |
| | | sb.append("<label>").append(esc(StrUtil.blankToDefault(activity.getRatingTitle(), "活动评价"))) |
| | | .append(" *</label><div class=\"choice-row\">"); |
| | | for (int i = 1; i <= max; i++) { |
| | | sb.append("<label class=\"choice\"><input type=\"radio\" name=\"rating\" value=\"") |
| | | .append(i).append("\">").append(i).append(" 星</label>"); |
| | | } |
| | | sb.append("</div>"); |
| | | } |
| | | } |
| | | |
| | | private List<String> splitOptions(String options) { |
| | | if (StrUtil.isBlank(options)) { |
| | | return Collections.emptyList(); |
| | | } |
| | | return StrUtil.split(options, ',', true, true); |
| | | } |
| | | |
| | | /** |
| | | * 把自定义字段定义转成前端提交脚本可用的 JSON |
| | | */ |
| | | private String buildFieldsJson(WxCpActivityDO activity) { |
| | | List<WxCpActivityFieldDO> fields = activityService.getActivityFields(activity.getId()); |
| | | StringBuilder json = new StringBuilder("["); |
| | | for (int i = 0; i < fields.size(); i++) { |
| | | WxCpActivityFieldDO field = fields.get(i); |
| | | if (i > 0) { |
| | | json.append(','); |
| | | } |
| | | json.append("{\"id\":").append(field.getId()) |
| | | .append(",\"label\":\"").append(esc(jsStr(field.getFieldLabel()))).append("\"") |
| | | .append(",\"type\":\"").append(esc(jsStr(field.getFieldType()))).append("\"") |
| | | .append(",\"required\":").append(Boolean.TRUE.equals(field.getRequired())).append('}'); |
| | | } |
| | | return json.append(']').toString(); |
| | | } |
| | | |
| | | /** |
| | | * 取活动详情图片地址列表(按上传先后排序,地址同样转成同源相对路径) |
| | | * |
| | | * 与封面分开取:封面按 16:9 裁剪展示,详情图按原图比例完整展示, |
| | | * 二维码这类不能裁的图要放详情图里。 |
| | | */ |
| | | private List<String> resolveDetailImageUrls(Long activityId) { |
| | | List<SystemStorageBlobRespVO> attachments = activityService.getActivityDetailAttachments(activityId); |
| | | if (CollUtil.isEmpty(attachments)) { |
| | | return Collections.emptyList(); |
| | | } |
| | | return attachments.stream() |
| | | .map(attachment -> StrUtil.blankToDefault(attachment.getPreviewURL(), attachment.getUrl())) |
| | | .filter(StrUtil::isNotBlank) |
| | | .map(this::toSameOriginUrl) |
| | | .toList(); |
| | | } |
| | | |
| | | /** |