Compare commits

...
6 Commits
12 changed files with 547 additions and 77 deletions
@@ -1,6 +1,8 @@
package com.ruoyi.business.controller; package com.ruoyi.business.controller;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -169,4 +171,34 @@ public class BizExpertController extends BaseController
ImportResult result = bizExpertService.importExpert(file, updateSupport, operName); ImportResult result = bizExpertService.importExpert(file, updateSupport, operName);
return success(result); return success(result);
} }
/**
* 批量导入执业证书 (zip 压缩包或单文件): 按文件名 (手机号优先/姓名兜底) 匹配专家回填 practice_cert_url.
* 提交即返回 jobId, 前端轮询 GET /business/upload/progress/{jobId} 读进度与结果.
*/
@Log(title = "专家执业证书导入", businessType = BusinessType.IMPORT)
@PostMapping("/importPracticeCert")
public AjaxResult importPracticeCert(MultipartFile file) throws Exception
{
String operName = SecurityUtils.getUsername();
String jobId = bizExpertService.submitCertImport(file, "practice", operName);
Map<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
}
/**
* 批量导入职称证明 (zip 压缩包或单文件): 按文件名 (手机号优先/姓名兜底) 匹配专家回填 title_cert_url.
* 提交即返回 jobId, 前端轮询 GET /business/upload/progress/{jobId} 读进度与结果.
*/
@Log(title = "专家职称证明导入", businessType = BusinessType.IMPORT)
@PostMapping("/importTitleCert")
public AjaxResult importTitleCert(MultipartFile file) throws Exception
{
String operName = SecurityUtils.getUsername();
String jobId = bizExpertService.submitCertImport(file, "title", operName);
Map<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
}
} }
@@ -48,4 +48,17 @@ public interface IBizExpertService
* @return 导入结果 * @return 导入结果
*/ */
ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception; ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception;
/**
* 批量导入证书文件 (执业证书 / 职称证明): 提交即返回 jobId, 后台线程解压 zip (或单文件),
* 按文件名匹配专家 (手机号优先, 姓名唯一命中兜底), 逐个上传 OSS 并覆盖式回填
* practiceCertUrl (certType="practice") 或 titleCertUrl (certType="title").
* 前端轮询 {@code GET /business/upload/progress/{jobId}} 读进度与结果.
*
* @param file .zip 压缩包 或单个 .png/.jpg/.jpeg/.pdf/.webp 文件
* @param certType "practice" | "title"
* @param operName 操作人 (写入 update_by)
* @return jobId (进度查询凭据)
*/
String submitCertImport(MultipartFile file, String certType, String operName) throws Exception;
} }
@@ -1,14 +1,28 @@
package com.ruoyi.business.service.impl; package com.ruoyi.business.service.impl;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -17,7 +31,9 @@ import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizExpertImportVo; import com.ruoyi.business.domain.vo.BizExpertImportVo;
import com.ruoyi.business.mapper.BizExpertMapper; import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.service.IBizExpertService; import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.upload.UploadProgressRegistry;
import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
@@ -46,6 +62,24 @@ public class BizExpertServiceImpl implements IBizExpertService
@Autowired @Autowired
private BizNotifyService bizNotifyService; private BizNotifyService bizNotifyService;
@Autowired
private OssUploader ossUploader;
@Autowired
private UploadProgressRegistry uploadProgressRegistry;
@Autowired
@Qualifier("zipUploadExecutor")
private ExecutorService zipUploadExecutor;
/** 证书类型: 执业证书 → practiceCertUrl + OSS 子目录 ry8080/cert/practice */
private static final String CERT_TYPE_PRACTICE = "practice";
/** 证书类型: 职称证明 → titleCertUrl + OSS 子目录 ry8080/cert/title */
private static final String CERT_TYPE_TITLE = "title";
/** 手机号 (1 开头 11 位) 匹配模式, 用于从证书文件名里提取专家标识 */
private static final Pattern PHONE_PATTERN = Pattern.compile("1\\d{10}");
@Override @Override
public BizExpert getById(Long expertId) public BizExpert getById(Long expertId)
{ return bizExpertMapper.selectByPrimaryKey(expertId); } { return bizExpertMapper.selectByPrimaryKey(expertId); }
@@ -438,4 +472,224 @@ public class BizExpertServiceImpl implements IBizExpertService
this.rowNo = rowNo; this.rowNo = rowNo;
} }
} }
// ==================== 证书批量导入 (执业证书 / 职称证明) ====================
@Override
public String submitCertImport(MultipartFile file, String certType, String operName) throws Exception {
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的证书文件");
boolean practice = CERT_TYPE_PRACTICE.equals(certType);
boolean title = CERT_TYPE_TITLE.equals(certType);
if (!practice && !title) throw new ServiceException("未知证书类型: " + certType);
String jobId = UUID.randomUUID().toString().replace("-", "");
byte[] fileBytes = file.getBytes();
String originalName = file.getOriginalFilename();
uploadProgressRegistry.start(jobId);
// 后台线程无 SecurityContext, 操作人已在请求线程 (controller) 取好
final String type = certType;
final String username = operName;
zipUploadExecutor.submit(() -> {
try {
Map<String, Object> result = doCertImport(fileBytes, originalName, type, jobId, username);
uploadProgressRegistry.finish(jobId, result);
} catch (Exception e) {
log.warn("[expert] 证书批量回填失败 type={} err={}", type, e.getMessage(), e);
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
}
});
return jobId;
}
/** 后台实际处理: 解压 → 匹配 → 上传 OSS → 回填 URL */
private Map<String, Object> doCertImport(byte[] fileBytes, String originalName, String certType,
String jobId, String username) throws Exception {
List<CertFile> files = parseCertFiles(fileBytes, originalName);
if (files.isEmpty()) {
throw new ServiceException("压缩包内没有可识别的证书文件 (支持 .png/.jpg/.jpeg/.pdf/.webp)");
}
// 一次性加载所有专家, 构建 phone/name 索引, 避免每文件一次 SQL
List<BizExpert> allExperts = bizExpertMapper.selectList(new BizExpert());
Map<String, BizExpert> byPhone = new HashMap<>();
Map<String, List<BizExpert>> byName = new HashMap<>();
for (BizExpert e : allExperts) {
if (e.getPhone() != null && !e.getPhone().isEmpty()) byPhone.put(e.getPhone(), e);
if (e.getName() != null && !e.getName().trim().isEmpty()) {
byName.computeIfAbsent(e.getName().trim(), k -> new ArrayList<>()).add(e);
}
}
boolean practice = CERT_TYPE_PRACTICE.equals(certType);
String subDir = practice ? "ry8080/cert/practice" : "ry8080/cert/title";
uploadProgressRegistry.setTotal(jobId, files.size());
int updated = 0;
int processed = 0;
// expertId -> 已拼接的 URL (处理同一专家多文件时避免互相覆盖)
Map<Long, String> accumulated = new HashMap<>();
List<Map<String, Object>> results = new ArrayList<>();
for (CertFile f : files) {
processed++;
Map<String, Object> r = new HashMap<>();
r.put("fileName", f.name);
CertMatch cm = matchExpert(f.name, byPhone, byName);
if (cm.expert == null) {
r.put("matched", false);
r.put("reason", cm.reason);
results.add(r);
uploadProgressRegistry.update(jobId, processed);
continue;
}
BizExpert matched = cm.expert;
String url = ossUploader.uploadWithOriginalName(f.bytes, f.name, subDir);
String existing = accumulated.containsKey(matched.getExpertId())
? accumulated.get(matched.getExpertId())
: (practice ? matched.getPracticeCertUrl() : matched.getTitleCertUrl());
String joined = (existing == null || existing.isEmpty()) ? url : existing + "," + url;
accumulated.put(matched.getExpertId(), joined);
BizExpert entity = new BizExpert();
entity.setExpertId(matched.getExpertId());
entity.setUpdateBy(username);
if (practice) entity.setPracticeCertUrl(joined);
else entity.setTitleCertUrl(joined);
bizExpertMapper.updateByPrimaryKey(entity);
updated++;
r.put("matched", true);
r.put("expertName", matched.getName());
r.put("phone", matched.getPhone());
results.add(r);
uploadProgressRegistry.update(jobId, processed);
}
log.info("[expert] 证书回填完成 type={} 命中{}个 (跳过{}个)", certType, updated, files.size() - updated);
Map<String, Object> result = new HashMap<>();
result.put("updated", updated);
result.put("total", files.size());
result.put("skipped", files.size() - updated);
result.put("results", results);
return result;
}
/**
* 按文件名匹配专家: 手机号优先 (唯一), 姓名兜底 (仅唯一命中才返回).
* 文件名先剥扩展名, 手机号从原串提取; 姓名兜底时剔除证书关键词与分隔符.
* 未命中返回 {@link CertMatch} (expert=null, reason 区分 手机号不存在 / 姓名重名 / 姓名不存在).
*/
private CertMatch matchExpert(String fileName, Map<String, BizExpert> byPhone, Map<String, List<BizExpert>> byName) {
String base = stripExt(fileName);
if (base.isEmpty()) return new CertMatch(null, "文件名无法解析");
Matcher m = PHONE_PATTERN.matcher(base);
if (m.find()) {
String phone = m.group();
BizExpert e = byPhone.get(phone);
if (e != null) return new CertMatch(e, null);
return new CertMatch(null, "手机号 " + phone + " 不存在");
}
String name = stripCertKeywords(base);
if (name.isEmpty()) return new CertMatch(null, "文件名无法解析出手机号或姓名");
List<BizExpert> list = byName.get(name);
if (list == null || list.isEmpty()) return new CertMatch(null, "姓名 " + name + " 不存在");
if (list.size() > 1) return new CertMatch(null, "姓名 " + name + " 重名, 无法唯一定位, 请用手机号命名");
return new CertMatch(list.get(0), null);
}
/** 去掉文件名最后一个扩展名 (如 张三.pdf → 张三) */
private static String stripExt(String name) {
if (name == null) return "";
int dot = name.lastIndexOf('.');
String s = dot > 0 ? name.substring(0, dot) : name;
return s.trim();
}
/** 剔除证书关键词与分隔符, 供姓名兜底匹配 (执业证书_张三 → 张三) */
private static String stripCertKeywords(String base) {
String s = base;
s = s.replaceAll("(?i)(执业证书|职称证明|职业资格证书|资格证书|职称证书|职称|证书|证明)", " ");
s = s.replaceAll("[_\\-\\s]+", " ");
return s.trim();
}
/** 解析输入: zip → 展平所有证书文件; 单文件 → 单条; 不支持的扩展名抛异常 */
private List<CertFile> parseCertFiles(byte[] bytes, String originalName) throws IOException {
String lower = originalName == null ? "" : originalName.toLowerCase();
if (lower.endsWith(".zip")) {
return parseCertZip(bytes);
}
if (isCertExt(originalName)) {
List<CertFile> out = new ArrayList<>();
out.add(new CertFile(originalName, bytes));
return out;
}
throw new ServiceException("不支持的文件类型, 请上传 .zip 压缩包或 .png/.jpg/.jpeg/.pdf/.webp 文件");
}
/** 解压 zip: UTF-8 失败回退 GBK (中文 Windows 打包器), 展平子目录, 只保留证书文件 */
private List<CertFile> parseCertZip(byte[] zipBytes) throws IOException {
try {
return readCertZip(zipBytes, StandardCharsets.UTF_8);
} catch (IllegalArgumentException | MalformedInputException e) {
log.warn("[expert] 证书 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
return readCertZip(zipBytes, Charset.forName("GBK"));
}
}
private List<CertFile> readCertZip(byte[] zipBytes, Charset charset) throws IOException {
List<CertFile> out = new ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
String path = entry.getName();
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
String name = lastSegment(path);
if (!isCertExt(name)) continue;
out.add(new CertFile(name, readAllBytes(zis)));
}
}
return out;
}
private static String lastSegment(String path) {
int i = path.lastIndexOf('/');
return i >= 0 ? path.substring(i + 1) : path;
}
private static boolean isCertExt(String name) {
if (name == null) return false;
String l = name.toLowerCase();
return l.endsWith(".png") || l.endsWith(".jpg") || l.endsWith(".jpeg")
|| l.endsWith(".pdf") || l.endsWith(".webp");
}
private static byte[] readAllBytes(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
return out.toByteArray();
}
/** 证书文件 (解压后的单个文件: 文件名 + 字节) */
private static class CertFile {
final String name;
final byte[] bytes;
CertFile(String name, byte[] bytes) {
this.name = name;
this.bytes = bytes;
}
}
/** 证书文件名 → 专家匹配结果 (expert 为 null 时 reason 说明未命中原因) */
private static class CertMatch {
final BizExpert expert;
final String reason;
CertMatch(BizExpert expert, String reason) {
this.expert = expert;
this.reason = reason;
}
}
} }
+2 -2
View File
@@ -84,10 +84,10 @@
import { useCamera } from '@/composables/useCamera' import { useCamera } from '@/composables/useCamera'
const videoId = 'signin-video' const videoId = 'signin-video'
// A4 纸: 从顶边到底边的一竖条, 水平位置在 60%~70% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏 // A4 纸: 从顶边到底边的一竖条, 水平位置在 75%~85% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
// 签到表支持连拍多张: shots 攒批 + 提交全部 // 签到表支持连拍多张: shots 攒批 + 提交全部
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, shots, addShot, removeShot, submitAll } = const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, shots, addShot, removeShot, submitAll } =
useCamera(videoId, { start: 0.6, end: 0.7, radius: 20 }) useCamera(videoId, { start: 0.75, end: 0.85, radius: 20 })
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
+1 -1
View File
@@ -1,7 +1,7 @@
# 测试环境 API 基路径 (nginx 反向代理前缀) # 测试环境 API 基路径 (nginx 反向代理前缀)
VITE_APP_BASE_API = '/hg-api' VITE_APP_BASE_API = '/hg-api'
# 测试环境静态资源子路径 (nginx 部署在 /hegui/) # 测试环境静态资源子路径 (nginx 部署在 /hg/)
VITE_BASE = '/hg/' VITE_BASE = '/hg/'
# 执行方(供应商)注册跳转地址 (测试环境) # 执行方(供应商)注册跳转地址 (测试环境)
+38
View File
@@ -38,4 +38,42 @@ export function downloadExpertImportTpl() {
method: 'get', method: 'get',
responseType: 'blob' responseType: 'blob'
}) })
}
/**
* 批量导入执业证书 (zip 压缩包或单文件): 后端异步处理, 返回 {jobId}.
* 文件名以手机号命名 (姓名唯一命中兜底), 后端匹配专家并回填 practice_cert_url.
* 上传成功后轮询 queryUploadProgress(jobId) 读进度与结果.
*/
export function importPracticeCert(file) {
const form = new FormData()
form.append('file', file)
return request({
url: '/business/expert/importPracticeCert',
method: 'post',
data: form,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 批量导入职称证明 (zip 压缩包或单文件): 同 importPracticeCert, 回填 title_cert_url.
*/
export function importTitleCert(file) {
const form = new FormData()
form.append('file', file)
return request({
url: '/business/expert/importTitleCert',
method: 'post',
data: form,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 查询批量上传任务进度 (后端异步处理, 前端轮询).
* @returns {Promise<{code,msg,data:{jobId,done,total,finished,error,message,result}}>}
*/
export function queryUploadProgress(jobId) {
return request({ url: `/business/upload/progress/${jobId}`, method: 'get' })
} }
+159 -37
View File
@@ -179,22 +179,50 @@
<ImportResultDialog v-model="importResultOpen" :result="importResult" /> <ImportResultDialog v-model="importResultOpen" :result="importResult" />
<!-- ========== 批量导入执业证书 ========== --> <!-- ========== 批量导入执业证书 ========== -->
<el-dialog v-model="importCertOpen" title="批量导入执业证书" width="520px"> <el-dialog v-model="importCertOpen" title="批量导入执业证书" width="520px" append-to-body :close-on-click-modal="false">
<p style="color:#606266;margin-bottom:8px">提示: 需将证书文件名称修改为手机号进行导入</p> <p class="cert-import-hint">上传 .zip 压缩包(或单个图片/PDF); 文件名以手机号命名和姓名匹配。示例: 张三_13800138000.jpg、张三.pdf(若重名上传不成功)、13800138000.jpg</p>
<el-input v-model="importCertUrl" placeholder="上传文件 URL 或选择文件" /> <el-upload
v-if="importCertOpen"
drag
:auto-upload="false"
:limit="1"
accept=".zip,.png,.jpg,.jpeg,.pdf,.webp"
:on-change="onPracticeCertChange"
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将压缩包/文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="certSubmitting" class="cert-import-progress">
<el-progress :percentage="certProgress" :stroke-width="12" :show-text="false" />
<span class="cert-import-status">{{ certStatus || '上传中' }}</span>
</div>
<template #footer> <template #footer>
<el-button @click="importCertOpen=false">取消</el-button> <el-button @click="importCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportCert">确定</el-button> <el-button type="primary" :loading="certSubmitting" @click="submitCertImport('practice')">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- ========== 批量导入职称证明 ========== --> <!-- ========== 批量导入职称证明 ========== -->
<el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px"> <el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px" append-to-body :close-on-click-modal="false">
<p style="color:#606266;margin-bottom:8px">提示: 需将证明文件名称修改为手机号进行导入</p> <p class="cert-import-hint">上传 .zip 压缩包(或单个图片/PDF); 文件名以手机号命名和姓名匹配。示例: 张三_13800138000.jpg、张三.pdf(若重名上传不成功)、13800138000.jpg</p>
<el-input v-model="importTitleCertUrl" placeholder="上传文件 URL 或选择文件" /> <el-upload
v-if="importTitleCertOpen"
drag
:auto-upload="false"
:limit="1"
accept=".zip,.png,.jpg,.jpeg,.pdf,.webp"
:on-change="onTitleCertChange"
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将压缩包/文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="certSubmitting" class="cert-import-progress">
<el-progress :percentage="certProgress" :stroke-width="12" :show-text="false" />
<span class="cert-import-status">{{ certStatus || '上传中' }}</span>
</div>
<template #footer> <template #footer>
<el-button @click="importTitleCertOpen=false">取消</el-button> <el-button @click="importTitleCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportTitleCert">确定</el-button> <el-button type="primary" :loading="certSubmitting" @click="submitCertImport('title')">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -204,7 +232,7 @@
<el-image <el-image
:src="previewUrl" :src="previewUrl"
fit="contain" fit="contain"
:preview-src-list="[previewUrl]" :preview-src-list="previewUrls"
style="width:100%;max-height:70vh" style="width:100%;max-height:70vh"
loading="lazy" loading="lazy"
> >
@@ -222,11 +250,13 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import GrTable from '@/components/GrTable.vue' import GrTable from '@/components/GrTable.vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { bizList, bizUpdate } from '@/api/public' import { bizList, bizUpdate } from '@/api/public'
import request from '@/utils/request'
import { importPracticeCert, importTitleCert } from '@/api/business/expert'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue' import { Upload } from '@element-plus/icons-vue'
import AuditStatusTag from '@/components/AuditStatusTag.vue' import AuditStatusTag from '@/components/AuditStatusTag.vue'
@@ -353,17 +383,25 @@ function onViewAuditOpinion(row) {
// ===== 证书查看/下载 ===== // ===== 证书查看/下载 =====
const previewOpen = ref(false) const previewOpen = ref(false)
const previewUrl = ref('') const previewUrl = ref('')
const previewUrls = ref([])
function splitUrls(url) {
return (url || '').split(',').map(s => s.trim()).filter(Boolean)
}
function onPreviewCert(url) { function onPreviewCert(url) {
if (!url) return const urls = splitUrls(url)
previewUrl.value = url if (!urls.length) return
previewUrl.value = urls[0]
previewUrls.value = urls
previewOpen.value = true previewOpen.value = true
} }
function onDownloadCert(url, phone) { function onDownloadCert(url, phone) {
if (!url) return const urls = splitUrls(url)
const a = document.createElement('a') urls.forEach((u, i) => {
a.href = url const a = document.createElement('a')
a.download = `${phone || 'cert'}_${Date.now()}` a.href = u
document.body.appendChild(a); a.click(); document.body.removeChild(a) a.download = urls.length > 1 ? `${phone || 'cert'}_${i + 1}_${Date.now()}` : `${phone || 'cert'}_${Date.now()}`
document.body.appendChild(a); a.click(); document.body.removeChild(a)
})
} }
// ===== 批量导入专家 (沿用 manager 最佳实践) ===== // ===== 批量导入专家 (沿用 manager 最佳实践) =====
@@ -435,33 +473,112 @@ function onImportError(err) {
importResult.value = null importResult.value = null
} }
// ===== 批量导入执业证书 ===== // ===== 批量导入执业证书 / 职称证明 (zip 或单文件 → 后端异步回填, 前端轮询进度) =====
const importCertOpen = ref(false) const importCertOpen = ref(false)
const importCertUrl = ref('') const importTitleCertOpen = ref(false)
function openImportPracticeCert() { importCertUrl.value = ''; importCertOpen.value = true } const practiceCertFile = ref(null)
async function submitImportCert() { const titleCertFile = ref(null)
if (!importCertUrl.value) return ElMessage.warning('请选择文件') const certSubmitting = ref(false)
importing.value = true const certProgress = ref(0)
await new Promise(r => setTimeout(r, 500)) const certStatus = ref('')
importing.value = false let certPollTimer = null
ElMessage.info('已提交批量导入执业证书任务 — 待后端实现')
importCertOpen.value = false function onPracticeCertChange(f) { practiceCertFile.value = (f && f.raw) || null }
function onTitleCertChange(f) { titleCertFile.value = (f && f.raw) || null }
function openImportPracticeCert() {
practiceCertFile.value = null
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
importCertOpen.value = true
}
function openImportTitleCert() {
titleCertFile.value = null
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
importTitleCertOpen.value = true
} }
// ===== 批量导入职称证明 ===== async function submitCertImport(type) {
const importTitleCertOpen = ref(false) const file = type === 'practice' ? practiceCertFile.value : titleCertFile.value
const importTitleCertUrl = ref('') if (!file) return ElMessage.warning('请选择证书文件')
function openImportTitleCert() { importTitleCertUrl.value = ''; importTitleCertOpen.value = true } certSubmitting.value = true
async function submitImportTitleCert() { certProgress.value = 0
if (!importTitleCertUrl.value) return ElMessage.warning('请选择文件') certStatus.value = ''
importing.value = true let jobId = null
await new Promise(r => setTimeout(r, 500)) try {
importing.value = false const resp = type === 'practice' ? await importPracticeCert(file) : await importTitleCert(file)
ElMessage.info('已提交批量导入职称证明任务 — 待后端实现') jobId = (resp && resp.data && resp.data.jobId) || null
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
certSubmitting.value = false
return
}
if (!jobId) {
ElMessage.error('上传失败: 未获取任务编号')
certSubmitting.value = false
return
}
certProgress.value = 100
pollCertProgress(jobId, type)
}
function pollCertProgress(jobId, type) {
clearInterval(certPollTimer)
certPollTimer = setInterval(async () => {
let p = null
try {
const resp = await request.get(`/business/upload/progress/${jobId}`, { __silentError: true })
p = (resp && resp.data) || null
} catch (e) {
clearInterval(certPollTimer)
ElMessage.error(e?.msg || '查询进度失败')
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
return
}
if (!p) { certStatus.value = '服务器准备中…'; return }
if (p.finished) {
clearInterval(certPollTimer)
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
if (p.error) { ElMessage.error(p.message || '导入失败'); return }
showCertResult(type, p.result)
return
}
certStatus.value = p.total > 0 ? `服务器处理中 ${p.done}/${p.total}` : '服务器处理中…'
}, 800)
}
function showCertResult(type, result) {
const data = result || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
const label = type === 'practice' ? '执业证书' : '职称证明'
if (skipped.length) {
// 复用 ImportResultDialog 弹失败明细 (文件名 + 具体原因: 重名/手机号不存在/姓名不存在)
importResult.value = {
okNum: updated,
ngNum: skipped.length,
ngList: skipped.map((s, i) => ({ rowNum: i + 1, message: `${s.fileName || '未知文件'} —— ${s.reason || '未匹配'}` }))
}
importResultOpen.value = true
} else {
ElMessage.success(`${label}已回填 ${updated}/${total} 位专家`)
}
importCertOpen.value = false
importTitleCertOpen.value = false importTitleCertOpen.value = false
load()
} }
onMounted(load) onMounted(load)
onUnmounted(() => { clearInterval(certPollTimer) })
// 从 ExpertNew 跳回来时刷新列表 // 从 ExpertNew 跳回来时刷新列表
watch(() => route.fullPath, () => { watch(() => route.fullPath, () => {
if (route.path === listBasePath.value) load() if (route.path === listBasePath.value) load()
@@ -477,6 +594,11 @@ watch(() => route.fullPath, () => {
.cert-preview { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f5f7fa; border-radius: 4px; padding: 8px; } .cert-preview { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f5f7fa; border-radius: 4px; padding: 8px; }
.cert-preview-error { color: #909399; padding: 40px 0; } .cert-preview-error { color: #909399; padding: 40px 0; }
.cert-import-hint { color: #606266; margin-bottom: 10px; font-size: 13px; line-height: 1.6; }
.cert-import-hint em { color: #e6a23c; font-style: normal; font-weight: 500; }
.cert-import-progress { margin-top: 12px; }
.cert-import-status { font-size: 13px; color: #909399; margin-top: 4px; display: inline-block; }
/* ======================================== /* ========================================
移动端适配 (≤768px) 移动端适配 (≤768px)
- 卡片 padding 收窄 - 卡片 padding 收窄
+29 -26
View File
@@ -75,24 +75,17 @@
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="开始时间" prop="startTime"> <el-form-item label="开始时间" prop="startTime">
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /> <el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择开始时间" style="width:100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="结束时间" prop="endTime"> <el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /> <el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择结束时间" style="width:100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="发布时间" prop="publishTime">
<el-date-picker v-model="form.publishTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
:disabled-date="disableFutureDate" placeholder="请选择发布时间" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<!-- ========== 第二区块:角色劳务设置 ========== --> <!-- ========== 第二区块:角色劳务设置 ========== -->
<div class="new-card-title">角色劳务设置</div> <div class="new-card-title">角色劳务设置</div>
<div v-for="(r, idx) in form.roleRows" :key="idx" class="role-row"> <div v-for="(r, idx) in form.roleRows" :key="idx" class="role-row">
@@ -132,6 +125,15 @@
</div> </div>
</div> </div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="发布日期" prop="publishTime">
<el-date-picker v-model="form.publishTime" type="date" value-format="YYYY-MM-DD"
:disabled-date="disableFutureDate" placeholder="请选择发布日期" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 底部按钮 (skill: 用 el-form-item 包裹, 不另起 div.form-actions) --> <!-- 底部按钮 (skill: 用 el-form-item 包裹, 不另起 div.form-actions) -->
<el-form-item class="form-actions"> <el-form-item class="form-actions">
<el-button @click="confirmCancel">取消</el-button> <el-button @click="confirmCancel">取消</el-button>
@@ -161,27 +163,27 @@ const fileInputRef = ref(null)
function defaultRoleRow() { return { role: '', customName: '', amount: 0 } } function defaultRoleRow() { return { role: '', customName: '', amount: 0 } }
function defaultNotice(key, label) { return { key, label, url: '', name: '' } } function defaultNotice(key, label) { return { key, label, url: '', name: '' } }
// 当前本地时间 "yyyy-MM-dd HH:mm:ss" (发布时间默认值/校验用, 与 el-date-picker 的本地时间口径一致; 不能用 toISOString 的 UTC) // 今天 "yyyy-MM-dd" (发布日期默认值/校验用, 取本地日期; 不能用 toISOString 的 UTC)
function nowLocalDatetime() { function todayLocalDate() {
const d = new Date() const d = new Date()
const p = n => String(n).padStart(2, '0') const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
} }
// 发布时间: 禁用今天之后的日历日期 (未来日期不可选) // 发布日期: 禁用今天之后的日历日期 (未来日期不可选)
function disableFutureDate(date) { function disableFutureDate(date) {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
return date.getTime() > today.getTime() return date.getTime() > today.getTime()
} }
// 发布时间校验: 必填 + 不能晚于当前时间 // 发布日期校验: 必填 + 不能晚于今天
// value 是 "YYYY-MM-DD HH:mm:ss" 定长补零字符串, 字典序 = 时间序, 直接字符串比较 (无需 Date 解析/时区) // value 是 "YYYY-MM-DD" 定长补零字符串, 字典序 = 日期序, 直接字符串比较 (无需 Date 解析/时区)
function validatePublishTime(rule, value, callback) { function validatePublishTime(rule, value, callback) {
if (!value) { if (!value) {
callback(new Error('请选择发布时间')) callback(new Error('请选择发布日期'))
return return
} }
if (value > nowLocalDatetime()) { if (value > todayLocalDate()) {
callback(new Error('发布时间不能晚于当前时间')) callback(new Error('发布日期不能晚于今天'))
return return
} }
callback() callback()
@@ -199,8 +201,8 @@ const form = reactive({
isBidProject: 'N', isBidProject: 'N',
totalSessions: 0, totalAmount: 0, manageFee: 0, totalSessions: 0, totalAmount: 0, manageFee: 0,
startTime: '', endTime: '', startTime: '', endTime: '',
// 发布时间: 默认打开页面时的本地时间, 用户可修改 (不可设未来时间) // 发布日期: 默认今天, 用户可修改 (不可设未来日期)
publishTime: nowLocalDatetime(), publishTime: todayLocalDate(),
roleRows: [ roleRows: [
{ role: '主席', customName: '', amount: 0 }, { role: '主席', customName: '', amount: 0 },
{ role: '主持', customName: '', amount: 0 }, { role: '主持', customName: '', amount: 0 },
@@ -319,8 +321,8 @@ async function loadProject() {
form.manageFee = p.manageFee || 0 form.manageFee = p.manageFee || 0
form.startTime = p.startTime || '' form.startTime = p.startTime || ''
form.endTime = p.endTime || '' form.endTime = p.endTime || ''
// 发布时间: 已有值反显, 否则沿用页面打开时的默认时间 // 发布日期: 已有值反显 (后端 "yyyy-MM-dd HH:mm:ss", 取前 10 位日期), 否则默认今天
form.publishTime = p.publishTime || nowLocalDatetime() form.publishTime = (p.publishTime || '').slice(0, 10) || todayLocalDate()
form.leadUserId = p.leadUserId || null form.leadUserId = p.leadUserId || null
form.isBidProject = p.isBidProject || 'N' form.isBidProject = p.isBidProject || 'N'
// 角色劳务反显: biz_project.role_labor JSON 数组 [{role, customName, amount}] // 角色劳务反显: biz_project.role_labor JSON 数组 [{role, customName, amount}]
@@ -435,10 +437,11 @@ async function submit(mode = 'save') {
else if (n.key === 'notice') payload.publishUrl = n.url else if (n.key === 'notice') payload.publishUrl = n.url
else if (n.key === 'schedule') payload.scheduleUrl = n.url else if (n.key === 'schedule') payload.scheduleUrl = n.url
} }
// 发布时设置 is_published='Y' + publish_time (用表单里用户可编辑的发布时间) // 发布时设置 is_published='Y' + publish_time (用表单里用户可编辑的发布日期)
// 表单是纯日期 "yyyy-MM-dd", 后端 publishTime 是 Date(@JsonFormat "yyyy-MM-dd HH:mm:ss"), 补 00:00:00 提交
if (mode === 'publish') { if (mode === 'publish') {
payload.isPublished = 'Y' payload.isPublished = 'Y'
payload.publishTime = form.publishTime payload.publishTime = form.publishTime ? `${form.publishTime} 00:00:00` : ''
} }
// 多执行方分配已移至"项目分配"页面单独管理 // 多执行方分配已移至"项目分配"页面单独管理
try { try {
+4 -8
View File
@@ -19,11 +19,11 @@
<el-form-item label="总场次/总期数"> <el-form-item label="总场次/总期数">
<span class="form-value">{{ snap.totalSessions }}</span> <span class="form-value">{{ snap.totalSessions }}</span>
</el-form-item> </el-form-item>
<el-form-item label="项目创建人"> <el-form-item label="项目开始时间">
<span class="form-value">{{ snap.createUserName }}</span> <span class="form-value">{{ snap.projectStartTime }}</span>
</el-form-item> </el-form-item>
<el-form-item label="项目创建时间"> <el-form-item label="项目结束时间">
<span class="form-value">{{ snap.createTime }}</span> <span class="form-value">{{ snap.projectEndTime }}</span>
</el-form-item> </el-form-item>
</div> </div>
@@ -109,8 +109,6 @@ const snap = reactive({
projectName: '', projectName: '',
totalSessions: '', totalSessions: '',
sponsorOrgName: '', sponsorOrgName: '',
createUserName: '',
createTime: '',
projectStartTime: '', projectStartTime: '',
projectEndTime: '' projectEndTime: ''
}) })
@@ -162,8 +160,6 @@ async function loadProjectSnapshot(pid) {
snap.projectName = d.projectName || '' snap.projectName = d.projectName || ''
snap.totalSessions = d.totalSessions ?? '' snap.totalSessions = d.totalSessions ?? ''
snap.sponsorOrgName = d.sponsorOrgName || '' snap.sponsorOrgName = d.sponsorOrgName || ''
snap.createUserName = d.createUserName || ''
snap.createTime = d.createTime || ''
snap.projectStartTime = d.startTime || '' snap.projectStartTime = d.startTime || ''
snap.projectEndTime = d.endTime || '' snap.projectEndTime = d.endTime || ''
// executor: "总场次" = 分配给本执行方(公司)的场次, 不是项目总场次 // executor: "总场次" = 分配给本执行方(公司)的场次, 不是项目总场次
+7 -1
View File
@@ -90,7 +90,13 @@ async function load() {
} }
function onSearch() { page.pageNum = 1; load() } function onSearch() { page.pageNum = 1; load() }
function onReset() { q.value = { projectNo: '', title: '' }; onSearch() } function onReset() { q.value = { projectNo: '', title: '' }; onSearch() }
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' } // 只显示年月日, 不显示时分秒 (与 portal/Publicity.vue fmtDate 口径一致)
function fmt(t) {
if (!t) return ''
const d = new Date(t)
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
const detailOpen = ref(false) const detailOpen = ref(false)
const current = ref(null) const current = ref(null)
+7 -1
View File
@@ -79,7 +79,13 @@ async function load() {
} }
function onSearch() { page.pageNum = 1; load() } function onSearch() { page.pageNum = 1; load() }
function onReset() { q.value = { projectNo: '', title: '' }; onSearch() } function onReset() { q.value = { projectNo: '', title: '' }; onSearch() }
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' } // 只显示年月日, 不显示时分秒 (与 portal/Publicity.vue fmtDate 口径一致)
function fmt(t) {
if (!t) return ''
const d = new Date(t)
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
const detailOpen = ref(false) const detailOpen = ref(false)
const current = ref(null) const current = ref(null)
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig(({ mode }) => {
// 开发 API 前缀 = /dev-api (Vite dev proxy), 生产 = /hg-api (nginx 反代) // 开发 API 前缀 = /dev-api (Vite dev proxy), 生产 = /hg-api (nginx 反代)
const apiPath = env.VITE_APP_BASE_API || '/dev-api' const apiPath = env.VITE_APP_BASE_API || '/dev-api'
return { return {
// 部署基路径: prod = / (.env.production), test = /hegui/ (.env.test), 开发默认 / // 部署基路径: prod = / (.env.production), test = /hg/ (.env.test), 开发默认 /
base: env.VITE_BASE || '/', base: env.VITE_BASE || '/',
plugins: [ plugins: [
vue(), vue(),