sids = body.getStaffUserIds();
if (sids == null || sids.isEmpty()) {
// 向后兼容: 单值 staffUserId
@@ -532,6 +546,8 @@ public class BizProjectController extends BaseController
if (body.getProjectId() == null || body.getMonitorUserId() == null) {
throw new IllegalArgumentException("projectId / monitorUserId 必填");
}
+ // 已结题项目不能分配 (抛 ServiceException → 本循环 catch 计入 errors)
+ requireAssignableProject(parseProjectId(body.getProjectId()));
body.setCreateBy(loginName);
body.setSponsorOrgId(bizOrgService.selectOrgIdByUserId(loginUid));
// 通知去重: 拉旧分配, 找同 monitorUserId, 比较 assignDesc/assignPoints 是否变化
diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java
new file mode 100644
index 0000000..c1ce976
--- /dev/null
+++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java
@@ -0,0 +1,106 @@
+package com.ruoyi.business.scheduler;
+
+import java.net.URLEncoder;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.env.Environment;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ruoyi.business.supplier.SupplierAccountApiCodec;
+import com.ruoyi.common.utils.http.HttpUtils;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 供应商账号数据拉取调度器: 每分钟拉取最近 5 分钟更新的账号, 解密后打印.
+ *
+ * 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts}
+ * 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回.
+ * 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密.
+ *
+ * 说明: 只打印不落库 (后续需要持久化时再扩展).
+ */
+@Slf4j
+@Component
+public class SupplierAccountPullScheduler
+{
+ private static final String KEY = "supplier-account-api-aes.key";
+ private static final String BASE_URL = "supplier-account-api.base-url";
+ private static final String PAGE_SIZE = "supplier-account-api.page-size";
+ private static final String PULL_MINUTES = "supplier-account-api.pull-minutes";
+
+ private static final String DEFAULT_BASE_URL =
+ "https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts";
+
+ private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ @Autowired
+ private Environment env;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Scheduled(fixedRate = 60_000, initialDelay = 30_000)
+ public void pullAccounts()
+ {
+ try
+ {
+ String key = env.getProperty(KEY);
+ if (key == null || key.isEmpty())
+ {
+ log.warn("[SupplierAccountPull] 未配置 {} , 跳过", KEY);
+ return;
+ }
+ String baseUrl = env.getProperty(BASE_URL, DEFAULT_BASE_URL);
+ int pageSize = env.getProperty(PAGE_SIZE, Integer.class, 20);
+ int pullMinutes = env.getProperty(PULL_MINUTES, Integer.class, 5*24*60*60);
+ String lastUpdatedTime = LocalDateTime.now().minusMinutes(pullMinutes).format(TIME_FMT);
+
+ int pageNum = 1;
+ int fetched = 0;
+ while (true)
+ {
+ String param = "lastUpdatedTime=" + URLEncoder.encode(lastUpdatedTime, "UTF-8")
+ + "&pageNum=" + pageNum + "&pageSize=" + pageSize;
+ String resp = HttpUtils.sendGet(baseUrl, param);
+ if (resp == null || resp.isEmpty())
+ {
+ break;
+ }
+ JsonNode root = objectMapper.readTree(resp);
+ String encrypted = root.path("data").asText("");
+ if (encrypted.isEmpty())
+ {
+ log.info("[SupplierAccountPull] page={} 无 data 字段 (code={}, msg={}), 结束",
+ pageNum, root.path("code").asText(), root.path("msg").asText());
+ break;
+ }
+
+ String plain = SupplierAccountApiCodec.decrypt(encrypted, key);
+ JsonNode inner = objectMapper.readTree(plain);
+ int total = inner.path("total").asInt(0);
+ JsonNode rows = inner.path("rows");
+ int size = rows.isArray() ? rows.size() : 0;
+ fetched += size;
+
+ // 只打印即可: 整页明文 JSON 打出来 (供观察/后续落库)
+ log.info("[SupplierAccountPull] page={} total={} 本页={} 明文: {}", pageNum, total, size, plain);
+
+ if (size == 0 || fetched >= total)
+ {
+ break;
+ }
+ pageNum++;
+ }
+ log.info("[SupplierAccountPull] 本轮完成, 共 {} 条", fetched);
+ }
+ catch (Exception e)
+ {
+ log.warn("[SupplierAccountPull] 拉取失败 (跳过, 下分钟再试)", e);
+ }
+ }
+}
diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java
index c56fd4e..aa9b06a 100644
--- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java
+++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java
@@ -3,7 +3,9 @@ package com.ruoyi.business.service.impl;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizMeeting;
@@ -67,7 +69,19 @@ public class BizMeetingServiceImpl implements IBizMeetingService
if (entity.getServiceAuditStage() == null || entity.getServiceAuditStage().isEmpty()) {
entity.setServiceAuditStage("NOT_SUBMITTED");
}
- return bizMeetingMapper.insert(entity);
+ try {
+ return bizMeetingMapper.insert(entity);
+ }
+ catch (DuplicateKeyException e) {
+ // 入库时 meeting_id 撞库 (Redis 序列被重置/回退): 自动加 10 + 随机(0~10) 重新取一次, 不直接报错
+ Long oldId = entity.getMeetingId();
+ if (oldId == null) {
+ throw e;
+ }
+ long retryId = oldId + 10L + ThreadLocalRandom.current().nextInt(11);
+ entity.setMeetingId(retryId);
+ return bizMeetingMapper.insert(entity);
+ }
}
/**
diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java
index 8658ab1..bf5aff8 100644
--- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java
+++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java
@@ -79,9 +79,14 @@ public class BizPersonServiceImpl implements IBizPersonService
}
// 1. 创建 sys_user 子账号
- // 继承主账号 role_type, 避免子账号被 sys_user.role_type DEFAULT 'executor' 覆盖
- // (之前不写 roleType 时, sponsor 主账号的子账号会被 DEFAULT 错位成 executor)
+ // role_type 取 person.unitType (sponsor/executor/doctor), 而非继承创建者角色:
+ // 否则 admin/manager 在 admin/sponsor-people 建人会把子账号错位成 admin/manager (后台管理员).
+ // 仅当 unitType 缺失时才回退到主账号 role_type 兜底.
SysUser mainUser = mainUserId == null ? null : sysUserMapper.selectUserById(mainUserId);
+ String roleType = entity.getUnitType();
+ if (roleType == null || roleType.isEmpty()) {
+ roleType = mainUser != null ? mainUser.getRoleType() : null;
+ }
SysUser newUser = new SysUser();
newUser.setUserName(entity.getLoginUsername());
newUser.setNickName(entity.getName());
@@ -92,8 +97,8 @@ public class BizPersonServiceImpl implements IBizPersonService
newUser.setParentUserId(mainUserId);
newUser.setStatus("0");
newUser.setDelFlag("0");
- if (mainUser != null && mainUser.getRoleType() != null) {
- newUser.setRoleType(mainUser.getRoleType());
+ if (roleType != null) {
+ newUser.setRoleType(roleType);
}
newUser.setCreateBy(SecurityUtils.getUsername());
// mybatis useGeneratedKeys 自动回填 userId
diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountApiCodec.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountApiCodec.java
new file mode 100644
index 0000000..b96b2ef
--- /dev/null
+++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountApiCodec.java
@@ -0,0 +1,73 @@
+package com.ruoyi.business.supplier;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * 供应商账号接口 AES 解密工具 (对应 refer/BiddingSupplierAccountApiCodec).
+ *
+ * 接口返回的 {@code data} 字段格式: {@code BSA.v1...}
+ *
+ * - BSA — 固定前缀 (Bidding Supplier Account)
+ * - v1 — 版本号
+ * - keyId — 密钥标识, 对应配置 {@code supplier-account-api-aes.key-id}
+ * (单密钥场景忽略, 密钥旋转时按此取对应 key)
+ * - nonce — 12 字节随机 IV, URL-safe Base64 编码 (无填充)
+ * - ciphertext — 密文, URL-safe Base64 编码 (无填充, 末尾带 16 字节 GCM 认证标签)
+ *
+ * 算法: AES-256/GCM/NoPadding (密钥 32 字节, GCM 认证标签 128 bit).
+ */
+public class SupplierAccountApiCodec
+{
+ private static final int GCM_TAG_BITS = 128;
+ private static final String TRANSFORMATION = "AES/GCM/NoPadding";
+
+ private SupplierAccountApiCodec() {}
+
+ /**
+ * 解密接口返回的 data 字段, 得到明文 JSON 字符串.
+ *
+ * @param data 接口返回的 data 字段 ({@code BSA.v1.keyId.nonce.ciphertext})
+ * @param key AES 密钥 (Base64 编码, 解码后 32 字节)
+ */
+ public static String decrypt(String data, String key)
+ {
+ if (data == null || data.isEmpty())
+ {
+ return "";
+ }
+ String[] parts = data.split("\\.");
+ if (parts.length < 5)
+ {
+ throw new IllegalArgumentException("供应商接口 data 格式非法 (期望 BSA.v1.keyId.nonce.ciphertext)");
+ }
+ byte[] keyBytes = Base64.getDecoder().decode(key);
+ byte[] nonce = decodeBase64Url(parts[3]);
+ byte[] ciphertext = decodeBase64Url(parts[4]);
+ try
+ {
+ Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"),
+ new GCMParameterSpec(GCM_TAG_BITS, nonce));
+ return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
+ }
+ catch (Exception e)
+ {
+ throw new IllegalStateException("供应商接口数据解密失败: " + e.getMessage(), e);
+ }
+ }
+
+ /** URL-safe Base64 → bytes (兼容 '-'/'_' 且无填充) */
+ private static byte[] decodeBase64Url(String s)
+ {
+ String b = s.replace('-', '+').replace('_', '/');
+ while (b.length() % 4 != 0)
+ {
+ b += "=";
+ }
+ return Base64.getDecoder().decode(b);
+ }
+}
diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml
index 77035d3..5e8ab7a 100644
--- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml
+++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml
@@ -49,15 +49,17 @@
p.is_deleted = 0
+ and p.plan_name like concat('%', #{planName}, '%')
and p.plan_direction_id = #{planDirectionId}
and p.plan_category = #{planCategory}
+ and p.project_form = #{projectForm}
and p.submitter_id = #{submitterId}
and p.status = #{status}
- and p.remark = #{remark}
+ and p.remark like concat('%', #{remark}, '%')
order by p.plan_id desc
diff --git a/ry-vue3/.env.development b/ry-vue3/.env.development
index 7c893ea..107ed66 100644
--- a/ry-vue3/.env.development
+++ b/ry-vue3/.env.development
@@ -1,2 +1,2 @@
-# 开发环境 API 基路径: 直连远程后端 (绕过 Vite proxy, 避免 HTTPS 代理卡住)
-VITE_APP_BASE_API = 'https://risingdoctor.com/hg-api'
+# 开发环境 API 基路径: 本地后端 (走 Vite proxy 转发到 localhost:8080)
+VITE_APP_BASE_API = '/dev-api'
diff --git a/ry-vue3/src/components/OssFileUploader.vue b/ry-vue3/src/components/OssFileUploader.vue
index 4156914..8629f93 100644
--- a/ry-vue3/src/components/OssFileUploader.vue
+++ b/ry-vue3/src/components/OssFileUploader.vue
@@ -10,8 +10,12 @@
/>
-->
-
-
+
+
+ 上传中
+
+
+
{{ placeholder }}
{{ hint }}
@@ -69,6 +73,7 @@ const emit = defineEmits(['update:modelValue', 'update:name'])
const fileInput = ref(null)
const uploading = ref(false)
+const progress = ref(0)
const fileName = computed(() => {
// 优先用调用方回传的原文件名 (v-model:name); 没有则从 OSS key 还原原名
@@ -132,8 +137,9 @@ async function onFileChange(e) {
return ElMessage.warning(`文件大小不能超过 ${props.maxSize}MB`)
}
uploading.value = true
+ progress.value = 0
try {
- const url = await uploadToOss(file, props.dir)
+ const url = await uploadToOss(file, props.dir, (p) => { progress.value = p })
emit('update:modelValue', url)
emit('update:name', file.name)
ElMessage.success('上传成功')
@@ -167,6 +173,18 @@ function onRemove() {
.ht-file-upload.has-file { border-style: solid; border-color: #52c41a; background: #fff; }
.ht-file-upload.is-block { width: 100%; }
.ht-file-upload.readonly { cursor: default; }
+.ht-file-upload.uploading { cursor: default; }
+
+/* 上传进度条 */
+.ht-file-progress {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+.progress-label { font-size: 13px; color: #606266; white-space: nowrap; flex-shrink: 0; }
+.progress-bar { flex: 1; }
.ht-file-placeholder {
flex: 1;
diff --git a/ry-vue3/src/components/PortalNavbar.vue b/ry-vue3/src/components/PortalNavbar.vue
new file mode 100644
index 0000000..8902d4e
--- /dev/null
+++ b/ry-vue3/src/components/PortalNavbar.vue
@@ -0,0 +1,410 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ry-vue3/src/utils/oss.js b/ry-vue3/src/utils/oss.js
index ee5b0ab..abfb9b5 100644
--- a/ry-vue3/src/utils/oss.js
+++ b/ry-vue3/src/utils/oss.js
@@ -43,7 +43,7 @@ function getMimeByExt(name) {
return map[ext] || 'application/octet-stream'
}
-export async function uploadToOss(file, dir) {
+export async function uploadToOss(file, dir, onProgress) {
const sign = await getOssSign(dir)
// 原文件名进 OSS key: 原始名称_时间戳_随机串.原扩展名 (保留中文, 仅清洗 URL/路径危险字符)
// 这样上传控件能从 URL 里还原原名显示, 无需额外 name 字段/列
@@ -67,14 +67,25 @@ export async function uploadToOss(file, dir) {
// ⚠️ 不要手动设置 Content-Type header,会让 multipart/form-data boundary 丢失
// FormData 会自动生成正确的 multipart 格式
- const resp = await fetch(sign.host, {
- method: 'POST',
- body: fd
+ // 用 XMLHttpRequest 代替 fetch: fetch 拿不到上传进度, XHR 的 upload.onprogress 可回调进度
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest()
+ xhr.open('POST', sign.host)
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable && onProgress) {
+ onProgress(Math.min(100, Math.round((e.loaded / e.total) * 100)))
+ }
+ }
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ // 返回 URL 时对路径逐段编码 (保留 / 分隔), 让含中文/特殊字符的 key 在所有下载场景下都是 ASCII 安全 URL
+ // OSS 对象 key 本身仍是原始值 (FormData 的 key 字段未编码), 下载时 OSS 会自动把 %XX 还原
+ resolve(sign.host + '/' + key.split('/').map(encodeURIComponent).join('/'))
+ } else {
+ reject(new Error('OSS 上传失败: HTTP ' + xhr.status))
+ }
+ }
+ xhr.onerror = () => reject(new Error('OSS 上传失败: 网络错误'))
+ xhr.send(fd)
})
- if (!resp.ok) {
- throw new Error('OSS 上传失败: HTTP ' + resp.status)
- }
- // 返回 URL 时对路径逐段编码 (保留 / 分隔), 让含中文/特殊字符的 key 在所有下载场景下都是 ASCII 安全 URL
- // OSS 对象 key 本身仍是原始值 (FormData 的 key 字段未编码), 下载时 OSS 会自动把 %XX 还原
- return sign.host + '/' + key.split('/').map(encodeURIComponent).join('/')
}
\ No newline at end of file
diff --git a/ry-vue3/src/views/auth/Login.vue b/ry-vue3/src/views/auth/Login.vue
index b5f6837..9df1442 100644
--- a/ry-vue3/src/views/auth/Login.vue
+++ b/ry-vue3/src/views/auth/Login.vue
@@ -358,6 +358,8 @@ const roleHome = {
/** redirect 是否属于当前角色: 防止 doctor 拿到 manager 的 redirect 后跳过去被踢回 login ("现在评审专家登录后不跳转" 的根本原因) */
function redirectBelongsToRole(path, role) {
if (!path) return false
+ // 公开门户详情页 (专项计划详情) 允许回跳: 未登录点击计划详情 → 登录后回详情
+ if (path.startsWith('/special-plan/')) return true
// role=doctor, path=/doctor/xxx → true
// role=doctor, path=/manager/xxx → false
return path === roleHome[role] || path.startsWith('/' + role + '/')
diff --git a/ry-vue3/src/views/doctor/Meetings.vue b/ry-vue3/src/views/doctor/Meetings.vue
index 6fe86e2..77790ff 100644
--- a/ry-vue3/src/views/doctor/Meetings.vue
+++ b/ry-vue3/src/views/doctor/Meetings.vue
@@ -14,7 +14,7 @@
-
+
diff --git a/ry-vue3/src/views/doctor/Projects.vue b/ry-vue3/src/views/doctor/Projects.vue
index 6ba24cd..6164ba3 100644
--- a/ry-vue3/src/views/doctor/Projects.vue
+++ b/ry-vue3/src/views/doctor/Projects.vue
@@ -10,7 +10,7 @@
-
+
diff --git a/ry-vue3/src/views/executor-people/ExecutorPersonNew.vue b/ry-vue3/src/views/executor-people/ExecutorPersonNew.vue
index 5deb0d0..08d0b7b 100644
--- a/ry-vue3/src/views/executor-people/ExecutorPersonNew.vue
+++ b/ry-vue3/src/views/executor-people/ExecutorPersonNew.vue
@@ -26,10 +26,10 @@
-
+
-
+
@@ -86,7 +86,9 @@ const rules = {
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式错误', trigger: 'blur' }
],
- orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }]
+ orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }],
+ department: [{ required: true, message: '请输入部门', trigger: 'blur' }],
+ position: [{ required: true, message: '请输入职务', trigger: 'blur' }]
}
function goBack() {
diff --git a/ry-vue3/src/views/executor/Meetings.vue b/ry-vue3/src/views/executor/Meetings.vue
index f0efaf9..65cf7ff 100644
--- a/ry-vue3/src/views/executor/Meetings.vue
+++ b/ry-vue3/src/views/executor/Meetings.vue
@@ -4,7 +4,7 @@
-
+
@@ -37,7 +37,7 @@
-
+
diff --git a/ry-vue3/src/views/executor/Projects.vue b/ry-vue3/src/views/executor/Projects.vue
index 7ac98b1..0f1f262 100644
--- a/ry-vue3/src/views/executor/Projects.vue
+++ b/ry-vue3/src/views/executor/Projects.vue
@@ -43,7 +43,7 @@
@selection-change="sel=selected=sel"
>
-
+
{{ row.assignedSessions || 0 }}/{{ row.assignedSessions || 0 }}
diff --git a/ry-vue3/src/views/manager/Projects.vue b/ry-vue3/src/views/manager/Projects.vue
index 5d1992a..9ec44bd 100644
--- a/ry-vue3/src/views/manager/Projects.vue
+++ b/ry-vue3/src/views/manager/Projects.vue
@@ -90,7 +90,7 @@
@selection-change="onSel"
>
-
+
@@ -126,7 +126,7 @@
建会
-
分配
+
分配
onAction(cmd, row)">
更多
@@ -139,7 +139,6 @@
开通
删除公告
执行单位评分
- 导出报名专家
删除项目
@@ -282,7 +281,6 @@ function onAction(cmd, row) {
else if (cmd === 'activate') openActivate(row)
else if (cmd === 'delAnn') openDeleteAnnouncement(row)
else if (cmd === 'rate') openSingleScore(row)
- else if (cmd === 'exportExperts') exportExperts(row)
else if (cmd === 'delete') doDelete(row)
}
// 仅 admin 可见删除按钮 (前端 v-if, 后端 controller 再兜底 role 校验)
@@ -291,7 +289,9 @@ const canDelete = computed(() => {
return r === 'admin'
})
function doCreateMeeting(row) { router.push(`/manager/meetings/new?projectId=${row.projectId}`) }
+function isFinishedRow(row) { return row.isFinished === '1' || row.isFinished === 1 }
function doAssign(row) {
+ if (isFinishedRow(row)) return ElMessage.warning('已结题项目不能分配')
// 跳转独立子页面 /manager/projects/assign?projectId=X
router.push({ path: '/manager/projects/assign', query: { projectId: row.projectId } })
}
@@ -358,33 +358,6 @@ function readQueryFromRoute() {
// 导出
function exportProjects() { ElMessage.info('导出项目功能开发中') }
function exportEval() { ElMessage.info('项目评价导出功能开发中') }
-// 导出某项目的报名专家 (biz_execution_intent)
-// 字段: 专家姓名 科室 医院 职称 报名时间 手机号
-async function exportExperts(row) {
- if (!row || !row.projectNo) { ElMessage.warning('缺少项目编号'); return }
- try {
- // 后端 BizExecutionIntent 参数没 @RequestBody, 必须走 URL query string
- const res = await request({
- url: '/business/executionIntent/export',
- method: 'post',
- params: { projectNo: row.projectNo },
- responseType: 'blob'
- })
- const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
- const url = window.URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `报名专家_${row.projectNo}.xlsx`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- window.URL.revokeObjectURL(url)
- ElMessage.success('导出成功')
- } catch (e) {
- const msg = e?.msg || e?.message || '导出失败'
- ElMessage.error(msg)
- }
-}
// ========== 单行操作按钮 handlers(弹 5 个独立 dialog) ==========
function openClose(row) {
@@ -527,15 +500,20 @@ function openAssign() {
ElMessage.warning('请先勾选项目')
return
}
+ // 已结题项目不能分配: 过滤掉已结题项, 并提示跳过数
+ const targets = selection.value.filter(r => !isFinishedRow(r))
+ if (!targets.length) return ElMessage.warning('所选项目均已结题, 不能分配')
+ const skipCount = selection.value.length - targets.length
// 跳转独立子页面 /manager/projects/assign, 单条 vs 批量通过 query 区分:
// 单条: ?projectId=X → 子页面 v-if 单条分支
// 批量: ?projectIds=1,2,3 → 子页面 v-else 批量分支
- if (selection.value.length === 1) {
- router.push({ path: '/manager/projects/assign', query: { projectId: selection.value[0].projectId } })
+ if (targets.length === 1) {
+ router.push({ path: '/manager/projects/assign', query: { projectId: targets[0].projectId } })
} else {
- const ids = selection.value.map(r => r.projectId).join(',')
+ const ids = targets.map(r => r.projectId).join(',')
router.push({ path: '/manager/projects/assign', query: { projectIds: ids } })
}
+ if (skipCount > 0) ElMessage.warning(`已跳过 ${skipCount} 个已结题项目`)
}
function openBatch(kind) {
if (!selection.value.length) return ElMessage.warning('请先勾选项目')
diff --git a/ry-vue3/src/views/meetings/MeetingDetail.vue b/ry-vue3/src/views/meetings/MeetingDetail.vue
index 441a47e..7a2d995 100644
--- a/ry-vue3/src/views/meetings/MeetingDetail.vue
+++ b/ry-vue3/src/views/meetings/MeetingDetail.vue
@@ -299,75 +299,70 @@
会议已执行
{{ nodeDesc('PRE') }}
-
-
-
审核时间轴
-
-
-
执行方提交材料
-
待执行人员提交
-
-
- {{ track.label }}
- 待提交
-
-
+
+
监察意见
+
待监察审核
+
+
+ {{ track.label }}
+ —
-
-
监察意见
-
待监察审核
-
-
- {{ track.label }}
- —
-
-
-
- 第{{ i + 1 }}次
-
- 已退回 · 未进入监察
-
-
- {{ c.supervision.auditor }}
- ·
- {{ fmtDateTime(c.supervision.auditTime) }}
- {{ c.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
-
- 待监察审核
-
- 💬 {{ c.supervision.opinion }}
+
+
+ 第{{ i + 1 }}次
+
+ 已退回 · 未进入监察
+
+ {{ c.supervision.auditor }}
+ ·
+ {{ fmtDateTime(c.supervision.auditTime) }}
+ {{ c.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
+
+ 待监察审核
-
+
💬 {{ c.supervision.opinion }}
+
@@ -421,14 +416,18 @@
-
+
+
+
+
+
+
+
+
-
-
-
@@ -475,7 +474,7 @@
-
+
@@ -570,7 +569,7 @@ import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import ProjectRoleMultiSelect from '@/components/ProjectRoleMultiSelect.vue'
import { ElMessageBox } from 'element-plus'
-import { Upload, ArrowDown } from '@element-plus/icons-vue'
+import { Upload, ArrowDown, Search } from '@element-plus/icons-vue'
import { derivePhysicalStage } from '@/utils/meetingStage'
const route = useRoute()
@@ -783,12 +782,6 @@ function nodeDesc(slot) {
return '-'
}
-/** 审核时间轴 组节点状态: 两轨均审核通过 (进入待结算) 后 done, 否则 pending (内嵌子步骤各自带色). */
-function auditNodeStatus() {
- const s = derivePhysicalStage(row.value)
- return ['AWAITING_SETTLEMENT', 'SETTLED', 'FINISHED'].includes(s) ? 'done' : 'pending'
-}
-
// ===================== 按钮显隐 =====================
// 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员).
// 执行单位是项目级分配 (biz_project_assign), 不在 biz_meeting_executor 里, 旧判定会让执行单位在"执行中"看不到提交按钮.
@@ -937,6 +930,50 @@ function resetAttendeeForm() {
show: false, title: '', editing: false,
form: emptyAttendeeForm(), saving: false
}
+ // 清掉上次的校验错误 (防止关掉后重开还残留"实发金额不能超过..."红字)
+ attendeeFormRef.value?.clearValidate()
+}
+
+/** 参会人表单 ref (el-form validate 用) */
+const attendeeFormRef = ref()
+
+/** 计算所选角色(可多个, 逗号分隔)的项目劳务金额合计; 返回 { sum, matchedAny } */
+function roleAmountSum(laborForm) {
+ const result = { sum: 0, matchedAny: false }
+ if (!laborForm) return result
+ const items = String(laborForm).split(',').map(s => s.trim()).filter(Boolean)
+ items.forEach(item => {
+ const matched = (projectRoles.value || []).find(r => {
+ if (!r) return false
+ const label = r.role === '其他' ? (r.customName || '').trim() : r.role
+ return label === item
+ })
+ if (matched && matched.amount != null) {
+ result.sum += Number(matched.amount) || 0
+ result.matchedAny = true
+ }
+ })
+ return result
+}
+
+/** 实发金额校验: 不超所选角色的劳务金额合计 (未选角色时跳过) */
+function validateFee(rule, value, callback) {
+ const laborForm = attendeeDialog.value.form.laborForm
+ if (!laborForm || !String(laborForm).trim()) {
+ callback()
+ return
+ }
+ const { sum } = roleAmountSum(laborForm)
+ const fee = Number(value) || 0
+ if (fee > sum) {
+ callback(new Error(`实发金额不能超过角色金额 ${sum.toFixed(2)} 元`))
+ } else {
+ callback()
+ }
+}
+
+const attendeeRules = {
+ fee: [{ validator: validateFee, trigger: ['blur', 'change'] }]
}
// ===================== 金额联动 (照搬 hwt guest.vue 单向链) =====================
@@ -994,20 +1031,7 @@ watch(
if (!val) return
// 多选: 逗号分隔, 按每个角色名匹配项目劳务金额求和, 填进 fee (实发).
// "其他"自定义角色不在项目列表里 → 不计入; 全"其他"时 matchedAny=false → 不动 fee.
- const items = String(val).split(',').map(s => s.trim()).filter(Boolean)
- let sum = 0
- let matchedAny = false
- items.forEach(item => {
- const matched = (projectRoles.value || []).find(r => {
- if (!r) return false
- const label = r.role === '其他' ? (r.customName || '').trim() : r.role
- return label === item
- })
- if (matched && matched.amount != null) {
- sum += Number(matched.amount) || 0
- matchedAny = true
- }
- })
+ const { sum, matchedAny } = roleAmountSum(val)
if (matchedAny) {
attendeeDialog.value.form.fee = Number(sum.toFixed(2))
}
@@ -1165,6 +1189,44 @@ function openAttendeeDialog(row) {
nextTick(() => { suppressFeeLink = false })
}
+/** 手机号放大镜: 按手机号查专家, 命中则回填参会人表单 (身份证/银行/科室/职称等) */
+async function lookupExpertByPhone() {
+ const phone = (attendeeDialog.value.form.phone || '').trim()
+ if (!phone) {
+ ElMessage.warning('请先输入手机号')
+ return
+ }
+ if (!phone.match(/^1\d{10}$/)) {
+ ElMessage.warning('手机号格式不正确')
+ return
+ }
+ try {
+ const resp = await request.get(`/business/expert/byPhone/${phone}`, { __silentError: true })
+ const expert = (resp && resp.data) || null
+ if (!expert) {
+ ElMessage.info('未找到该手机号对应的专家')
+ return
+ }
+ const f = attendeeDialog.value.form
+ f.name = expert.name || f.name
+ f.workUnit = expert.workUnit || f.workUnit
+ f.department = expert.department || f.department
+ f.title = expert.title || f.title
+ f.idCard = expert.idCard || f.idCard
+ f.bankName = expert.bankName || f.bankName
+ f.bankCard = expert.bankCard || f.bankCard
+ f.bankRegion = expert.bankRegion || f.bankRegion
+ f.bankAddress = expert.bankAddress || f.bankAddress
+ f.idCardAttachments = expert.idCardAttachments || f.idCardAttachments
+ // 账户名称 = 持卡人姓名, 默认取专家姓名
+ f.accountName = expert.name || f.accountName
+ ElMessage.success('已回填专家信息')
+ } catch (e) {
+ console.error('[meeting-detail] lookupExpertByPhone failed', e)
+ ElMessage.error(e?.msg || e?.message || '查询失败')
+ }
+}
+
/** 提交 dialog (新增 → POST; 编辑 → PUT) */
async function confirmAttendee() {
const { editing, form } = attendeeDialog.value
@@ -1176,6 +1238,12 @@ async function confirmAttendee() {
ElMessage.warning('手机号格式不正确')
return
}
+ // 校验实发金额不超所选角色劳务金额合计 (未选角色时跳过)
+ try {
+ await attendeeFormRef.value.validate()
+ } catch {
+ return
+ }
attendeeDialog.value.saving = true
try {
if (editing) {
@@ -1962,15 +2030,6 @@ onBeforeUnmount(stopFeePolling)
.cycle-no { font-size: 11px; color: #909399; }
.timeline-opinion { margin-top: 4px; font-size: 12px; color: #f56c6c; background: #fef0f0; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
.timeline-item.done .timeline-opinion { color: #909399; background: #f5f7fa; }
-/* 审核时间轴内嵌子时间轴 (提交 → 合规 → 监察) */
-.audit-sub-timeline { position: relative; padding-left: 14px; border-left: 2px solid #e8e8e8; margin: 6px 0 2px; }
-.sub-timeline-item { padding: 4px 0 10px 12px; position: relative; }
-.sub-timeline-item:last-child { padding-bottom: 0; }
-.sub-timeline-item::before { content: ''; position: absolute; left: -20px; top: 7px; width: 8px; height: 8px; border-radius: 50%; background: var(--brand-primary); }
-.sub-timeline-item.done::before { background: #67c23a; }
-.sub-timeline-item.pending::before { background: #c0c4cc; }
-.sub-timeline-item.rejected::before { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2); }
-.sub-timeline-title { font-size: 12px; font-weight: 600; color: #303133; margin-bottom: 4px; line-height: 1.4; }
.audit-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; }
.audit-column { margin-bottom: 0; padding: 16px 18px; min-width: 0; }
@@ -1987,6 +2046,9 @@ onBeforeUnmount(stopFeePolling)
.attendee-form-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; }
.attendee-form-2col :deep(.el-form-item) { margin-bottom: 12px; }
.attendee-form-2col :deep(.el-form-item__content) { min-width: 0; }
+/* 手机号放大镜 suffix: 可点 + hover 高亮 */
+.phone-lookup { cursor: pointer; color: var(--el-text-color-secondary); transition: color 0.2s; }
+.phone-lookup:hover { color: var(--brand-primary, #409eff); }
/* 表格外层横向滚动容器: 内容总宽 ~1680px 超出 left-col 宽度时, 容器内出现横向滚动条, 不撑爆外层 grid */
.attendee-table-wrap { overflow-x: auto; max-width: 100%; min-width: 0; }
.row-actions { display: inline-flex; align-items: center; gap: 4px; }
@@ -2161,8 +2223,6 @@ onBeforeUnmount(stopFeePolling)
/* 时间轴缩窄 */
.timeline { padding-left: 18px !important; }
.timeline-item::before { left: -23px !important; }
- .audit-sub-timeline { padding-left: 12px !important; }
- .sub-timeline-item::before { left: -18px !important; }
/* 顶部"返回/操作"按钮组横滚 */
.page-title { flex-wrap: wrap !important; gap: 8px !important; }
diff --git a/ry-vue3/src/views/meetings/Meetings.vue b/ry-vue3/src/views/meetings/Meetings.vue
index d822163..92f4f35 100644
--- a/ry-vue3/src/views/meetings/Meetings.vue
+++ b/ry-vue3/src/views/meetings/Meetings.vue
@@ -4,7 +4,7 @@
-
+
@@ -46,7 +46,7 @@
:main-cols="['projectNo', 'meetingName']"
>
-
+
diff --git a/ry-vue3/src/views/portal/Home.vue b/ry-vue3/src/views/portal/Home.vue
index 4e5cbb4..689598e 100644
--- a/ry-vue3/src/views/portal/Home.vue
+++ b/ry-vue3/src/views/portal/Home.vue
@@ -1,87 +1,6 @@
-
-
-
-
-
-
-
-
-
+
@@ -135,16 +54,14 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
-import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import PortalFooter from '@/components/PortalFooter.vue'
+import PortalNavbar from '@/components/PortalNavbar.vue'
const router = useRouter()
const userStore = useUserStore()
-const isScrolled = ref(false)
const specialPlans = ref([])
-const drawerOpen = ref(false)
async function loadSpecialPlans() {
try {
@@ -156,11 +73,15 @@ async function loadSpecialPlans() {
}
function openPlan(id) {
+ // 点击计划详情需登录: 未登录先跳登录, 登录后回跳详情
+ if (!loggedIn.value) {
+ ElMessage.warning('请先登录系统')
+ router.push({ path: '/login', query: { redirect: `/special-plan/${id}` } })
+ return
+ }
window.open(`${import.meta.env.BASE_URL}#/special-plan/${id}`, '_blank')
}
-const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
-const userName = computed(() => userStore.user?.userName || '用户')
// 投稿角色: admin/manager 不开放投稿 → 隐藏「项目提案」按钮; 未登录 user 为 null → 显示(点击引导登录)
const canPropose = computed(() => {
const r = userStore.user?.role || ''
@@ -224,42 +145,15 @@ const planSvg = `