liu
9 小时以前 5d34bdd3c426bb2ca5f6627424ae67984f745603
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityPublicController.java
@@ -6,7 +6,9 @@
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;
@@ -22,6 +24,7 @@
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;
@@ -57,9 +60,13 @@
        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);
    }
@@ -72,12 +79,9 @@
    }
    /**
     * 报名是否开放:活动需为已发布,且处于报名时间窗口内
     * 是否处于报名时间窗口内
     */
    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;
@@ -96,6 +100,9 @@
                .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}")
@@ -104,8 +111,13 @@
                .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}")
@@ -148,8 +160,80 @@
        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>")
@@ -157,13 +241,14 @@
                .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>");
@@ -171,10 +256,27 @@
        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>'}")
@@ -184,6 +286,8 @@
                .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([]);")
@@ -202,6 +306,8 @@
                .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){")
@@ -209,7 +315,131 @@
                .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();
    }
    /**