This commit is contained in:
2026-06-12 14:05:53 +08:00
parent 54a2879a04
commit 5ad0142b7a
13 changed files with 2154 additions and 0 deletions
@@ -25,6 +25,12 @@ public interface SysFileService extends BaseService<Sys_file> {
*/
String uploadReturnUrl(String engine, TempFile file);
/**
* 文件字节上传,返回文件Url。
* 参数 engine 为存储引擎,fileName 为带后缀的文件名,bytes 为文件内容;返回值为前端可直接预览的下载地址。
*/
String uploadBytesReturnUrl(String engine, String fileName, byte[] bytes);
/**
* 分页
*/
@@ -65,6 +65,11 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
return this.storageFile(engine, file, false);
}
@Override
public String uploadBytesReturnUrl(String engine, String fileName, byte[] bytes) {
return this.storageFile(engine, fileName, bytes, false);
}
@Override
public Pagination page(Sys_file file) {
return null;
@@ -0,0 +1,90 @@
package com.budwk.app.zhgh.activity.friendship.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.activity.friendship.models.ActivityFriendshipSignup;
import com.budwk.app.zhgh.activity.friendship.service.ActivityFriendshipSignupService;
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.AdaptBy;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import org.nutz.mvc.upload.TempFile;
import org.nutz.mvc.upload.UploadAdaptor;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
@IocBean
@Ok("json:full")
@At("/platform/activity/friendship/signup")
public class ActivityFriendshipSignupController {
@Inject
private ActivityFriendshipSignupService activityFriendshipSignupService;
@At("")
@Ok("beetl:/platform/zhgh/activity/friendship/index.html")
@SaCheckPermission("activity.friendship.manage")
public void index() {
}
@At
@SaCheckPermission("activity.friendship.manage")
public Result pageData(PageForm pageForm, String sex, String maritalStatus, String education, String post, String personnelType, String unionId, String unitId) {
return Result.success(activityFriendshipSignupService.pageData(pageForm, sex, maritalStatus, education, post, personnelType, unionId, unitId));
}
@At
@SaCheckPermission("activity.friendship.manage")
public Result findOne(@Valid String id) {
return Result.success(activityFriendshipSignupService.fetch(id));
}
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("activity.friendship.manage")
@SLog(tag = "单身联谊", msg = "新增报名台账")
public Result insert(@Param("data") @Valid ActivityFriendshipSignup signup) {
activityFriendshipSignupService.insertSignup(signup);
return Result.success();
}
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("activity.friendship.manage")
@SLog(tag = "单身联谊", msg = "修改报名台账")
public Result update(@Param("data") @Valid ActivityFriendshipSignup signup) {
activityFriendshipSignupService.updateSignup(signup);
return Result.success();
}
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("activity.friendship.manage")
@SLog(tag = "单身联谊", msg = "删除报名台账")
public Result delete(@Valid String id) {
activityFriendshipSignupService.delete(id);
return Result.success();
}
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("activity.friendship.manage")
@AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"})
public Result doImport(@Param("file") TempFile file, @Param("isFlag") Boolean isFlag) {
return activityFriendshipSignupService.doImport(file, isFlag);
}
@At
@Ok("void")
@SaCheckPermission("activity.friendship.manage")
public void exportExcel(HttpServletResponse response) {
activityFriendshipSignupService.exportExcel(response);
}
}
@@ -0,0 +1,30 @@
package com.budwk.app.zhgh.activity.friendship.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.activity.friendship.service.ActivityFriendshipSignupService;
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;
@IocBean
@Ok("json:full")
@At("/platform/h5/activity/friendship")
public class H5ActivityFriendshipController {
@Inject
private ActivityFriendshipSignupService activityFriendshipSignupService;
@At("")
@Ok("beetl:/platform/zhghh5/activity/friendship/index.html")
@SaCheckPermission("h5.activity.friendship.view")
public void index() {
}
@At
@SaCheckPermission("h5.activity.friendship.view")
public Result listCards() {
return Result.success(activityFriendshipSignupService.listMobileCards());
}
}
@@ -0,0 +1,80 @@
package com.budwk.app.zhgh.activity.friendship.mode;
import cn.afterturn.easypoi.excel.annotation.Excel;
import lombok.Data;
/**
* 单身联谊 Excel 导出结构。
*/
@Data
public class ActivityFriendshipExcelMode {
@Excel(name = "编号", width = 15)
private String signupNo;
@Excel(name = "工号", width = 20)
private String loginName;
@Excel(name = "姓名", width = 20)
private String username;
@Excel(name = "所属工会", width = 25)
private String unionName;
@Excel(name = "所属单位", width = 30)
private String unitName;
@Excel(name = "报名时间", width = 25)
private String signupTime;
@Excel(name = "性别", width = 10)
private String sex;
@Excel(name = "婚况", width = 15)
private String maritalStatus;
@Excel(name = "学历", width = 15)
private String education;
@Excel(name = "岗位", width = 20)
private String post;
@Excel(name = "人事编制", width = 20)
private String personnelType;
@Excel(name = "出生年份", width = 15)
private String birthYear;
@Excel(name = "户籍(市)", width = 20)
private String householdCity;
@Excel(name = "家乡(市)", width = 20)
private String hometownCity;
@Excel(name = "身高(厘米)", width = 15)
private String heightCm;
@Excel(name = "体重(千克)", width = 15)
private String weightKg;
@Excel(name = "年收入(万)", width = 15)
private String annualIncome;
@Excel(name = "车房", width = 25)
private String carHouse;
@Excel(name = "自我描述", width = 40)
private String selfDescription;
@Excel(name = "家庭情况", width = 40)
private String familyInfo;
@Excel(name = "兴趣爱好", width = 40)
private String hobbies;
@Excel(name = "择偶意向", width = 40)
private String matePreference;
@Excel(name = "生活照(不展示)", width = 40)
private String photoUrl;
}
@@ -0,0 +1,18 @@
package com.budwk.app.zhgh.activity.friendship.mode;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 单身联谊 Excel 导入错误行。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ActivityFriendshipImportError {
private int rowNo;
private String loginName;
private String errorInfo;
}
@@ -0,0 +1,18 @@
package com.budwk.app.zhgh.activity.friendship.mode;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 单身联谊 Excel 导入结果。
*/
@Data
public class ActivityFriendshipImportResult {
private int totalCount;
private int successCount;
private int errorCount;
private List<ActivityFriendshipImportError> errorList = new ArrayList<>();
}
@@ -0,0 +1,130 @@
package com.budwk.app.zhgh.activity.friendship.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
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.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableMeta;
import org.nutz.dao.interceptor.annotation.PrevInsert;
/**
* 单身联谊报名台账。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Table("activity_friendship_signup")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("单身联谊报名台账")
public class ActivityFriendshipSignup extends BaseModel {
@Name
@Comment("ID")
@PrevInsert(uu32 = true)
@ColDefine(type = ColType.VARCHAR, width = 32)
private String id;
@Column
@Comment("报名编号")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String signupNo;
@Column
@Comment("工号")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String loginName;
@Column
@Comment("报名时间")
@ColDefine(type = ColType.VARCHAR, width = 30)
private String signupTime;
@Column
@Comment("性别")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String sex;
@Column
@Comment("婚况")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String maritalStatus;
@Column
@Comment("学历")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String education;
@Column
@Comment("岗位")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String post;
@Column
@Comment("人事编制")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String personnelType;
@Column
@Comment("出生年份")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String birthYear;
@Column
@Comment("户籍")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String householdCity;
@Column
@Comment("家乡")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String hometownCity;
@Column
@Comment("身高厘米")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String heightCm;
@Column
@Comment("体重千克")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String weightKg;
@Column
@Comment("年收入万")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String annualIncome;
@Column
@Comment("车房")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String carHouse;
@Column
@Comment("自我描述")
@ColDefine(type = ColType.TEXT)
private String selfDescription;
@Column
@Comment("家庭情况")
@ColDefine(type = ColType.TEXT)
private String familyInfo;
@Column
@Comment("兴趣爱好")
@ColDefine(type = ColType.TEXT)
private String hobbies;
@Column
@Comment("择偶意向")
@ColDefine(type = ColType.TEXT)
private String matePreference;
@Column
@Comment("生活照URL")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String photoUrl;
}
@@ -0,0 +1,53 @@
package com.budwk.app.zhgh.activity.friendship.service;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.zhgh.activity.friendship.models.ActivityFriendshipSignup;
import com.budwk.app.zhgh.activity.friendship.vo.ActivityFriendshipSignupVO;
import org.nutz.mvc.upload.TempFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
public interface ActivityFriendshipSignupService extends BaseService<ActivityFriendshipSignup> {
/**
* 查询报名台账分页。
* 参数 pageForm 为分页和人员信息关键字参数,仅支持工号、姓名模糊查询;
* sex、maritalStatus、education、post、personnelType、unionId、unitId 为页面下拉筛选值,分别对应性别、婚况、学历、岗位、人事编制、所属工会、所属单位;
* 返回值为 ActivityFriendshipSignupVO 分页数据,包含台账字段和关联用户的姓名、工会、单位信息。
*/
Pagination<ActivityFriendshipSignupVO> pageData(PageForm pageForm, String sex, String maritalStatus, String education, String post, String personnelType, String unionId, String unitId);
/**
* 查询移动端可查看的异性资料卡。
* 根据当前登录用户性别自动筛选异性报名信息;返回值为资料卡展示 VO 列表。
*/
List<ActivityFriendshipSignupVO> listMobileCards();
/**
* 新增报名记录。
* 参数 signup 为前端表单数据;当编号为空时根据报名时间年份生成年份加三位流水号。
*/
void insertSignup(ActivityFriendshipSignup signup);
/**
* 修改报名记录。
* 参数 signup 为前端表单数据;已有编号不变,编号为空时按报名时间补生成。
*/
void updateSignup(ActivityFriendshipSignup signup);
/**
* 导入报名 Excel。
* 参数 file 为 xls/xlsx 文件,isFlag 为 true 时先清空台账再导入;返回导入统计和错误行。
*/
Result doImport(TempFile file, Boolean isFlag);
/**
* 导出报名台账。
* 参数 response 为下载响应,导出字段与报名模板保持一致。
*/
void exportExcel(HttpServletResponse response);
}
@@ -0,0 +1,412 @@
package com.budwk.app.zhgh.activity.friendship.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.ExcelImportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.ImportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.sys.enums.SysFileEngineTypeEnum;
import com.budwk.app.sys.views.View_user;
import com.budwk.app.sys.services.SysFileService;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.friendship.mode.ActivityFriendshipExcelMode;
import com.budwk.app.zhgh.activity.friendship.mode.ActivityFriendshipImportError;
import com.budwk.app.zhgh.activity.friendship.mode.ActivityFriendshipImportResult;
import com.budwk.app.zhgh.activity.friendship.models.ActivityFriendshipSignup;
import com.budwk.app.zhgh.activity.friendship.service.ActivityFriendshipSignupService;
import com.budwk.app.zhgh.activity.friendship.vo.ActivityFriendshipSignupVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ooxml.POIXMLDocumentPart;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.usermodel.XSSFClientAnchor;
import org.apache.poi.xssf.usermodel.XSSFDrawing;
import org.apache.poi.xssf.usermodel.XSSFPicture;
import org.apache.poi.xssf.usermodel.XSSFShape;
import org.apache.poi.xssf.usermodel.XSSFSheet;
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.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.mvc.upload.TempFile;
import javax.servlet.http.HttpServletResponse;
import java.io.FileInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@IocBean(args = {"refer:dao"})
public class ActivityFriendshipSignupServiceImpl extends BaseServiceImpl<ActivityFriendshipSignup> implements ActivityFriendshipSignupService {
private static final int PHOTO_COLUMN_INDEX = 18;
@Inject
private SysFileService sysFileService;
public ActivityFriendshipSignupServiceImpl(Dao dao) {
super(dao);
}
@Override
public Pagination<ActivityFriendshipSignupVO> pageData(PageForm pageForm, String sex, String maritalStatus, String education, String post, String personnelType, String unionId, String unitId) {
Sql sql = Sqls.create("""
SELECT
afs.id,
afs.signupNo,
afs.loginName,
vu.username,
vu.unionId,
vu.unionName,
vu.unitId,
vu.unitName,
afs.signupTime,
afs.sex,
afs.maritalStatus,
afs.education,
afs.post,
afs.personnelType,
afs.birthYear,
afs.householdCity,
afs.hometownCity,
afs.heightCm,
afs.weightKg,
afs.annualIncome,
afs.carHouse,
afs.selfDescription,
afs.familyInfo,
afs.hobbies,
afs.matePreference,
afs.photoUrl,
afs.createdAt
FROM
activity_friendship_signup afs
LEFT JOIN vw_user vu ON vu.loginname = afs.loginName
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and("afs.delFlag", "=", false);
cnd.andEX("afs.sex", "=", sex);
cnd.andEX("afs.maritalStatus", "=", maritalStatus);
cnd.andEX("afs.education", "=", education);
cnd.andEX("afs.post", "=", post);
cnd.andEX("afs.personnelType", "=", personnelType);
cnd.andEX("vu.unionId", "=", unionId);
cnd.andEX("vu.unitId", "=", unitId);
if (StrUtil.isNotBlank(pageForm.getSearchKeyword()) && StrUtil.isNotBlank(pageForm.getSearchName())) {
cnd.and(Cnd.likeEX(getSearchColumn(pageForm.getSearchName()), pageForm.getSearchKeyword()));
}
cnd.desc("afs.signupNo");
cnd.desc("afs.createdAt");
sql.setCondition(cnd);
return listPageVO(pageForm, sql, ActivityFriendshipSignupVO.class);
}
@Override
public void insertSignup(ActivityFriendshipSignup signup) {
fillSignupNoIfBlank(signup);
insert(signup);
}
@Override
public void updateSignup(ActivityFriendshipSignup signup) {
fillSignupNoIfBlank(signup);
updateIgnoreNull(signup);
}
@Override
public List<ActivityFriendshipSignupVO> listMobileCards() {
View_user currentUser = dao().fetch(View_user.class, Cnd.where("id", "=", SecurityUtil.getUserId()));
if (currentUser == null || !List.of("", "").contains(currentUser.getSex())) {
return new ArrayList<>();
}
String targetSex = "".equals(currentUser.getSex()) ? "" : "";
Sql sql = Sqls.create("""
SELECT
afs.id,
afs.signupNo,
afs.loginName,
vu.username,
vu.unionId,
vu.unionName,
vu.unitId,
vu.unitName,
afs.signupTime,
afs.sex,
afs.maritalStatus,
afs.education,
afs.post,
afs.personnelType,
afs.birthYear,
afs.householdCity,
afs.hometownCity,
afs.heightCm,
afs.weightKg,
afs.annualIncome,
afs.carHouse,
afs.selfDescription,
afs.familyInfo,
afs.hobbies,
afs.matePreference,
afs.createdAt
FROM
activity_friendship_signup afs
LEFT JOIN vw_user vu ON vu.loginname = afs.loginName
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.and("afs.delFlag", "=", false);
// cnd.and("afs.sex", "=", targetSex);
cnd.desc("afs.signupNo");
cnd.desc("afs.createdAt");
sql.setCondition(cnd);
return listVO(sql, ActivityFriendshipSignupVO.class);
}
@Override
public Result doImport(TempFile file, Boolean isFlag) {
if (Lang.isEmpty(file)) {
return Result.error("上传的文件不能为空");
}
String extName = FileUtil.extName(file.getFile());
if (!List.of("xls", "xlsx").contains(extName.toLowerCase())) {
return Result.error("请上传xls,xlsx文件");
}
ActivityFriendshipImportResult result = new ActivityFriendshipImportResult();
List<ActivityFriendshipSignup> signups = new ArrayList<>();
try (FileInputStream inputStream = new FileInputStream(file.getFile());
Workbook workbook = WorkbookFactory.create(inputStream)) {
org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
Map<Integer, String> rowPhotoUrlMap = buildRowPhotoUrlMap(sheet);
// 导入参数说明:Excel 第 1 行为字段表头,返回值为按 @Excel 表头映射好的报名导入实体列表。
ImportParams importParams = new ImportParams();
importParams.setTitleRows(0);
importParams.setHeadRows(1);
List<ActivityFriendshipExcelMode> excelList = ExcelImportUtil.importExcel(file.getFile(), ActivityFriendshipExcelMode.class, importParams);
for (int dataIndex = 0; dataIndex < excelList.size(); dataIndex++) {
ActivityFriendshipSignup signup = BeanUtil.copyProperties(excelList.get(dataIndex), ActivityFriendshipSignup.class);
if (isBlankSignup(signup)) {
continue;
}
result.setTotalCount(result.getTotalCount() + 1);
signup.setPhotoUrl(rowPhotoUrlMap.get(dataIndex + importParams.getHeadRows() + importParams.getTitleRows()));
if (StrUtil.isBlank(signup.getLoginName())) {
result.getErrorList().add(new ActivityFriendshipImportError(dataIndex + importParams.getHeadRows() + importParams.getTitleRows() + 1, "", "工号不能为空"));
continue;
}
signups.add(signup);
}
} catch (Exception e) {
log.error("单身联谊报名导入失败", e);
return Result.error("读取不到数据,请检查excel文件格式");
}
if (Boolean.TRUE.equals(isFlag)) {
clear(Cnd.where("delFlag", "=", false));
}
fillSignupNo(signups, Boolean.TRUE.equals(isFlag));
if (Lang.isNotEmpty(signups)) {
insert(signups);
}
result.setErrorCount(result.getErrorList().size());
result.setSuccessCount(Math.max(result.getTotalCount() - result.getErrorCount(), 0));
if (result.getErrorCount() > 0) {
return Result.success(result);
}
return Result.success("导入成功");
}
@Override
public void exportExcel(HttpServletResponse response) {
try {
Sql sql = Sqls.create("""
SELECT
afs.signupNo,
afs.loginName,
vu.username,
vu.unionName,
vu.unitName,
afs.signupTime,
afs.sex,
afs.maritalStatus,
afs.education,
afs.post,
afs.personnelType,
afs.birthYear,
afs.householdCity,
afs.hometownCity,
afs.heightCm,
afs.weightKg,
afs.annualIncome,
afs.carHouse,
afs.selfDescription,
afs.familyInfo,
afs.hobbies,
afs.matePreference,
afs.photoUrl
FROM
activity_friendship_signup afs
LEFT JOIN vw_user vu ON vu.loginname = afs.loginName
WHERE
afs.delFlag = 0
ORDER BY
afs.signupNo DESC,
afs.createdAt DESC
""");
List<ActivityFriendshipSignupVO> list = listVO(sql, ActivityFriendshipSignupVO.class);
List<ActivityFriendshipExcelMode> exportList = list.stream().map(item -> BeanUtil.copyProperties(item, ActivityFriendshipExcelMode.class)).collect(Collectors.toList());
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
exportParams.setTitle("单身联谊报名人员名单");
exportParams.setSheetName("报名人员名单");
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, ActivityFriendshipExcelMode.class, exportList);
CommonDownloadUtil.download("单身联谊报名人员名单.xlsx", workbook, response);
} catch (Exception e) {
log.error("单身联谊报名导出失败", e);
}
}
/**
* 将前端搜索字段转换为连表查询字段。
* 参数 searchName 为前端选择的搜索项;返回值为带表别名的数据库字段,避免连表后字段歧义。
*/
private String getSearchColumn(String searchName) {
return switch (searchName) {
case "loginName" -> "afs.loginName";
case "username" -> "vu.username";
default -> "afs.loginName";
};
}
/**
* 解析 Excel 内嵌生活照。
* 参数 sheet 为报名工作表;返回值 key 为 POI 行号,value 为系统文件下载 URL。
*/
private Map<Integer, String> buildRowPhotoUrlMap(org.apache.poi.ss.usermodel.Sheet sheet) {
Map<Integer, String> rowPhotoUrlMap = new HashMap<>();
if (!(sheet instanceof XSSFSheet xssfSheet)) {
return rowPhotoUrlMap;
}
for (POIXMLDocumentPart relation : xssfSheet.getRelations()) {
if (!(relation instanceof XSSFDrawing drawing)) {
continue;
}
for (XSSFShape shape : drawing.getShapes()) {
if (!(shape instanceof XSSFPicture picture)) {
continue;
}
XSSFClientAnchor anchor = picture.getClientAnchor();
if (anchor == null || anchor.getCol1() != PHOTO_COLUMN_INDEX) {
continue;
}
byte[] imageBytes = picture.getPictureData().getData();
String extension = picture.getPictureData().suggestFileExtension();
String fileName = "friendship-photo-" + (anchor.getRow1() + 1) + "." + (StrUtil.isBlank(extension) ? "png" : extension);
String photoUrl = sysFileService.uploadBytesReturnUrl(SysFileEngineTypeEnum.MINIO.getValue(), fileName, imageBytes);
rowPhotoUrlMap.put(anchor.getRow1(), photoUrl);
}
}
return rowPhotoUrlMap;
}
/**
* 判断导入实体是否为空行。
* 参数 signup 为 EasyPOI 根据表头映射出的导入对象;返回 true 表示所有业务文本字段均为空,不参与统计和入库。
*/
private boolean isBlankSignup(ActivityFriendshipSignup signup) {
return signup == null
|| StrUtil.isAllBlank(signup.getLoginName(), signup.getSignupTime(), signup.getSex(), signup.getMaritalStatus(),
signup.getEducation(), signup.getPost(), signup.getPersonnelType(), signup.getBirthYear(), signup.getHouseholdCity(),
signup.getHometownCity(), signup.getHeightCm(), signup.getWeightKg(), signup.getAnnualIncome(), signup.getCarHouse(),
signup.getSelfDescription(), signup.getFamilyInfo(), signup.getHobbies(), signup.getMatePreference(), signup.getPhotoUrl());
}
/**
* 批量生成报名编号。
* 参数 signups 为待入库报名记录,resetFromOne 表示是否忽略库中已有编号;编号格式为报名年份加三位流水号。
*/
private void fillSignupNo(List<ActivityFriendshipSignup> signups, boolean resetFromOne) {
Map<String, Integer> yearMaxNoMap = new HashMap<>();
signups.sort(Comparator.comparing(ActivityFriendshipSignup::getSignupTime, Comparator.nullsLast(String::compareTo)));
for (ActivityFriendshipSignup signup : signups) {
String year = parseSignupYear(signup.getSignupTime());
int currentMax = yearMaxNoMap.computeIfAbsent(year, key -> resetFromOne ? 0 : queryMaxSignupNo(key));
int nextNo = currentMax + 1;
signup.setSignupNo(year + String.format("%03d", nextNo));
yearMaxNoMap.put(year, nextNo);
}
}
/**
* 单条补充报名编号。
* 参数 signup 为表单报名记录;当编号为空时按报名时间年份和数据库最大流水号生成下一号。
*/
private void fillSignupNoIfBlank(ActivityFriendshipSignup signup) {
if (signup == null || StrUtil.isNotBlank(signup.getSignupNo())) {
return;
}
String year = parseSignupYear(signup.getSignupTime());
signup.setSignupNo(year + String.format("%03d", queryMaxSignupNo(year) + 1));
}
/**
* 查询指定年份现有最大流水号。
* 参数 year 为四位年份;返回值为该年份后三位流水号最大值,没有记录时返回 0。
*/
private int queryMaxSignupNo(String year) {
List<ActivityFriendshipSignup> list = query(Cnd.where("delFlag", "=", false)
.and("signupNo", "like", year + "%")
.desc("signupNo"));
if (Lang.isEmpty(list) || StrUtil.isBlank(list.get(0).getSignupNo())) {
return 0;
}
String signupNo = list.get(0).getSignupNo();
if (signupNo.length() <= 4) {
return 0;
}
try {
return Integer.parseInt(signupNo.substring(4));
} catch (Exception e) {
return 0;
}
}
/**
* 解析报名时间年份。
* 参数 signupTime 支持完整日期时间或直接填写年份;无法解析时使用当前年份,避免导入中断。
*/
private String parseSignupYear(String signupTime) {
if (StrUtil.isBlank(signupTime)) {
return String.valueOf(LocalDate.now().getYear());
}
String value = signupTime.trim();
if (value.length() >= 4 && value.substring(0, 4).matches("\\d{4}")) {
return value.substring(0, 4);
}
try {
return String.valueOf(LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE).getYear());
} catch (Exception e) {
return String.valueOf(LocalDate.now().getYear());
}
}
}
@@ -0,0 +1,38 @@
package com.budwk.app.zhgh.activity.friendship.vo;
import lombok.Data;
/**
* 单身联谊报名台账前端展示数据。
*/
@Data
public class ActivityFriendshipSignupVO {
private String id;
private String signupNo;
private String loginName;
private String username;
private String unionId;
private String unionName;
private String unitId;
private String unitName;
private String signupTime;
private String sex;
private String maritalStatus;
private String education;
private String post;
private String personnelType;
private String birthYear;
private String householdCity;
private String hometownCity;
private String heightCm;
private String weightKg;
private String annualIncome;
private String carHouse;
private String selfDescription;
private String familyInfo;
private String hobbies;
private String matePreference;
private String photoUrl;
private Long createdAt;
}
@@ -0,0 +1,471 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<search @search="doSearch">
<search-item label="人员信息">
<el-input @keyup.enter.native="doSearch" clearable placeholder="请输入内容" style="width: 100%" v-model="pageForm.searchKeyword">
<el-select slot="prepend" style="width: 90px" v-model="pageForm.searchName">
<el-option label="工号" value="loginName"></el-option>
<el-option label="姓名" value="username"></el-option>
</el-select>
</el-input>
</search-item>
<search-item label="性别">
<el-select clearable placeholder="请选择性别" style="width: 100%" v-model="pageForm.sex">
<el-option label="男" value="男"></el-option>
<el-option label="女" value="女"></el-option>
</el-select>
</search-item>
<search-item label="婚况">
<el-select clearable placeholder="请选择婚况" style="width: 100%" v-model="pageForm.maritalStatus">
<el-option label="未婚" value="未婚"></el-option>
<el-option label="离异" value="离异"></el-option>
<el-option label="其他" value="其他"></el-option>
</el-select>
</search-item>
<search-item label="学历">
<el-select clearable placeholder="请选择学历" style="width: 100%" v-model="pageForm.education">
<el-option label="本科" value="本科"></el-option>
<el-option label="硕士" value="硕士"></el-option>
<el-option label="博士" value="博士"></el-option>
<el-option label="其他" value="其他"></el-option>
</el-select>
</search-item>
<search-item label="岗位">
<el-select clearable placeholder="请选择岗位" style="width: 100%" v-model="pageForm.post">
<el-option label="教师" value="教师"></el-option>
<el-option label="实验" value="实验"></el-option>
<el-option label="辅导员" value="辅导员"></el-option>
<el-option label="行政" value="行政"></el-option>
<el-option label="其他" value="其他"></el-option>
</el-select>
</search-item>
<search-item label="人事编制">
<el-select clearable placeholder="请选择人事编制" style="width: 100%" v-model="pageForm.personnelType">
<el-option label="在编" value="在编"></el-option>
<el-option label="人事代理" value="人事代理"></el-option>
<el-option label="人才派遣" value="人才派遣"></el-option>
</el-select>
</search-item>
<search-item label="所属工会">
<el-select clearable filterable placeholder="请选择工会" style="width: 100%" v-model="pageForm.unionId">
<el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in unionList"></el-option>
</el-select>
</search-item>
<search-item label="所属单位">
<el-select clearable filterable placeholder="请选择单位" style="width: 100%" v-model="pageForm.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id" v-for="item in unitList"></el-option>
</el-select>
</search-item>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="邮缘会台账">
<el-button @click="openForm()" icon="el-icon-plus" size="small" type="primary">新增</el-button>
<el-button @click="openImport" icon="el-icon-upload2" size="small" type="primary">导入</el-button>
<el-button @click="doExport" icon="el-icon-download" size="small" type="primary">导出</el-button>
</table-tool>
<el-table :data="tableData" :size="tableSize" @sort-change="pageOrder" class="vi-table" ref="table" row-key="id" style="width: 100%" v-loading="tableLoading">
<el-table-column :index="indexMethod" align="center" header-align="center" label="序号" type="index" width="70"></el-table-column>
<el-table-column align="center" header-align="center" label="编号" prop="signupNo" show-overflow-tooltip width="110"></el-table-column>
<el-table-column align="center" header-align="center" label="生活照" width="100">
<template scope="{row}">
<el-image :preview-src-list="[row.photoUrl]" :src="row.photoUrl" fit="cover" style="width: 60px; height: 60px" v-if="row.photoUrl"></el-image>
<el-tag type="warning" v-else>暂无</el-tag>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="工号" prop="loginName" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip width="150"></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="报名时间" prop="signupTime" show-overflow-tooltip width="170"></el-table-column>
<el-table-column align="center" header-align="center" label="性别" prop="sex" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="婚况" prop="maritalStatus" width="90"></el-table-column>
<el-table-column align="center" header-align="center" label="学历" prop="education" show-overflow-tooltip width="100"></el-table-column>
<el-table-column align="center" header-align="center" label="岗位" prop="post" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="人事编制" prop="personnelType" show-overflow-tooltip width="120"></el-table-column>
<el-table-column align="center" header-align="center" label="出生年份" prop="birthYear" width="100"></el-table-column>
<el-table-column align="center" header-align="center" label="户籍" prop="householdCity" show-overflow-tooltip width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="家乡" prop="hometownCity" show-overflow-tooltip width="130"></el-table-column>
<el-table-column align="center" header-align="center" label="身高" prop="heightCm" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="体重" prop="weightKg" width="80"></el-table-column>
<el-table-column align="center" header-align="center" label="年收入" prop="annualIncome" width="90"></el-table-column>
<el-table-column align="center" header-align="center" label="车房" prop="carHouse" show-overflow-tooltip width="140"></el-table-column>
<el-table-column align="center" header-align="center" label="自我描述" prop="selfDescription" show-overflow-tooltip width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="家庭情况" prop="familyInfo" show-overflow-tooltip width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="兴趣爱好" prop="hobbies" show-overflow-tooltip width="180"></el-table-column>
<el-table-column align="center" header-align="center" label="择偶意向" prop="matePreference" show-overflow-tooltip width="180"></el-table-column>
<el-table-column align="center" fixed="right" header-align="center" label="操作" width="150">
<template scope="{row}">
<el-button @click="openForm(row)" size="mini" type="primary">编辑</el-button>
<el-button @click="doDelete(row)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</template>
</guava>
<el-dialog :close-on-click-modal="false" :title="formData.id ? '编辑报名信息' : '新增报名信息'" :visible.sync="formDialog" top="3vh" width="72%">
<el-form :model="formData" :rules="formRules" label-width="120px" ref="form">
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="编号" prop="signupNo">
<el-input disabled placeholder="保存后自动生成" v-model="formData.signupNo"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="工号" prop="loginName">
<el-input maxlength="100" placeholder="请输入工号" v-model="formData.loginName"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="报名时间" prop="signupTime">
<el-date-picker placeholder="请选择报名时间" style="width: 100%" type="datetime" v-model="formData.signupTime" value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="性别" prop="sex">
<el-select clearable placeholder="请选择性别" style="width: 100%" v-model="formData.sex">
<el-option label="男" value="男"></el-option>
<el-option label="女" value="女"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="婚况" prop="maritalStatus">
<el-select clearable placeholder="请选择婚况" style="width: 100%" v-model="formData.maritalStatus">
<el-option label="未婚" value="未婚"></el-option>
<el-option label="离异" value="离异"></el-option>
<el-option label="丧偶" value="丧偶"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="学历" prop="education">
<el-input maxlength="50" placeholder="请输入学历" v-model="formData.education"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="岗位" prop="post">
<el-input maxlength="100" placeholder="请输入岗位" v-model="formData.post"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="人事编制" prop="personnelType">
<el-input maxlength="100" placeholder="请输入人事编制" v-model="formData.personnelType"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="出生年份" prop="birthYear">
<el-input maxlength="20" placeholder="请输入出生年份" v-model="formData.birthYear"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="户籍(市)" prop="householdCity">
<el-input maxlength="100" placeholder="请输入户籍" v-model="formData.householdCity"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="家乡(市)" prop="hometownCity">
<el-input maxlength="100" placeholder="请输入家乡" v-model="formData.hometownCity"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="身高(厘米)" prop="heightCm">
<el-input maxlength="20" placeholder="请输入身高" v-model="formData.heightCm"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="体重(千克)" prop="weightKg">
<el-input maxlength="20" placeholder="请输入体重" v-model="formData.weightKg"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="年收入(万)" prop="annualIncome">
<el-input maxlength="20" placeholder="请输入年收入" v-model="formData.annualIncome"></el-input>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="车房" prop="carHouse">
<el-input maxlength="100" placeholder="请输入车房情况" v-model="formData.carHouse"></el-input>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="生活照" prop="photoUrl">
<file-upload :upload_number="1" :value.sync="formData.photoUrl" upload_mode="image" upload_result_category="interval" upload_result_type="url"></file-upload>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="自我描述" prop="selfDescription">
<el-input :rows="4" maxlength="1000" placeholder="请输入自我描述" type="textarea" v-model="formData.selfDescription"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="家庭情况" prop="familyInfo">
<el-input :rows="4" maxlength="1000" placeholder="请输入家庭情况" type="textarea" v-model="formData.familyInfo"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="兴趣爱好" prop="hobbies">
<el-input :rows="4" maxlength="1000" placeholder="请输入兴趣爱好" type="textarea" v-model="formData.hobbies"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="择偶意向" prop="matePreference">
<el-input :rows="4" maxlength="1000" placeholder="请输入择偶意向" type="textarea" v-model="formData.matePreference"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-form>
<span slot="footer">
<el-button @click="formDialog = false" :disabled="formLoading">取消</el-button>
<el-button @click="save" type="primary" v-loading="formLoading">提交</el-button>
</span>
</el-dialog>
<el-dialog :close-on-click-modal="false" :visible.sync="importDialog" title="导入报名人员名单" width="50%">
<div style="padding: 20px 50px">
<el-timeline>
<el-timeline-item placement="top" timestamp="选择文件">
<el-card>
<el-form>
<el-form-item label="导入模式">
<el-radio-group v-model="importData.isFlag">
<el-radio-button label="true">清空更新</el-radio-button>
<el-radio-button label="false">追加</el-radio-button>
</el-radio-group>
</el-form-item>
<el-upload :auto-upload="false" :file-list="importData.fileList" :limit="1" :on-change="fileChange" :on-remove="fileRemove" ref="upload">
<el-button icon="el-icon-upload" size="medium" style="width: 200px">选择文件</el-button>
<div class="el-upload__tip" slot="tip" style="color: #f56c6c">只能上传 xls/xlsx 文件,生活照需为 Excel 内嵌图片</div>
</el-upload>
</el-form>
</el-card>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="导入结果">
<el-card shadow="never">
<p>总记录数:{{ importResult.totalCount }}</p>
<p>成功数:<span class="text-success">{{ importResult.successCount }}</span></p>
<p>错误数:<span class="text-danger">{{ importResult.errorCount }}</span></p>
<el-table :data="importResult.errorList" size="mini" v-if="importResult.errorCount > 0">
<el-table-column label="行号" prop="rowNo" width="80"></el-table-column>
<el-table-column label="工号" prop="loginName"></el-table-column>
<el-table-column label="错误原因" prop="errorInfo"></el-table-column>
</el-table>
</el-card>
</el-timeline-item>
</el-timeline>
<div style="text-align: right">
<el-button @click="importDialog = false" :disabled="importLoading">取消</el-button>
<el-button @click="doImport" type="primary" v-loading="importLoading">确定</el-button>
</div>
</div>
</el-dialog>
</div>
<script>
new Vue({
el: "#app",
store,
mixins: [initTableMixins],
data() {
return {
pageForm: {
searchName: "loginName"
},
formDialog: false,
formLoading: false,
importDialog: false,
importLoading: false,
formData: {},
unionList: [],
unitList: [],
importData: {
fileList: [],
isFlag: "false"
},
importResult: {
totalCount: 0,
successCount: 0,
errorCount: 0,
errorList: []
},
formRules: {
loginName: [{ required: true, message: "请输入工号", trigger: "blur" }]
}
}
},
methods: {
pageData() {
this.tableLoading = true
this.$axios.post("/platform/activity/friendship/signup/pageData", this.pageForm)
.then((res) => {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
})
.finally(() => {
this.tableLoading = false
})
},
openForm(row) {
if (row) {
this.$set(this, "formData", clone(row))
} else {
this.$set(this, "formData", {
signupNo: "",
loginName: "",
signupTime: moment().format("YYYY-MM-DD HH:mm:ss"),
sex: "",
maritalStatus: "",
education: "",
post: "",
personnelType: "",
birthYear: "",
householdCity: "",
hometownCity: "",
heightCm: "",
weightKg: "",
annualIncome: "",
carHouse: "",
selfDescription: "",
familyInfo: "",
hobbies: "",
matePreference: "",
photoUrl: ""
})
}
this.formDialog = true
this.$nextTick(() => {
if (this.$refs.form) {
this.$refs.form.clearValidate()
}
})
},
save() {
this.$refs.form.validate((valid) => {
if (!valid) {
return
}
this.formLoading = true
const url = this.formData.id ? "/platform/activity/friendship/signup/update" : "/platform/activity/friendship/signup/insert"
this.$axios.post(url, { data: JSON.stringify(this.formData) })
.then((res) => {
if (res.code === 0) {
this.$message.success("保存成功")
this.formDialog = false
this.doSearch()
} else {
this.$message.warning(res.msg)
}
})
.finally(() => {
this.formLoading = false
})
})
},
doDelete(row) {
this.$confirm("确定要删除该报名记录吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$axios.post("/platform/activity/friendship/signup/delete", { id: row.id })
.then((res) => {
if (res.code === 0) {
this.$message.success("删除成功")
this.doSearch()
} else {
this.$message.warning(res.msg)
}
})
})
},
openImport() {
this.$set(this, "importData", {
fileList: [],
isFlag: "false"
})
this.$set(this, "importResult", {
totalCount: 0,
successCount: 0,
errorCount: 0,
errorList: []
})
this.importDialog = true
},
fileRemove(file, fileList) {
this.$set(this.importData, "fileList", fileList)
},
fileChange(file, fileList) {
const fileType = file.name.split(".").pop().toLowerCase()
if (!file.size) {
this.$message.warning("您选择的是空文件")
fileList.splice(fileList.findIndex((item) => item === file), 1)
}
if (!["xls", "xlsx"].includes(fileType)) {
this.$message.warning("文件只能是 XLS/XLSX 格式")
fileList.splice(fileList.findIndex((item) => item === file), 1)
}
this.$set(this.importData, "fileList", fileList)
},
doImport() {
if (this.importData.fileList.length === 0) {
this.$message.warning("请选择文件")
return
}
const data = new FormData()
data.append("isFlag", this.importData.isFlag)
this.importData.fileList.forEach((val) => {
data.append("file", val.raw, val.raw.name)
})
this.importLoading = true
this.$axios.post("/platform/activity/friendship/signup/doImport", data)
.then((res) => {
if (res.code === 0) {
if (res.data) {
this.$set(this, "importResult", res.data)
if (res.data.errorCount > 0) {
this.$message.warning("导入完成,存在错误记录")
} else {
this.$message.success("导入成功")
}
} else {
this.$message.success("导入成功")
}
this.doSearch()
} else {
this.$message.warning(res.msg)
}
})
.finally(() => {
this.importLoading = false
})
},
doExport() {
this.$downLoad("/platform/activity/friendship/signup/exportExcel")
}
},
created() {
this.$businessTool.listUnion().then((res) => {
this.unionList = res
})
this.$businessTool.listUnit().then((res) => {
this.unitList = res
})
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,803 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<div id="app" v-cloak>
<div class="friendship-page">
<header class="friendship-header">
<van-nav-bar left-arrow left-text="返回" title="邮缘会" @click-left="historyBack"></van-nav-bar>
<section class="friendship-filter-shell">
<div class="filter-pill-row">
<button :class="['filter-pill', filterForm.sex ? 'active' : '']" @click="openFilterPicker('sex')" type="button">
<span>性别:{{ getFilterText('sex') }}</span>
<i></i>
</button>
<button :class="['filter-pill', filterForm.education ? 'active' : '']" @click="openFilterPicker('education')" type="button">
<span>学历:{{ getFilterText('education') }}</span>
<i></i>
</button>
<button :class="['filter-pill', filterForm.post ? 'active' : '']" @click="openFilterPicker('post')" type="button">
<span>岗位:{{ getFilterText('post') }}</span>
<i></i>
</button>
<button :class="['filter-pill', filterForm.personnelType ? 'active' : '']" @click="openFilterPicker('personnelType')" type="button">
<span>编制:{{ getFilterText('personnelType') }}</span>
<i></i>
</button>
<button @click="clearFilters" class="filter-clear" type="button" v-if="hasFilter">清空</button>
</div>
</section>
<div class="card-counter" v-if="cardList.length > 0">{{ currentIndex + 1 }}/{{ cardList.length }}</div>
</header>
<main class="card-container">
<van-empty description="暂无可查看的资料" v-if="!tableLoading && cardList.length === 0"></van-empty>
<van-swipe :duration="420" :loop="cardList.length > 1" :show-indicators="false" @change="handleSwipeChange" class="friendship-swipe" indicator-color="#333" v-else>
<van-swipe-item :key="item.id" v-for="item in cardList">
<article :class="['profile-card', 'responsive-text', item.sex === '男' ? 'male-card' : 'female-card']">
<header class="card-header">
<div class="signup-no">{{ item.signupNo || '暂无编号' }}</div>
<div class="gender-name">
<span class="gender-icon">{{ item.sex === '男' ? '👨' : '👩' }}</span>
<span>{{ item.sex === '男' ? '男士' : '女士' }}</span>
</div>
</header>
<div class="header-pointer"></div>
<section class="profile-main">
<h1>{{ item.birthYear || '未知年份' }}年出生</h1>
<p>{{ buildSubtitle(item) }}</p>
</section>
<section class="info-grid">
<div class="info-cell">
<span class="cell-icon"></span>
<span>身高</span>
<strong>{{ item.heightCm || '未知' }}cm</strong>
</div>
<div class="info-cell">
<span class="cell-icon"></span>
<span>体重</span>
<strong>{{ item.weightKg || '未知' }}kg</strong>
</div>
<div class="info-cell">
<span class="cell-icon">$</span>
<span>年收入</span>
<strong>{{ item.annualIncome || '未知' }}万</strong>
</div>
<div class="info-cell">
<span class="cell-icon"></span>
<span>房车</span>
<strong>{{ item.carHouse || '未知' }}</strong>
</div>
<div class="info-cell wide">
<span class="cell-icon"></span>
<span>户籍</span>
<strong>{{ item.householdCity || '未知' }}</strong>
</div>
<div class="info-cell wide">
<span class="cell-icon"></span>
<span>家乡</span>
<strong>{{ item.hometownCity || '未知' }}</strong>
</div>
</section>
<section class="text-section">
<div class="text-block">
<h2>✍ 自我描述</h2>
<p>{{ item.selfDescription || '暂无' }}</p>
</div>
<div class="text-block">
<h2>👨‍👩‍👧‍👦 家庭情况</h2>
<p>{{ item.familyInfo || '暂无' }}</p>
</div>
<div class="text-block">
<h2>🎯 兴趣爱好</h2>
<p>{{ item.hobbies || '暂无' }}</p>
</div>
<div class="text-block intention">
<h2>💬 择偶意向</h2>
<p>{{ item.matePreference || '暂无' }}</p>
</div>
</section>
</article>
</van-swipe-item>
</van-swipe>
</main>
<footer class="privacy-footer">
<div>🛡 隐私保护:信息已脱敏处理</div>
<p>若有意向,请联系校工会</p>
</footer>
<van-popup position="bottom" safe-area-inset-bottom v-model="pickerVisible">
<van-picker :columns="currentPickerOptions" :title="currentFilterTitle" show-toolbar value-key="label" @cancel="pickerVisible = false" @confirm="confirmFilter"></van-picker>
</van-popup>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
store,
data() {
return {
tableLoading: false,
sourceCardList: [],
cardList: [],
currentIndex: 0,
pickerVisible: false,
currentFilterType: "",
currentFilterTitle: "",
currentPickerOptions: [],
filterForm: {
sex: "",
education: "",
post: "",
personnelType: ""
},
filterOptions: {
sex: [
{ label: "全部", value: "" },
{ label: "男", value: "男" },
{ label: "女", value: "女" }
],
education: [
{ label: "全部", value: "" },
{ label: "本科", value: "本科" },
{ label: "硕士", value: "硕士" },
{ label: "博士", value: "博士" },
{ label: "其他", value: "其他" }
],
post: [
{ label: "全部", value: "" },
{ label: "教师", value: "教师" },
{ label: "实验", value: "实验" },
{ label: "辅导员", value: "辅导员" },
{ label: "行政", value: "行政" },
{ label: "其他", value: "其他" }
],
personnelType: [
{ label: "全部", value: "" },
{ label: "在编", value: "在编" },
{ label: "人事代理", value: "人事代理" },
{ label: "人才派遣", value: "人才派遣" }
]
},
filterTitles: {
sex: "选择性别",
education: "选择学历",
post: "选择岗位",
personnelType: "选择人事编制"
}
}
},
computed: {
hasFilter() {
return this.filterForm.sex || this.filterForm.education || this.filterForm.post || this.filterForm.personnelType
}
},
methods: {
pageData() {
this.tableLoading = true
this.$axios.post("/platform/h5/activity/friendship/listCards", {})
.then((res) => {
if (res.code === 0) {
const list = res.data || []
this.$set(this, "sourceCardList", list)
this.applyFilters()
} else {
this.$toast(res.msg)
}
})
.finally(() => {
this.tableLoading = false
})
},
openFilterPicker(type) {
this.currentFilterType = type
this.currentFilterTitle = this.filterTitles[type] || "选择筛选条件"
this.currentPickerOptions = this.filterOptions[type] || []
this.pickerVisible = true
},
confirmFilter(option) {
if (!this.currentFilterType) {
this.pickerVisible = false
return
}
this.$set(this.filterForm, this.currentFilterType, option.value)
this.pickerVisible = false
this.applyFilters()
},
getFilterText(type) {
const options = this.filterOptions[type] || []
const current = options.find((item) => item.value === this.filterForm[type])
return current && current.value ? current.label : "全部"
},
clearFilters() {
this.$set(this, "filterForm", {
sex: "",
education: "",
post: "",
personnelType: ""
})
this.applyFilters()
},
applyFilters() {
const list = this.sourceCardList.filter((item) => {
return this.matchFilter(item, "sex") && this.matchFilter(item, "education") && this.matchFilter(item, "post") && this.matchFilter(item, "personnelType")
})
this.$set(this, "cardList", list)
// 筛选条件变化后回到第一张,避免轮播下标沿用旧列表位置。
this.$set(this, "currentIndex", 0)
},
matchFilter(item, key) {
const value = this.filterForm[key]
if (!value) {
return true
}
return item[key] === value
},
buildSubtitle(item) {
const values = [item.maritalStatus, item.education, item.post, item.personnelType].filter((value) => value)
return values.length > 0 ? values.join(" · ") : "暂无基础信息"
},
// 接收 Vant Swipe 返回的当前下标,用于同步右上角“当前张数/总张数”。
handleSwipeChange(index) {
this.$set(this, "currentIndex", index)
}
},
created() {
this.pageData()
}
})
</script>
<style>
:root {
--friendship-page-bg: #f8f9fb;
--friendship-surface: #ffffff;
--friendship-surface-warm: #fffdf9;
--friendship-border: #eceff3;
--friendship-text-main: #15171a;
--friendship-text-muted: #5f6673;
--friendship-text-soft: #8a93a3;
--friendship-shadow-soft: 0 10px 26px rgba(31, 41, 55, 0.08);
--friendship-shadow-pill: 0 4px 14px rgba(31, 41, 55, 0.08);
--friendship-pink: #f76f91;
--friendship-pink-deep: #f45d82;
--friendship-pink-soft: rgba(247, 111, 145, 0.08);
--friendship-pink-line: rgba(247, 111, 145, 0.42);
--friendship-blue: #4a90e2;
--friendship-blue-deep: #2f7dd6;
--friendship-blue-soft: rgba(74, 144, 226, 0.10);
--friendship-blue-line: rgba(74, 144, 226, 0.38);
--friendship-danger: #e53935;
}
body {
background: var(--friendship-page-bg);
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
font-size: clamp(13px, 3.7vw, 16px);
}
.responsive-text {
font-size: clamp(13px, 3.7vw, 16px);
}
.friendship-page {
display: flex;
flex: 1;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--friendship-page-bg);
box-sizing: border-box;
}
.friendship-header {
flex: 0 0 auto;
z-index: 8;
background: rgba(248, 249, 251, 0.96);
border-bottom: 1px solid rgba(229, 231, 235, 0.86);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.friendship-header .van-nav-bar {
background: rgba(248, 249, 251, 0.96);
box-shadow: none;
}
.friendship-header .van-nav-bar::after {
border-color: var(--friendship-border);
}
.friendship-header .van-nav-bar__title {
color: #2f343b;
font-weight: 600;
letter-spacing: 0;
}
.friendship-header .van-nav-bar__left,
.friendship-header .van-nav-bar__text,
.friendship-header .van-nav-bar .van-icon {
color: #2878d4;
}
.friendship-filter-shell {
margin: 0 auto;
width: 100%;
max-width: 430px;
padding: clamp(0.625rem, 2.8vw, 0.8125rem) clamp(0.875rem, 4vw, 1rem) clamp(0.375rem, 1.8vw, 0.5rem);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
box-sizing: border-box;
}
.card-container {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: clamp(0.75rem, 3.6vw, 1rem) 0 clamp(0.75rem, 3.5vw, 1rem);
box-sizing: border-box;
}
.filter-pill-row {
display: flex;
align-items: center;
gap: clamp(0.5rem, 2.5vw, 0.75rem);
overflow-x: auto;
padding: 0.125rem 0 0.375rem;
-webkit-overflow-scrolling: touch;
}
.filter-pill-row::-webkit-scrollbar {
display: none;
}
.filter-pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: clamp(0.125rem, 0.8vw, 0.1875rem);
flex: 0 0 auto;
height: clamp(2rem, 8.4vw, 2.375rem);
max-width: clamp(7rem, 34vw, 8.75rem);
padding: 0 clamp(0.75rem, 3.4vw, 0.9375rem);
border: 1px solid #e4e7ec;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
color: #3f4650;
text-align: center;
box-shadow: var(--friendship-shadow-pill);
box-sizing: border-box;
}
.filter-pill.active {
border-color: rgba(247, 111, 145, 0.38);
background: rgba(255, 255, 255, 0.98);
color: var(--friendship-pink-deep);
box-shadow: 0 5px 16px rgba(247, 111, 145, 0.13);
}
.filter-pill span,
.filter-pill i {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0;
}
.filter-pill span {
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-weight: 600;
}
.filter-pill i {
flex: 0 0 auto;
margin-top: -0.0625rem;
font-size: clamp(9px, 2.7vw, 11px);
line-height: 1;
font-style: normal;
opacity: 0.78;
}
.filter-clear {
flex: 0 0 auto;
height: clamp(2rem, 8.4vw, 2.375rem);
padding: 0 clamp(0.75rem, 3.2vw, 0.9375rem);
border: 1px solid rgba(247, 111, 145, 0.28);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
color: var(--friendship-pink-deep);
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
box-shadow: var(--friendship-shadow-pill);
box-sizing: border-box;
}
.card-counter {
position: relative;
z-index: 3;
margin: 0 max(1rem, calc((100vw - 430px) / 2 + 1rem)) clamp(0.375rem, 2vw, 0.5rem) auto;
min-width: clamp(3rem, 15vw, 3.375rem);
width: fit-content;
padding: clamp(0.3125rem, 1.6vw, 0.4375rem) clamp(0.75rem, 3.4vw, 0.9375rem);
border: 1px solid rgba(236, 239, 243, 0.95);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
color: #3d424b;
font-size: clamp(12px, 3.3vw, 14px);
line-height: 1.4;
font-weight: 600;
text-align: center;
box-shadow: var(--friendship-shadow-pill);
box-sizing: border-box;
}
.friendship-swipe {
min-height: 100%;
overflow: hidden;
transform: translateZ(0);
}
.friendship-swipe .van-swipe__track {
height: 100%;
transform: translateZ(0);
will-change: transform;
}
.friendship-swipe .van-swipe-item {
display: flex;
justify-content: center;
min-height: 100%;
overflow: visible;
padding: 0 clamp(1rem, 4vw, 1.125rem) clamp(0.75rem, 3.5vw, 1.125rem);
box-sizing: border-box;
}
.profile-card {
display: flex;
flex-direction: column;
width: 100%;
max-width: 430px;
overflow: hidden;
transform: translateZ(0);
will-change: transform;
border: 1px solid rgba(231, 236, 242, 0.9);
border-radius: clamp(1.25rem, 5vw, 1.75rem);
background: var(--friendship-surface);
box-shadow: var(--friendship-shadow-soft);
}
.card-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
min-height: clamp(3.625rem, 15vw, 4.75rem);
padding: 0 clamp(1.375rem, 6vw, 1.75rem);
color: #ffffff;
box-sizing: border-box;
}
.female-card .card-header {
background: linear-gradient(135deg, var(--friendship-pink), var(--friendship-pink-deep));
}
.male-card .card-header {
background: linear-gradient(135deg, var(--friendship-blue), var(--friendship-blue-deep));
}
.signup-no {
min-width: clamp(6.25rem, 31vw, 7.5rem);
padding: clamp(0.5rem, 2.2vw, 0.625rem) clamp(0.875rem, 4vw, 1.125rem);
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
font-size: clamp(15px, 4.6vw, 18px);
font-weight: 700;
text-align: center;
box-shadow: 0 5px 14px rgba(31, 41, 55, 0.08);
box-sizing: border-box;
}
.female-card .signup-no {
color: var(--friendship-pink-deep);
}
.male-card .signup-no {
color: var(--friendship-blue-deep);
}
.gender-name {
display: flex;
align-items: center;
gap: clamp(0.1875rem, 1.4vw, 0.3125rem);
font-size: clamp(18px, 5.2vw, 22px);
font-weight: 700;
}
.gender-icon {
font-size: clamp(16px, 4.8vw, 20px);
}
.header-pointer {
width: 0;
height: 0;
margin: -0.125rem auto 0;
border-left: clamp(0.875rem, 5vw, 1.25rem) solid transparent;
border-right: clamp(0.875rem, 5vw, 1.25rem) solid transparent;
}
.female-card .header-pointer {
border-top: clamp(1rem, 5.5vw, 1.375rem) solid var(--friendship-pink-deep);
}
.male-card .header-pointer {
border-top: clamp(1rem, 5.5vw, 1.375rem) solid var(--friendship-blue-deep);
}
.profile-main {
padding: clamp(1.25rem, 5.6vw, 1.75rem) clamp(1rem, 5vw, 1.375rem) clamp(0.625rem, 2.8vw, 0.875rem);
text-align: center;
}
.profile-main h1 {
margin: 0;
color: #050505;
font-size: clamp(26px, 7.4vw, 34px);
line-height: 1.25;
font-weight: 800;
letter-spacing: 0;
}
.profile-main p {
margin: clamp(0.375rem, 1.8vw, 0.5rem) 0 0;
color: #9a684d;
font-size: clamp(13px, 4vw, 16px);
line-height: 1.35;
font-weight: 500;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(0.625rem, 3.2vw, 0.875rem);
padding: 0 clamp(1.25rem, 5.5vw, 1.5rem) clamp(0.875rem, 4vw, 1.125rem);
}
.info-cell {
display: flex;
align-items: center;
gap: clamp(0.5rem, 2.4vw, 0.6875rem);
min-height: clamp(2.625rem, 11vw, 3.125rem);
padding: clamp(0.625rem, 2.8vw, 0.75rem) clamp(0.75rem, 3.6vw, 0.9375rem);
border-radius: 0.625rem;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.35;
box-sizing: border-box;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.62);
}
.female-card .info-cell,
.female-card .text-block {
background: var(--friendship-pink-soft);
}
.male-card .info-cell,
.male-card .text-block {
background: var(--friendship-blue-soft);
}
.info-cell.wide {
grid-column: 1 / -1;
}
.info-cell strong {
min-width: 0;
color: var(--friendship-text-main);
font-weight: 600;
word-break: break-word;
}
.cell-icon {
flex: 0 0 auto;
width: clamp(1rem, 4.4vw, 1.25rem);
font-size: clamp(14px, 4vw, 16px);
font-weight: 700;
text-align: center;
}
.female-card .cell-icon {
color: var(--friendship-pink-deep);
}
.male-card .cell-icon {
color: var(--friendship-blue-deep);
}
.text-section {
display: flex;
flex-direction: column;
gap: clamp(0.875rem, 4vw, 1rem);
padding: 0 clamp(1.25rem, 5.5vw, 1.5rem) clamp(1.125rem, 5vw, 1.5rem);
}
.text-block {
padding: clamp(0.875rem, 4vw, 1.125rem) clamp(0.9375rem, 4.5vw, 1.25rem);
border-left: 4px solid;
border-radius: 0.625rem;
box-sizing: border-box;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
}
.female-card .text-block {
border-left-color: var(--friendship-pink);
}
.male-card .text-block {
border-left-color: var(--friendship-blue);
}
.text-block h2 {
margin: 0 0 clamp(0.5rem, 2.4vw, 0.625rem);
color: var(--friendship-text-main);
font-size: clamp(15px, 4.2vw, 17px);
line-height: 1.3;
font-weight: 700;
letter-spacing: 0;
}
.text-block p {
margin: 0;
color: #30343a;
font-size: clamp(13px, 3.7vw, 15px);
line-height: 1.8;
word-break: break-word;
}
.female-card .intention {
background: var(--friendship-pink-soft);
border-left-color: var(--friendship-pink);
}
.male-card .intention {
background: var(--friendship-blue-soft);
border-left-color: var(--friendship-blue);
}
.privacy-footer {
flex: 0 0 auto;
width: 100%;
padding: clamp(0.5rem, 2.8vw, 0.625rem) clamp(0.875rem, 5vw, 1.25rem) clamp(0.5rem, 3.3vw, 0.75rem);
border-top: 1px dashed #d7dce3;
background: rgba(255, 255, 255, 0.94);
color: var(--friendship-text-muted);
text-align: center;
font-size: clamp(11px, 3.3vw, 13px);
line-height: 1.7;
box-shadow: 0 -6px 18px rgba(31, 41, 55, 0.04);
box-sizing: border-box;
}
.privacy-footer div {
color: var(--friendship-text-muted);
}
.privacy-footer p {
margin: 0.125rem 0 0;
color: var(--friendship-danger);
}
@media (max-height: 700px) {
.card-container {
padding-top: clamp(0.25rem, 1.6vh, 0.375rem);
padding-bottom: clamp(0.375rem, 2vh, 0.5rem);
}
.card-header {
min-height: clamp(2.875rem, 12vh, 3.5rem);
}
.profile-main {
padding-top: clamp(0.875rem, 3.2vh, 1.125rem);
padding-bottom: clamp(0.125rem, 1vh, 0.25rem);
}
.info-grid {
gap: clamp(0.25rem, 1.6vh, 0.5rem);
padding-bottom: clamp(0.375rem, 2vh, 0.625rem);
}
.text-section {
gap: clamp(0.375rem, 2vh, 0.625rem);
padding-bottom: clamp(0.375rem, 2vh, 0.75rem);
}
.text-block {
padding-top: clamp(0.375rem, 2vh, 0.625rem);
padding-bottom: clamp(0.375rem, 2vh, 0.625rem);
}
.text-block p {
max-height: clamp(3.75rem, 14vh, 5.625rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.privacy-footer {
padding-top: clamp(0.375rem, 1.8vh, 0.5rem);
padding-bottom: clamp(0.375rem, 1.8vh, 0.5rem);
}
}
@media (max-width: 360px) {
.card-container {
padding-top: clamp(0.5rem, 2.8vw, 0.75rem);
padding-bottom: clamp(0.5rem, 2.8vw, 0.75rem);
}
.card-header {
padding: 0 clamp(0.75rem, 4vw, 1.125rem);
}
.signup-no {
min-width: clamp(4.75rem, 27vw, 6.125rem);
font-size: clamp(13px, 4vw, 15px);
}
.profile-main h1 {
font-size: clamp(20px, 6.4vw, 23px);
}
.info-grid,
.text-section {
padding-left: clamp(0.5rem, 3.5vw, 0.875rem);
padding-right: clamp(0.5rem, 3.5vw, 0.875rem);
}
.info-cell {
padding-left: clamp(0.375rem, 2.4vw, 0.5rem);
padding-right: clamp(0.375rem, 2.4vw, 0.5rem);
}
.text-block p {
max-height: clamp(4.5rem, 24vw, 6.25rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
@media (max-width: 330px) {
.info-grid {
grid-template-columns: 1fr;
}
.info-cell.wide {
grid-column: auto;
}
}
</style>
<!--#
}
#-->