This commit is contained in:
=
2026-06-01 11:25:27 +08:00
parent 175c8de61a
commit 67ea2030b5
7 changed files with 276 additions and 35 deletions
@@ -233,6 +233,11 @@ public class MemberApplyRecord extends BaseModel {
@ColDefine(type = ColType.VARCHAR, width = 100)
private String sign;
@Column
@Comment("照片")
@ColDefine(type = ColType.VARCHAR, width = 500)
private String photo;
@Column
@Comment("来源,高校编码")
@ColDefine(type = ColType.VARCHAR, width = 50)
@@ -40,6 +40,15 @@ import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import lombok.extern.slf4j.Slf4j;
import org.ddr.poi.html.HtmlRenderPolicy;
import org.apache.poi.xwpf.usermodel.LineSpacingRule;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.ParagraphAlignment;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.*;
import org.nutz.dao.sql.Sql;
@@ -52,10 +61,10 @@ import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang;
import org.nutz.lang.random.R;
import org.nutz.lang.util.NutMap;
import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTInline;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTDrawing;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
@@ -77,6 +86,13 @@ import java.util.zip.ZipOutputStream;
@Slf4j
public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implements MemberCommonService {
private static final int MEMBER_APPLY_PHOTO_WIDTH_PIXEL = 100;
private static final int MEMBER_APPLY_PHOTO_HEIGHT_PIXEL = 140;
private static final int MEMBER_APPLY_PHOTO_WIDTH_EMU = Units.pixelToEMU(MEMBER_APPLY_PHOTO_WIDTH_PIXEL);
private static final int MEMBER_APPLY_PHOTO_HEIGHT_EMU = Units.pixelToEMU(MEMBER_APPLY_PHOTO_HEIGHT_PIXEL);
private static final int MEMBER_APPLY_PHOTO_SIZE_TOLERANCE_EMU = Units.pixelToEMU(2);
private static final double MEMBER_APPLY_PHOTO_PARAGRAPH_LINE_POINT = MEMBER_APPLY_PHOTO_HEIGHT_PIXEL * 0.75D;
@Inject
private SysDictService sysDictService;
@Inject
@@ -624,7 +640,7 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
docData.put("nativePlace", member.getNativePlace());
docData.put("marriage", member.getMarriage());
docData.put("specialty", StrUtil.isNotBlank(member.getSpecialty()) ? HtmlUtil.cleanHtmlTag(member.getSpecialty()) : "");
docData.put("specialty", buildMemberApplyDocHtml(member.getSpecialty()));
docData.put("jobCategory", member.getJobCategory());
docData.put("idCard", member.getIdCard());
@@ -633,7 +649,8 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
docData.put("arrivalAtSchoolDate", StrUtil.isNotBlank(member.getArrivalAtSchoolDate()) ? DateUtil.parse(member.getArrivalAtSchoolDate()).toString("yyyy-MM-dd") : "");
docData.put("homeAddress", member.getHomeAddress());
docData.put("personalData", sysOfficeTemplateUtil.convertRichTextToDocText(member.getPersonalData()));
docData.put("personalData", buildMemberApplyDocHtml(member.getPersonalData()));
docData.put("photo", sysOfficeTemplateUtil.createPictureRenderData(MEMBER_APPLY_PHOTO_WIDTH_PIXEL, MEMBER_APPLY_PHOTO_HEIGHT_PIXEL, member.getPhoto()));
docData.put("sign", sysOfficeTemplateUtil.createPictureRenderData(member.getSign()));
docData.put("applyDateTime", member.getApplyDateTime() != null ? DateUtil.format(member.getApplyDateTime(), "yyyy年MM月dd日") : "");
@@ -702,16 +719,109 @@ public class MemberManageServiceImpl extends BaseServiceImpl<Sys_user> implement
Configure config = Configure.builder()
.bind("personalData", htmlRenderPolicy)
.bind("specialty", htmlRenderPolicy)
.build();
try {
XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate("member_apply_form"), config)
.render(docData)
.write(response.getOutputStream());
} catch (IOException e) {
XWPFTemplate template = XWPFTemplate.compile(sysOfficeTemplateUtil.getTemplate("member_apply_form"), config)
.render(docData);
resizeMemberApplyPhotoRow(template.getXWPFDocument());
template.write(response.getOutputStream());
} catch (Exception e) {
log.error("导出会员入会申请表失败,ID: {}, 错误信息: {}", id, e.getMessage());
throw new RuntimeException("导出文件失败", e);
}
}
/**
* 入会申请导出时统一转为 HTML 片段,历史富文本保留样式,普通文本保留换行。
*/
private String buildMemberApplyDocHtml(String text) {
if (StrUtil.isBlank(text)) {
return "";
}
String docText = sysOfficeTemplateUtil.convertRichTextToDocText(text);
if (containsHtmlTag(docText)) {
return docText;
}
String escapeText = HtmlUtil.escape(docText)
.replace("\r\n", "\n")
.replace("\r", "\n")
.replace("\n", "<br/>");
return "<p>" + escapeText + "</p>";
}
/**
* 判断内容是否包含 HTML 标签,避免把历史富文本当普通文本转义。
*/
private boolean containsHtmlTag(String text) {
return StrUtil.isNotBlank(text) && text.matches("(?s).*<\\s*[a-zA-Z][^>]*>.*");
}
/**
* Keep the member photo row tall enough so Word does not clip the default inline picture.
*/
private void resizeMemberApplyPhotoRow(XWPFDocument document) {
if (document == null) {
return;
}
for (XWPFTable table : document.getTables()) {
for (XWPFTableRow row : table.getRows()) {
adjustMemberApplyPhotoParagraph(row);
}
}
}
/**
* Match only the two-inch member photo by rendered picture size, avoiding signature pictures.
*/
private boolean adjustMemberApplyPhotoParagraph(XWPFTableRow row) {
if (row == null) {
return false;
}
boolean found = false;
for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph paragraph : cell.getParagraphs()) {
for (XWPFRun run : paragraph.getRuns()) {
if (runContainsMemberApplyPhoto(run)) {
adjustMemberApplyPhotoParagraphStyle(paragraph);
found = true;
}
}
}
}
return found;
}
/**
* Keep the inline photo visible without changing the merged cell vertical anchor.
*/
private void adjustMemberApplyPhotoParagraphStyle(XWPFParagraph paragraph) {
paragraph.setAlignment(ParagraphAlignment.CENTER);
paragraph.setSpacingBefore(0);
paragraph.setSpacingAfter(0);
paragraph.setSpacingBeforeLines(0);
paragraph.setSpacingAfterLines(0);
paragraph.setSpacingBetween(MEMBER_APPLY_PHOTO_PARAGRAPH_LINE_POINT, LineSpacingRule.AT_LEAST);
}
private boolean runContainsMemberApplyPhoto(XWPFRun run) {
if (run == null || run.getCTR() == null) {
return false;
}
for (CTDrawing drawing : run.getCTR().getDrawingArray()) {
for (CTInline inline : drawing.getInlineArray()) {
if (inline.getExtent() != null && isMemberApplyPhotoSize(inline.getExtent().getCx(), inline.getExtent().getCy())) {
return true;
}
}
}
return false;
}
private boolean isMemberApplyPhotoSize(long widthEmu, long heightEmu) {
return Math.abs(widthEmu - MEMBER_APPLY_PHOTO_WIDTH_EMU) <= MEMBER_APPLY_PHOTO_SIZE_TOLERANCE_EMU
&& Math.abs(heightEmu - MEMBER_APPLY_PHOTO_HEIGHT_EMU) <= MEMBER_APPLY_PHOTO_SIZE_TOLERANCE_EMU;
}
}
@@ -54,14 +54,19 @@ const INFO = {
<el-empty v-else :image-size="50" description="暂无家庭成员信息"></el-empty>
</el-descriptions-item>
<el-descriptions-item label="个人学习及工作经历" :span="3">
<div v-if="viewData.personalData" class="text-left w-e-text member-apply-rich-text" v-html="viewData.personalData"></div>
<div v-if="viewData.personalData" class="text-left w-e-text member-apply-rich-text" style="white-space: pre-wrap; word-break: break-all;" v-html="viewData.personalData"></div>
<el-tag type="warning" v-else>暂无</el-tag>
</el-descriptions-item>
<el-descriptions-item label="特长及获奖情况" :span="3">
<div v-if="viewData.specialty" class="text-left w-e-text member-apply-rich-text" v-html="viewData.specialty"></div>
<div v-if="viewData.specialty" class="text-left w-e-text member-apply-rich-text" style="white-space: pre-wrap; word-break: break-all;" v-html="viewData.specialty"></div>
<el-tag type="warning" v-else>暂无</el-tag>
</el-descriptions-item>
<el-descriptions-item label="照片" :span="3" v-if="viewData.photo">
<el-image :src="viewData.photo"
style="width: 120px;height: 150px"
fit="cover"></el-image>
</el-descriptions-item>
<el-descriptions-item label="签字信息" :span="3" v-if="viewData.sign">
<el-image :src="viewData.sign"
@@ -193,13 +193,28 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="个人学习及工作经历" :span="3">
<el-form-item prop="personalData" label="个人学习及工作经历">
<text-editor v-model="formData.personalData"></text-editor>
<el-input type="textarea" :rows="4" v-model="formData.personalData"
placeholder="请输入个人学习及工作经历"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="特长及获奖情况" :span="3">
<el-form-item prop="specialty" label="特长及获奖情况">
<text-editor v-model="formData.specialty"></text-editor>
<el-input type="textarea" :rows="4" v-model="formData.specialty"
maxlength="100" show-word-limit
placeholder="请输入特长及获奖情况"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="照片" :span="3">
<el-form-item prop="photo" label="照片">
<file-upload
:value.sync="formData.photo"
:upload_number="1"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
></file-upload>
</el-form-item>
</el-descriptions-item>
@@ -259,7 +274,29 @@ layout("/layouts/platform.html"){
sex: [{ required: false, message: "必填", trigger: ["change", "blur"] }],
isVoluntary: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
sign: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
photo: [{ required: true, message: "请上传照片", trigger: ["change", "blur"] }],
homeAddress: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
families: [{
validator: (rule, value, callback) => {
// 提交时家庭主要成员至少填写一条,并校验核心成员信息。
if (!value || value.length === 0) {
callback(new Error("请添加家庭主要成员"))
return
}
const hasIncomplete = value.some(item => {
item = item || {}
return !String(item.relation || "").trim()
|| !String(item.name || "").trim()
|| !String(item.unit || "").trim()
})
if (hasIncomplete) {
callback(new Error("请完善家庭主要成员的关系、姓名、工作单位"))
return
}
callback()
},
trigger: ["change", "blur"]
}],
personalData: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
specialty: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
marriage: [{ required: true, message: "必填", trigger: ["change", "blur"] }],
@@ -388,23 +425,27 @@ layout("/layouts/platform.html"){
})
},
onFinishTask() {
this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
const loading = createLoading()
this.$axios.post("/platform/member/apply/submit/submitAgain", {
data: JSON.stringify(this.formData),
taskId: this.taskId
}).then(res => {
if (res.code === 0) {
this.$message.success(res.msg)
commonUtil.pjaxPush("/platform/member/apply/mine")
}
}).finally(() => {
loading.close()
})
this.$refs.formRef.validate((valid) => {
if (valid) {
this.$confirm("您确定要提交吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
const loading = createLoading()
this.$axios.post("/platform/member/apply/submit/submitAgain", {
data: JSON.stringify(this.formData),
taskId: this.taskId
}).then(res => {
if (res.code === 0) {
this.$message.success(res.msg)
commonUtil.pjaxPush("/platform/member/apply/mine")
}
}).finally(() => {
loading.close()
})
})
}
})
},
async init() {
@@ -40,8 +40,19 @@
</el-table>
<el-empty v-else :image-size="50" description="暂无家庭成员信息"></el-empty>
</el-descriptions-item>
<el-descriptions-item label="个人简况" :span="3">
<div v-if="viewData.personalData" class="text-left" v-html="viewData.personalData"></div>
<el-descriptions-item label="个人学习及工作经历" :span="3">
<div v-if="viewData.personalData" class="text-left w-e-text member-apply-rich-text" style="white-space: pre-wrap; word-break: break-all;" v-html="viewData.personalData"></div>
<el-tag type="warning" v-else>暂无</el-tag>
</el-descriptions-item>
<el-descriptions-item label="特长及获奖情况" :span="3">
<div v-if="viewData.specialty" class="text-left w-e-text member-apply-rich-text" style="white-space: pre-wrap; word-break: break-all;" v-html="viewData.specialty"></div>
<el-tag type="warning" v-else>暂无</el-tag>
</el-descriptions-item>
<el-descriptions-item label="照片" :span="3">
<el-image v-if="viewData.photo" :src="viewData.photo"
style="width: 120px;height: 150px"
fit="cover"></el-image>
<el-tag type="warning" v-else>暂无</el-tag>
</el-descriptions-item>
@@ -63,18 +63,24 @@ const INFO = {
<van-cell title="个人学习及工作经历">
<template #label>
<div v-if="viewData.personalData" class="text-left" v-html="viewData.personalData"></div>
<div v-if="viewData.personalData" class="text-left w-e-text member-apply-rich-text" style="white-space: pre-wrap; word-break: break-all;" v-html="viewData.personalData"></div>
<span style="font-size: 14px" v-else>无数据</span>
</template>
</van-cell>
<van-cell title="特长及获奖情况">
<template #label>
<div v-if="viewData.specialty" class="text-left" v-html="viewData.specialty"></div>
<div v-if="viewData.specialty" class="text-left w-e-text member-apply-rich-text" style="white-space: pre-wrap; word-break: break-all;" v-html="viewData.specialty"></div>
<span style="font-size: 14px" v-else>无数据</span>
</template>
</van-cell>
<van-cell title="照片" v-if="viewData.photo">
<van-image :src="viewData.photo"
style="width: 120px;height: 150px"
fit="cover"></van-image>
</van-cell>
<van-cell title="签字" >
<van-image :src="viewData.sign"
v-if="viewData.sign"
@@ -288,11 +288,48 @@ layout("/layouts/platform_h5.html"){
</van-cell-group>
<van-cell-group title="个人学习及工作经历" class="form-section">
<text-editor v-model="formData.personalData"></text-editor>
<van-field
v-model="formData.personalData"
name="personalData"
rows="4"
label=""
type="textarea"
placeholder="请输入个人学习及工作经历"
required
:rules="[{ required: true, message: '请填写个人学习及工作经历' }]"
></van-field>
</van-cell-group>
<van-cell-group title="特长及获奖情况" class="form-section">
<text-editor v-model="formData.specialty"></text-editor>
<van-field
v-model="formData.specialty"
name="specialty"
rows="4"
label=""
type="textarea"
maxlength="100"
show-word-limit
placeholder="请输入特长及获奖情况"
required
:rules="[{ required: true, message: '请填写特长及获奖情况' }]"
></van-field>
</van-cell-group>
<van-cell-group title="照片" class="form-section">
<van-field class="direction-column-field" name="photo" label=""
required :rules="[{ required: true, message: '请上传照片' }]">
<template #input>
<h5-file-upload
slot="input"
:value.sync="formData.photo"
:upload_number="1"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
complete_result
></h5-file-upload>
</template>
</van-field>
</van-cell-group>
<van-cell-group title="签字" class="form-section">
@@ -401,6 +438,9 @@ layout("/layouts/platform_h5.html"){
onSubmit() {
this.$refs.formRef.validate().then(() => {
if (!this.validateFamilies()) {
return
}
if (!this.formData.personalData) {
this.$toast.fail("请填写个人学习及工作经历")
return
@@ -442,6 +482,9 @@ layout("/layouts/platform_h5.html"){
onFinishTask() {
this.$refs.formRef.validate().then(() => {
if (!this.validateFamilies()) {
return
}
if (!this.formData.personalData) {
this.$toast.fail("请填写个人学习及工作经历")
return
@@ -482,6 +525,26 @@ layout("/layouts/platform_h5.html"){
})
},
validateFamilies() {
// 提交时家庭主要成员至少填写一条,并校验核心成员信息。
const families = this.formData.families || []
if (families.length === 0) {
this.$toast.fail("请添加家庭主要成员")
return false
}
const hasIncomplete = families.some(item => {
item = item || {}
return !String(item.relation || "").trim()
|| !String(item.name || "").trim()
|| !String(item.unit || "").trim()
})
if (hasIncomplete) {
this.$toast.fail("请完善家庭主要成员的关系、姓名、工作单位")
return false
}
return true
},
onDateConfirm(value) {
this.formData.birthday = value
this.showDatePicker = false