批量推送代码

This commit is contained in:
郭庆泰
2026-08-26 23:14:46 +08:00
parent 18f7e93d5b
commit 36528b6e0c
36 changed files with 933 additions and 779 deletions
+1
View File
@@ -63,6 +63,7 @@ bin/
*.swp
*.swo
*~
*.zip
.gradle/
*.iml
Binary file not shown.
-161
View File
@@ -1,161 +0,0 @@
<template>
<div class="gr-table-container">
<div class="gr-table-filter">
<div class="gr-query-wrap">
<div>筛选</div>
<van-field class="gr-query" v-model="queryVal"></van-field>
<van-icon name="search" size="18"></van-icon>
</div>
</div>
<el-table class="gr-table" ref="mesSysTable" :data="data2Show" :border="border" :height="height"
:max-height="maxHeight" :stripe="stripe" :size="size" :fit="fit" :show-header="showHeader"
:highlight-current-row="highlightCurrentRow" :highlight-selection-row="highlightSelectionRow"
:current-row-key="currentRowKey" :row-class-name="rowClassName" :row-style="rowStyle" :cell-class-name="cellClassName"
:cell-style="cellStyle" :header-row-class-name="headerRowClassName" :header-row-style="headerRowStyle"
:header-cell-class-name="headerCellClassName" :header-cell-style="headerCellStyle" :row-key="rowKey"
:empty-text="emptyText" :default-expand-all="defaultExpandAll" :expand-row-keys="expandRowKeys"
:default-sort="defaultSort" :tooltip-effect="tooltipEffect" :show-summary="showSummary" :summary-method="summaryMethod"
:sum-text="sumText" :span-method="spanMethod" :select-on-indeterminate="selectOnIndeterminate" :indent="indent"
:lazy="lazy" :load="load" :tree-props="treeProps"
@select="(evt) => $emit('select',evt)" @select-all="(evt) => $emit('select-all',evt)"
@selection-change="(evt) => $emit('selection-change',evt)" @cell-mouse-enter="(evt) => $emit('cell-mouse-enter',evt)"
@cell-mouse-leave="(evt) => $emit('cell-mouse-leave',evt)" @cell-click="(evt) => $emit('cell-click',evt)"
@cell-dblclick="(evt) => $emit('cell-dblclick',evt)" @row-click="(evt) => $emit('row-click',evt)"
@row-contextmenu="(evt) => $emit('row-contextmenu',evt)" @row-dblclick="(evt) => $emit('row-dblclick',evt)"
@header-click="(evt) => $emit('header-click',evt)" @header-contextmenu="(evt) => $emit('header-contextmenu',evt)"
@sort-change="(evt) => $emit('sort-change',evt)" @filter-change="(evt) => $emit('filter-change',evt)"
@current-change="(evt) => $emit('current-change',evt)" @header-dragend="(evt) => $emit('header-dragend',evt)"
@expand-change="(evt) => $emit('expand-change',evt)" >
<el-table-column width="30" type="expand" v-if="meta">
<template slot-scope="scope">
<el-descriptions :column="1" style="margin: 0 20px;">
<el-descriptions-item v-for="(item, index) in meta" :label="item.label" :key="index">{{scope.row[item.prop]}}</el-descriptions-item>
</el-descriptions>
</template>
</el-table-column>
<slot>
</slot>
</el-table>
</div>
</template>
<script>
import Vue from 'vue';
import { Field } from 'vant';
Vue.use(Field);
import {Icon} from "vant";
Vue.use(Icon);
export default {
props: ["data","meta" ,"major","border","height","maxHeight","stripe","size","fit","showHeader",
"highlightCurrentRow","highlightSelectionRow","currentRowKey","rowClassName","rowStyle","cellClassName",
"cellStyle","headerRowClassName", "headerRowStyle","headerCellClassName","headerCellStyle",
"rowKey", "emptyText","defaultExpandAll", "expandRowKeys","defaultSort","tooltipEffect","sumText","showSummary",
"summaryMethod", "spanMethod","selectOnIndeterminate","indent","lazy","load","treeProps",
],
data() {
return {
data2Show: [],
slight: null,
mainCol: this.major,
rows: null,
queryVal: null,
};
},
watch: {
data() {
this.refreshData();
},
queryVal(val) {
// console.log("queryval ", val);
this.refreshData();
}
},
methods: {
getDataList(row) {
return Object.keys(row)
.filter((key) => this.slight.some((s) => s.prop == key)).map((key) => {
return {
label: this.slight.find((s) => s.prop == key).label,
value: row[this.slight.find((s) => s.prop == key).prop],
};
});
},
clearSelection(...args) { this.$refs['mesSysTable'].clearSelection(...args)},
toggleRowSelection (...args) {this.$refs['mesSysTable'].toggleRowSelection(...args)},
toggleAllSelection (...args) { this.$refs['mesSysTable'].toggleAllSelection(...args)},
toggleRowExpansion (...args) { this.$refs['mesSysTable'].toggleRowExpansion(...args)},
setCurrentRow (...args) { this.$refs['mesSysTable'].setCurrentRow(...args)},
clearSort (...args) { this.$refs['mesSysTable'].clearSort(...args)},
clearFilter (...args){ this.$refs['mesSysTable'].clearFilter(...args)},
doLayout (...args){ this.$refs['mesSysTable'].doLayout(...args)},
sort (...args){ this.$refs['mesSysTable'].sort(...args)},
refreshData() {
if (this.queryVal) {
// this.$showLoading();
// console.log("queryval is",this.queryVal);
this.data2Show = this.data.filter(item => {
return JSON.stringify(item).indexOf(this.queryVal) != -1;
})
// this.$hideLoading();
} else {
this.data2Show = this.data;
}
}
},
// created() {
// this.data2Show = JSON.parse(JSON.stringify(this.data));
// },
};
</script>
<style scoped lang="scss">
@import "@/assets/styles/vant.scss";
::v-deep .van-cell {
padding: 0px !important
}
::v-deep .el-table .cell {
padding-left: 0 !important;
padding-right: 0 !important;
font-size: 12px;
line-height: 18px;
}
.gr-table-container {
padding: 0 8px;
.gr-table-filter {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
border-radius: 16px;
height: 32px;
border: 1px solid $gr-border;
margin-bottom: 10px;
.gr-query-wrap {
width: calc(100% - 32px);
display: flex;
justify-content: space-between;
align-items: center;
}
.gr-query {
width: calc(100% - 60px);
}
}
}
::v-deep th.el-table__cell {
background: #0ECD4916 !important;
font-family: PingFangSC-Medium;
color: #323233;
line-height: 22px;
font-weight: 700;
}
::v-deep .el-table__cell {
padding: 8px !important;
}
::v-deep .el-descriptions__body {
font-size: 12px !important;
line-height: 18px;
}
</style>
-94
View File
@@ -1,94 +0,0 @@
<template>
<div>
<el-table v-if="showTable" ref="mesSysTable" :data="data" :border="border" :height="height"
@select="(evt) => $emit('select',evt)" @select-all="(evt) => $emit('select-all',evt)"
@selection-change="(evt) => $emit('selection-change',evt)" @cell-mouse-enter="(evt) => $emit('cell-mouse-enter',evt)"
>
<el-table-column type="expand" v-if="slight && slight[0]">
<template slot-scope="scope">
<el-descriptions :column="1" style="margin: 0 20px;">
<el-descriptions-item v-for="(item, index) in getDataList(scope.row)" :label="item.label" :key="index">{{item.value}}</el-descriptions-item>
</el-descriptions>
</template>
</el-table-column>
<slot name="default">
</slot>
</el-table>
</div>
</template>
<script>
export default {
props: ["data", "major","border","height"],
data() {
return {
mainCol: this.major,
rows: null,
showTable: true,
refreshing: false,
};
},
methods: {
getDataList(row) {
return Object.keys(row)
.filter((key) => this.slight.some((s) => s.prop == key)).map((key) => {
return {
label: this.slight.find((s) => s.prop == key).label,
value: row[this.slight.find((s) => s.prop == key).prop],
};
});
},
initData() {
let length = this.$slots.default.length, rows = this.$slots.default;
// if (window.innerWidth < 768) { //判断PDA
if (!this.major || !this.major[0]) { //如果没有指定重要的列,默认第一列(一般是名称)和最后一列(一般是操作)
this.mainCol = [rows[0].componentOptions.propsData.prop,rows[length-1].componentOptions.propsData.prop]
}
this.slight = rows.filter((item) => { //筛选不重要的列,展示到expand中
return !(this.mainCol.some((main) => {
return main == item.componentOptions.propsData.prop;
}));
}).map((item) => {
return item.componentOptions.propsData;
});
this.rows = rows.filter((item) => { //去掉不重要的列
return this.mainCol.some((main) => {
return main == item.componentOptions.propsData.prop;
}) || !item.componentOptions.propsData.prop;
});
this.$slots.default = this.rows;
// }
}
},
created() {
this.initData();
},
updated() {
if (!this.refreshing) {
this.refreshing = true;
this.showTable = false;
} else{
setTimeout(()=> {
this.showTable = true;
this.initData();
},0)
}
}
};
</script>
<style scoped lang="scss">
::v-deep th.el-table__cell {
background: #0ECD4916 !important;
font-family: PingFangSC-Medium;
// font-size: 16px;
color: #323233;
line-height: 22px;
font-weight: 700;
}
::v-deep .el-table__cell {
padding: 8px !important;
}
</style>
-117
View File
@@ -1,117 +0,0 @@
<template>
<div>
<el-table ref="mesSysTable" :data="data" :border="border" :height="height"
:max-height="maxHeight" :stripe="stripe" :size="size" :fit="fit" :show-header="showHeader"
:highlight-current-row="highlightCurrentRow" :highlight-selection-row="highlightSelectionRow"
:current-row-key="currentRowKey" :row-class-name="rowClassName" :row-style="rowStyle" :cell-class-name="cellClassName"
:cell-style="cellStyle" :header-row-class-name="headerRowClassName" :header-row-style="headerRowStyle"
:header-cell-class-name="headerCellClassName" :header-cell-style="headerCellStyle" :row-key="rowKey"
:empty-text="emptyText" :default-expand-all="defaultExpandAll" :expand-row-keys="expandRowKeys"
:default-sort="defaultSort" :tooltip-effect="tooltipEffect" :show-summary="showSummary" :summary-method="summaryMethod"
:sum-text="sumText" :span-method="spanMethod" :select-on-indeterminate="selectOnIndeterminate" :indent="indent"
:lazy="lazy" :load="load" :tree-props="treeProps"
@select="(evt) => $emit('select',evt)" @select-all="(evt) => $emit('select-all',evt)"
@selection-change="(evt) => $emit('selection-change',evt)" @cell-mouse-enter="(evt) => $emit('cell-mouse-enter',evt)"
@cell-mouse-leave="(evt) => $emit('cell-mouse-leave',evt)" @cell-click="(evt) => $emit('cell-click',evt)"
@cell-dblclick="(evt) => $emit('cell-dblclick',evt)" @row-click="(evt) => $emit('row-click',evt)"
@row-contextmenu="(evt) => $emit('row-contextmenu',evt)" @row-dblclick="(evt) => $emit('row-dblclick',evt)"
@header-click="(evt) => $emit('header-click',evt)" @header-contextmenu="(evt) => $emit('header-contextmenu',evt)"
@sort-change="(evt) => $emit('sort-change',evt)" @filter-change="(evt) => $emit('filter-change',evt)"
@current-change="(evt) => $emit('current-change',evt)" @header-dragend="(evt) => $emit('header-dragend',evt)"
@expand-change="(evt) => $emit('expand-change',evt)" >
<el-table-column type="expand" v-if="slight && slight[0]">
<template slot-scope="scope">
<el-descriptions :column="1" style="margin: 0 20px;">
<el-descriptions-item v-for="(item, index) in getDataList(scope.row)" :label="item.label" :key="index">{{item.value}}</el-descriptions-item>
</el-descriptions>
</template>
</el-table-column>
<slot>
</slot>
</el-table>
</div>
</template>
<script>
export default {
props: ["data", "major","border","height","maxHeight","stripe","size","fit","showHeader",
"highlightCurrentRow","highlightSelectionRow","currentRowKey","rowClassName","rowStyle","cellClassName",
"cellStyle","headerRowClassName", "headerRowStyle","headerCellClassName","headerCellStyle",
"rowKey", "emptyText","defaultExpandAll", "expandRowKeys","defaultSort","tooltipEffect","sumText","showSummary",
"summaryMethod", "spanMethod","selectOnIndeterminate","indent","lazy","load","treeProps",
],
data() {
return {
slight: null,
mainCol: this.major,
rows: null,
};
},
methods: {
getDataList(row) {
return Object.keys(row)
.filter((key) => this.slight.some((s) => s.prop == key)).map((key) => {
return {
label: this.slight.find((s) => s.prop == key).label,
value: row[this.slight.find((s) => s.prop == key).prop],
};
});
},
clearSelection(...args) { this.$refs['mesSysTable'].clearSelection(...args)},
toggleRowSelection (...args) {this.$refs['mesSysTable'].toggleRowSelection(...args)},
toggleAllSelection (...args) { this.$refs['mesSysTable'].toggleAllSelection(...args)},
toggleRowExpansion (...args) { this.$refs['mesSysTable'].toggleRowExpansion(...args)},
setCurrentRow (...args) { this.$refs['mesSysTable'].setCurrentRow(...args)},
clearSort (...args) { this.$refs['mesSysTable'].clearSort(...args)},
clearFilter (...args){ this.$refs['mesSysTable'].clearFilter(...args)},
doLayout (...args){ this.$refs['mesSysTable'].doLayout(...args)},
sort (...args){ this.$refs['mesSysTable'].sort(...args)},
initData() {
let length = this.$slots.default.length, rows = this.$slots.default;
// if (window.innerWidth < 768) { //判断PDA
if (!this.major || !this.major[0]) { //如果没有指定重要的列,默认第一列(一般是名称)和最后一列(一般是操作)
this.mainCol = [rows[0].componentOptions.propsData.prop,rows[length-1].componentOptions.propsData.prop]
}
this.slight = rows.filter((item) => { //筛选不重要的列,展示到expand中
return !(this.mainCol.some((main) => {
return main == item.componentOptions.propsData.prop;
}));
}).map((item) => {
return item.componentOptions.propsData;
});
this.rows = rows.filter((item) => { //去掉不重要的列
return this.mainCol.some((main) => {
return main == item.componentOptions.propsData.prop;
}) || !item.componentOptions.propsData.prop;
});
this.$slots.default = this.rows;
// }
}
},
created() {
this.initData();
},
updated() {
console.log("updated!!!!");
// this.initData();
// this.$slots.default = this.rows;
}
};
</script>
<style scoped lang="scss">
::v-deep th.el-table__cell {
background: #0ECD4916 !important;
font-family: PingFangSC-Medium;
// font-size: 16px;
color: #323233;
line-height: 22px;
font-weight: 700;
}
::v-deep .el-table__cell {
padding: 8px !important;
}
</style>
+13
View File
@@ -0,0 +1,13 @@
供应商系统需要增加一个接口
请求参数:最后更新时间,支持分页。
返回:
1、供应商账号注册信息(企业类型 企业名称 邮箱 账号 密码 公司地址 税号 联系人 联系电话) 启用 删除 禁用 标识 根据修改时间倒序排序返回。
2、整个信息可以使用AES加密,提供解密算法,密码不可逆,提供提供算法进行比对。(因为数据返回都是加密的,且加密算法只提供给你们 不要到时候说我们接口信息安全泄露哈)
https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts?lastUpdatedTime=2026-08-01%2000:00:00&pageNum=1&pageSize=20
测试环境的密钥配置如下
supplier-account-api-aes:
key-id: supplier-api-key
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
@@ -59,6 +59,18 @@ ruoyi:
camera:
base-url: https://risingdoctor.com/camera/
# 供应商账号接口 (拉取/解密) 配置
supplier-account-api:
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
# 每页条数
page-size: 20
# 每次拉取最近 N 分钟更新的数据
pull-minutes: 5
# 供应商账号接口 AES 密钥 (数据解密用, 测试环境密钥)
supplier-account-api-aes:
key-id: supplier-api-key
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
# 开发环境配置
server:
# 服务器的HTTP端口,默认为8080
@@ -48,6 +48,17 @@ public class BizExpertController extends BaseController
{
return success(bizExpertService.getByUserId(SecurityUtils.getUserId()));
}
/**
* 按手机号查专家 (参会人 dialog 手机号放大镜回填用): 命中返回专家档案, 未命中返回 null
*/
@GetMapping("/byPhone/{phone}")
public AjaxResult getByPhone(@PathVariable String phone)
{
BizExpert q = new BizExpert();
q.setPhone(phone);
List<BizExpert> list = bizExpertService.selectList(q);
return success(list != null && !list.isEmpty() ? list.get(0) : null);
}
/**
* admin 创建专家: 同时创建 sys_user (用户名=手机号, 密码=手机号)
* 返回 SysUser (含明文 password 给前端 toast 用)
@@ -259,6 +259,9 @@ public class BizProjectController extends BaseController
@PostMapping("/{projectId}/assigns")
public AjaxResult saveAssigns(@PathVariable("projectId") Long projectId, @RequestBody List<BizProjectAssign> assigns)
{
// 已结题项目不能再分配
BizProject project = requireAssignableProject(projectId);
if (assigns == null) assigns = new ArrayList<>();
bizProjectAssignService.validateSum(projectId, assigns);
// #3 通知去重: 拉旧数据按 executionUnitId 索引, 同 (executionUnitId, sessions, amount) → 无变化 → 跳过
@@ -270,8 +273,7 @@ public class BizProjectController extends BaseController
}
bizProjectAssignService.deleteByProjectId(projectId);
// #3 通知: 查一次项目名, 避免循环里重复查 DB
BizProject project = bizProjectService.getById(projectId);
String projectName = project != null ? project.getProjectName() : null;
String projectName = project.getProjectName();
for (BizProjectAssign a : assigns) {
a.setProjectId(projectId);
if (a.getStatus() == null) a.setStatus("0");
@@ -331,6 +333,15 @@ public class BizProjectController extends BaseController
try { return Long.parseLong(s); } catch (NumberFormatException e) { return null; }
}
/** 校验项目存在且未结题 — 已结题项目禁止一切分配操作, 返回项目供取项目名. */
private BizProject requireAssignableProject(Long projectId) {
if (projectId == null) throw new ServiceException("项目不存在");
BizProject p = bizProjectService.getById(projectId);
if (p == null) throw new ServiceException("项目不存在");
if ("1".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配");
return p;
}
@Log(title = "项目执行方分配", businessType = BusinessType.DELETE)
@DeleteMapping("/{projectId}/assigns")
public AjaxResult clearAssigns(@PathVariable("projectId") Long projectId)
@@ -406,6 +417,8 @@ public class BizProjectController extends BaseController
if (body.getProjectId() == null) {
return error("projectId 必填");
}
// 已结题项目不能分配
BizProject project = requireAssignableProject(parseProjectId(body.getProjectId()));
java.util.List<Long> mids = body.getMonitorUserIds();
if (mids == null || mids.isEmpty()) {
// 向后兼容: 单值 monitorUserId
@@ -430,8 +443,7 @@ public class BizProjectController extends BaseController
// 通知被分配的监察员 (新增 / 说明或积分变化才发). projectId 在 sponsor_assign 是 String, 转 Long 查主表
Long projectIdLong = parseProjectId(body.getProjectId());
BizProject project = projectIdLong != null ? bizProjectService.getById(projectIdLong) : null;
String projectName = project != null ? project.getProjectName() : null;
String projectName = project.getProjectName();
for (Long mid : mids) {
if (mid == null) continue;
BizProjectSponsorAssign old = oldByMonitor.get(mid);
@@ -467,6 +479,8 @@ public class BizProjectController extends BaseController
if (body.getProjectId() == null) {
return error("projectId 必填");
}
// 已结题项目不能分配
requireAssignableProject(parseProjectId(body.getProjectId()));
java.util.List<Long> 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 是否变化
@@ -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 分钟更新的账号, 解密后打印.
* <p>
* 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts}
* 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回.
* 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密.
* <p>
* 说明: 只打印不落库 (后续需要持久化时再扩展).
*/
@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);
}
}
}
@@ -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);
}
}
/**
@@ -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
@@ -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).
* <p>
* 接口返回的 {@code data} 字段格式: {@code BSA.v1.<keyId>.<nonce>.<ciphertext>}
* <ul>
* <li>BSA — 固定前缀 (Bidding Supplier Account)</li>
* <li>v1 — 版本号</li>
* <li>keyId — 密钥标识, 对应配置 {@code supplier-account-api-aes.key-id}
* (单密钥场景忽略, 密钥旋转时按此取对应 key)</li>
* <li>nonce — 12 字节随机 IV, URL-safe Base64 编码 (无填充)</li>
* <li>ciphertext — 密文, URL-safe Base64 编码 (无填充, 末尾带 16 字节 GCM 认证标签)</li>
* </ul>
* 算法: 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);
}
}
@@ -49,15 +49,17 @@
<include refid="selectFields"/>
<where>
p.is_deleted = 0
<if test="planName != null and planName != ''"> and p.plan_name like concat('%', #{planName}, '%')</if>
<if test="planDirectionId != null"> and p.plan_direction_id = #{planDirectionId}</if>
<if test="planCategory != null and planCategory != ''"> and p.plan_category = #{planCategory}</if>
<if test="projectForm != null and projectForm != ''"> and p.project_form = #{projectForm}</if>
<if test="submitterId != null"> and p.submitter_id = #{submitterId}</if>
<choose>
<!-- 选了具体状态: 等值匹配 -->
<when test="status != null and status != ''"> and p.status = #{status}</when>
<!-- 未选: 不加 status 过滤, 由前端按角色决定默认值 (经理侧默认查 1/2/3, 医生侧默认查全部含 0) -->
</choose>
<if test="remark != null and remark != ''"> and p.remark = #{remark}</if>
<if test="remark != null and remark != ''"> and p.remark like concat('%', #{remark}, '%')</if>
</where>
order by p.plan_id desc
</select>
+2 -2
View File
@@ -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'
+21 -3
View File
@@ -10,8 +10,12 @@
/>
-->
<template>
<div class="ht-file-upload" :class="{ 'has-file': modelValue, 'is-block': block, readonly }" @click="handleClick">
<div v-if="!modelValue && !readonly" class="ht-file-placeholder">
<div class="ht-file-upload" :class="{ 'has-file': modelValue, 'is-block': block, readonly, uploading }" @click="handleClick">
<div v-if="uploading" class="ht-file-progress">
<span class="progress-label">上传中</span>
<el-progress :percentage="progress" :stroke-width="6" class="progress-bar" />
</div>
<div v-else-if="!modelValue && !readonly" class="ht-file-placeholder">
<span class="placeholder-text">{{ placeholder }}</span>
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
</div>
@@ -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;
+410
View File
@@ -0,0 +1,410 @@
<template>
<!-- 公开门户共享顶部导航 (首页 / 项目公示 / 公示详情 共用)
active 态按当前 route 推导, 无需各页面各自传参 -->
<header class="top-nav" :class="topNavClass">
<a class="logo" title="返回首页" @click.prevent="goHome">
<div class="logo-icon"><img src="/logo.png" alt="logo" /></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
<ul class="nav-list">
<li class="nav-item"><a class="nav-link" :class="{ active: isHome }" @click.prevent="goHome">年度项目规划</a></li>
<li class="nav-item"><a class="nav-link" :class="{ active: isPublicity }" @click.prevent="goPublicity">项目公示</a></li>
</ul>
<div class="top-tools">
<template v-if="!loggedIn">
<span class="login-btn" @click="goLogin">登录</span>
</template>
<template v-else>
<el-dropdown trigger="click" @command="onUserCmd">
<a class="user-link" @click.prevent>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="account">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
<!-- 手机端汉堡按钮 (桌面隐藏) -->
<button class="hamburger" type="button" aria-label="打开菜单" @click="drawerOpen = true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</header>
<!-- 手机端抽屉菜单 (桌面隐藏) -->
<transition name="drawer-fade">
<div v-if="drawerOpen" class="drawer-mask" @click="drawerOpen = false"></div>
</transition>
<transition name="drawer-slide">
<aside v-if="drawerOpen" class="drawer-panel" role="dialog" aria-label="导航菜单">
<div class="drawer-header">
<span class="drawer-title">导航菜单</span>
<button class="drawer-close" type="button" aria-label="关闭菜单" @click="drawerOpen = false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="6" y1="6" x2="18" y2="18"/>
<line x1="6" y1="18" x2="18" y2="6"/>
</svg>
</button>
</div>
<nav class="drawer-nav">
<a class="drawer-link" @click.prevent="goHomeAndClose">年度项目规划</a>
<a class="drawer-link" @click.prevent="goPublicityAndClose">项目公示</a>
</nav>
<div class="drawer-divider"></div>
<div class="drawer-tools">
<template v-if="!loggedIn">
<span class="drawer-login" @click="goLoginAndClose">登录</span>
</template>
<template v-else>
<div class="drawer-user">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</div>
<a class="drawer-link" @click.prevent="goAccountAndClose">我的主页</a>
<a class="drawer-link danger" @click.prevent="goLogoutAndClose">退出登录</a>
</template>
</div>
</aside>
</transition>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const isScrolled = ref(false)
const drawerOpen = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
// active 态: 按当前路由推导 (首页=年度项目规划, /publicity* = 项目公示)
const isHome = computed(() => route.path === '/')
const isPublicity = computed(() => route.path === '/publicity' || route.path.startsWith('/publicity/'))
function handleScroll() { isScrolled.value = window.scrollY > 10 }
onMounted(() => window.addEventListener('scroll', handleScroll, { passive: true }))
onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
// 当前已在目标页时点导航 = 仅回顶, 否则跳转
function goHome() { if (route.path !== '/') router.push('/'); else window.scrollTo({ top: 0, behavior: 'smooth' }) }
function goPublicity() { if (route.path !== '/publicity') router.push('/publicity'); else window.scrollTo({ top: 0, behavior: 'smooth' }) }
function goLogin() { router.push('/login') }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/admin/workbench', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/admin/workbench')
}
}
// 抽屉版导航: 跳转后关闭抽屉
function goHomeAndClose() { drawerOpen.value = false; goHome() }
function goPublicityAndClose() { drawerOpen.value = false; goPublicity() }
function goLoginAndClose() { drawerOpen.value = false; goLogin() }
function goAccountAndClose() { drawerOpen.value = false; onUserCmd('account') }
async function goLogoutAndClose() { drawerOpen.value = false; await goLogout() }
</script>
<style scoped>
* { margin: 0; padding: 0; box-sizing: border-box; }
a { color: inherit; text-decoration: none; }
/* ========== 顶部导航 ========== */
.top-nav {
position: sticky;
top: 0;
z-index: 1000;
height: 72px;
padding: 0 60px;
background: var(--brand-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.3s;
}
.top-nav.is-scrolled {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.logo-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.logo-title {
font-size: 16px;
font-weight: 600;
color: #fff;
letter-spacing: 0.5px;
}
.logo-subtitle {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
margin-top: 2px;
letter-spacing: 0.3px;
}
.nav-list {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
list-style: none;
}
.nav-item {
position: relative;
height: 72px;
display: flex;
align-items: center;
}
.nav-link {
font-size: 15px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
transition: color 0.25s;
position: relative;
height: 72px;
display: flex;
align-items: center;
}
.nav-link::after {
content: '';
position: absolute;
left: 50%;
bottom: 0;
width: 0;
height: 2px;
background: #fff;
transform: translateX(-50%);
transition: width 0.3s ease;
}
.nav-item:hover .nav-link,
.nav-link.active {
color: #fff;
}
.nav-item:hover .nav-link::after,
.nav-link.active::after {
width: 100%;
}
/* 顶部工具栏 */
.top-tools {
display: flex;
align-items: center;
gap: 16px;
}
.login-btn {
padding: 7px 20px;
background: #fff;
color: var(--brand-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
letter-spacing: 1px;
}
.login-btn:hover {
background: #f3f4f6;
}
.user-link {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.user-link:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* ========== 汉堡按钮 (桌面隐藏, 手机显示) ========== */
.hamburger {
display: none;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: #fff;
padding: 8px;
cursor: pointer;
margin-left: auto;
border-radius: 4px;
transition: background 0.2s;
}
.hamburger:hover { background: rgba(255, 255, 255, 0.12); }
.hamburger:active { background: rgba(255, 255, 255, 0.2); }
/* ========== 抽屉 (手机端) ========== */
.drawer-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1999;
}
.drawer-panel {
position: fixed;
top: 0;
right: 0;
width: 260px;
max-width: 80vw;
height: 100vh;
background: #fff;
z-index: 2000;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 16px 0 20px;
border-bottom: 1px solid var(--brand-slate-200);
flex-shrink: 0;
}
.drawer-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.drawer-close {
background: transparent;
border: none;
color: var(--el-text-color-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
}
.drawer-close:hover { background: var(--brand-slate-100); color: var(--el-text-color-primary); }
.drawer-nav { display: flex; flex-direction: column; padding: 8px 0; }
.drawer-link {
display: block;
padding: 12px 20px;
font-size: 14px;
color: var(--el-text-color-primary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
border-left: 3px solid transparent;
}
.drawer-link:hover { background: var(--brand-slate-50); }
.drawer-link.danger { color: var(--el-color-danger); }
.drawer-divider { height: 1px; background: var(--brand-slate-200); margin: 4px 20px; }
.drawer-tools { display: flex; flex-direction: column; padding: 8px 0; gap: 0; }
.drawer-user {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 13px;
color: var(--el-text-color-secondary);
border-bottom: 1px solid var(--brand-slate-100);
margin-bottom: 4px;
}
.drawer-user svg { color: var(--brand-primary); }
.drawer-login {
display: block;
margin: 4px 20px;
padding: 10px 0;
text-align: center;
background: var(--brand-primary);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-radius: 4px;
transition: opacity 0.2s;
}
.drawer-login:hover { opacity: 0.9; }
/* 抽屉过渡 */
.drawer-fade-enter-active, .drawer-fade-leave-active { transition: opacity 0.2s; }
.drawer-fade-enter-from, .drawer-fade-leave-to { opacity: 0; }
.drawer-slide-enter-active, .drawer-slide-leave-active { transition: transform 0.25s ease; }
.drawer-slide-enter-from, .drawer-slide-leave-to { transform: translateX(100%); }
/* ========== 移动端适配 ========== */
@media (max-width: 768px) {
/* 顶栏: 高度收窄 + padding 减小 */
.top-nav { height: 56px !important; padding: 0 16px !important; }
.logo-title { font-size: 14px !important; }
.logo-subtitle { display: none; }
/* 桌面 nav-list + 顶部 tools 隐藏, 改用汉堡 */
.nav-list { display: none !important; }
.top-tools { display: none !important; }
.hamburger { display: inline-flex !important; }
}
</style>
+21 -10
View File
@@ -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('/')
}
+2
View File
@@ -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 + '/')
+1 -1
View File
@@ -14,7 +14,7 @@
</el-form>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="160" />
<el-table-column prop="projectNo" label="项目编号" width="170" />
<el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
<el-table-column prop="currentStage" label="当前阶段" width="140">
<template #default="{ row }">
+1 -1
View File
@@ -10,7 +10,7 @@
</el-form>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="160" />
<el-table-column prop="projectNo" label="项目编号" width="170" />
<el-table-column prop="projectName" label="项目名称" min-width="240" show-overflow-tooltip />
<el-table-column prop="meetingName" label="会议名称" min-width="220" show-overflow-tooltip />
<el-table-column label="状态" width="100">
@@ -26,10 +26,10 @@
<el-form-item label="所属公司" prop="orgName">
<el-input v-model="form.orgName" placeholder="所属公司" maxlength="200" :disabled="!!orgId" />
</el-form-item>
<el-form-item label="部门">
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" placeholder="部门" maxlength="50" />
</el-form-item>
<el-form-item label="职务">
<el-form-item label="职务" prop="position">
<el-input v-model="form.position" placeholder="职务" maxlength="50" />
</el-form-item>
</el-form>
@@ -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() {
+2 -2
View File
@@ -4,7 +4,7 @@
<!-- ========== 筛选区 (仿 manager/meetings/Meetings.vue: 项目编号/会议ID/会议名称/期数/会议时间/项目形式/当前阶段/备注) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:140px" /></el-form-item>
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:170px" /></el-form-item>
<el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="请输入会议ID" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
<el-form-item label="期数"><el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:140px" /></el-form-item>
@@ -37,7 +37,7 @@
<GrTable :data="rows" v-loading="loading" stripe border
:main-cols="['projectNo', 'meetingName']"
>
<el-table-column prop="projectNo" label="项目编号" width="130" fixed />
<el-table-column prop="projectNo" label="项目编号" width="170" fixed />
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip />
+1 -1
View File
@@ -43,7 +43,7 @@
@selection-change="sel=selected=sel"
>
<el-table-column v-if="!isSub" type="selection" width="48" />
<el-table-column prop="projectNo" label="项目编号" width="120" />
<el-table-column prop="projectNo" label="项目编号" width="170" />
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
<el-table-column label="总场次/总期数" width="110" align="center">
<template #default="{ row }">{{ row.assignedSessions || 0 }}/{{ row.assignedSessions || 0 }}</template>
+12 -34
View File
@@ -90,7 +90,7 @@
@selection-change="onSel"
>
<el-table-column type="selection" width="44" />
<el-table-column prop="projectNo" label="项目编号" width="140" fixed="left" />
<el-table-column prop="projectNo" label="项目编号" width="170" fixed="left" />
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="totalSessions" label="总场次/总期数" width="100" align="center" />
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
@@ -126,7 +126,7 @@
<template #default="{ row }"><div class="table-actions">
<div class="op-cell">
<el-link :underline="false" type="primary" @click="doCreateMeeting(row)">建会</el-link>
<el-link :underline="false" type="primary" @click="doAssign(row)">分配</el-link>
<el-link :underline="false" type="primary" :disabled="isFinishedRow(row)" @click="doAssign(row)">分配</el-link>
<el-dropdown trigger="hover" @command="(cmd) => onAction(cmd, row)">
<el-link :underline="false" type="primary" class="op-dropdown">
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
@@ -139,7 +139,6 @@
<el-dropdown-item command="activate">开通</el-dropdown-item>
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
<el-dropdown-item command="exportExperts">导出报名专家</el-dropdown-item>
<el-dropdown-item v-if="canDelete" command="delete">删除项目</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -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('请先勾选项目')
+160 -100
View File
@@ -299,75 +299,70 @@
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<!-- 节点 2: 审核时间轴 (内嵌 提交 合规 监察 三步, 两轨并列, 保留重交历史) -->
<div :class="['timeline-item', auditNodeStatus()]">
<div class="timeline-title">审核时间轴</div>
<div class="audit-sub-timeline">
<div :class="['sub-timeline-item', nodeStatus('submit')]">
<div class="sub-timeline-title">执行方提交材料</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待执行人员提交</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text">待提交</span>
</div>
<div v-for="(c, i) in track.cycles" :key="i" class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<span>{{ c.submit.auditor }}</span>
<!-- 材料审核三步 (提交 合规 监察) 并入主时间轴, 两轨并列, 保留重交历史 -->
<div :class="['timeline-item', nodeStatus('submit')]">
<div class="timeline-title">执行方提交材料</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待执行人员提交</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text">待提交</span>
</div>
<div v-for="(c, i) in track.cycles" :key="i" class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<span>{{ c.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.submit.auditTime) }}</span>
</div>
</div>
</div>
<div :class="['timeline-item', nodeStatus('compliance')]">
<div class="timeline-title">合规审核</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待合规审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance">
<span>{{ c.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.submit.auditTime) }}</span>
</div>
</div>
</div>
<div :class="['sub-timeline-item', nodeStatus('compliance')]">
<div class="sub-timeline-title">合规审核</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待合规审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance">
<span>{{ c.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.compliance.auditTime) }}</span>
<el-tag size="small" :type="c.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待审核</span>
</div>
<div v-if="c.compliance?.opinion" class="timeline-opinion">💬 {{ c.compliance.opinion }}</div>
<span>{{ fmtDateTime(c.compliance.auditTime) }}</span>
<el-tag size="small" :type="c.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待审核</span>
</div>
<div v-if="c.compliance?.opinion" class="timeline-opinion">💬 {{ c.compliance.opinion }}</div>
</template>
</div>
</div>
<div :class="['timeline-item', nodeStatus('supervision')]">
<div class="timeline-title">监察意见</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待监察审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<div :class="['sub-timeline-item', nodeStatus('supervision')]">
<div class="sub-timeline-title">监察意见</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待监察审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance && c.compliance.auditResult === 'REJECTED'">
<span class="pending-text">已退回 · 未进入监察</span>
</template>
<template v-else-if="c.supervision">
<span>{{ c.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.supervision.auditTime) }}</span>
<el-tag size="small" :type="c.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待监察审核</span>
</div>
<div v-if="c.supervision?.opinion" class="timeline-opinion">💬 {{ c.supervision.opinion }}</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance && c.compliance.auditResult === 'REJECTED'">
<span class="pending-text">已退回 · 未进入监察</span>
</template>
<template v-else-if="c.supervision">
<span>{{ c.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.supervision.auditTime) }}</span>
<el-tag size="small" :type="c.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待监察审核</span>
</div>
</div>
<div v-if="c.supervision?.opinion" class="timeline-opinion">💬 {{ c.supervision.opinion }}</div>
</template>
</div>
</div>
<!-- 节点 3/4: 结算 / 完结 -->
@@ -421,14 +416,18 @@
<!-- 参会人 dialog (新增/编辑通用, 2 列布局) -->
<el-dialog v-model="attendeeDialog.show" :title="attendeeDialog.title" width="760px" append-to-body :close-on-click-modal="false" @closed="resetAttendeeForm">
<el-form :model="attendeeDialog.form" label-width="100px" class="attendee-form-2col">
<el-form ref="attendeeFormRef" :model="attendeeDialog.form" :rules="attendeeRules" label-width="100px" class="attendee-form-2col">
<!-- Col 1: 基础档案 (8 字段) -->
<el-form-item label="联系方式" required>
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11">
<template v-if="!attendeeDialog.editing" #suffix>
<el-icon class="phone-lookup" title="按手机号查专家并回填" @click="lookupExpertByPhone"><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名(未注册手机号将以此作为昵称建号)" />
</el-form-item>
<el-form-item label="联系方式" required>
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11" />
</el-form-item>
<el-form-item label="医院">
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
</el-form-item>
@@ -475,7 +474,7 @@
<el-form-item label="增值税附加">
<el-input-number v-model="attendeeDialog.form.vatAndSurcharge" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="实发金额">
<el-form-item label="实发金额" prop="fee">
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="摘要">
@@ -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; }
+2 -2
View File
@@ -4,7 +4,7 @@
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:140px" /></el-form-item>
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:170px" /></el-form-item>
<el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="请输入会议ID" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
<el-form-item label="期数"><el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:140px" /></el-form-item>
@@ -46,7 +46,7 @@
:main-cols="['projectNo', 'meetingName']"
>
<el-table-column v-if="isRole('admin', 'manager')" type="selection" width="44" />
<el-table-column prop="projectNo" label="项目编号" width="130" fixed />
<el-table-column prop="projectNo" label="项目编号" width="170" fixed />
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip />
+8 -114
View File
@@ -1,87 +1,6 @@
<template>
<div class="home-page">
<header class="top-nav" :class="topNavClass">
<a class="logo" title="返回首页" @click.prevent="onHome">
<div class="logo-icon"><img src="/logo.png" alt="logo" /></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
<ul class="nav-list">
<li class="nav-item"><span class="nav-link active">年度项目规划</span></li>
<li class="nav-item"><a class="nav-link" @click.prevent="goPublicity">项目公示</a></li>
</ul>
<div class="top-tools">
<template v-if="!loggedIn">
<span class="login-btn" @click="goLogin">登录</span>
</template>
<template v-else>
<el-dropdown trigger="click" @command="onUserCmd">
<a class="user-link" @click.prevent>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="account">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
<!-- 手机端汉堡按钮 (桌面隐藏) -->
<button class="hamburger" type="button" aria-label="打开菜单" @click="drawerOpen = true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</header>
<!-- 手机端抽屉菜单 (桌面隐藏) -->
<transition name="drawer-fade">
<div v-if="drawerOpen" class="drawer-mask" @click="drawerOpen = false"></div>
</transition>
<transition name="drawer-slide">
<aside v-if="drawerOpen" class="drawer-panel" role="dialog" aria-label="导航菜单">
<div class="drawer-header">
<span class="drawer-title">导航菜单</span>
<button class="drawer-close" type="button" aria-label="关闭菜单" @click="drawerOpen = false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="6" y1="6" x2="18" y2="18"/>
<line x1="6" y1="18" x2="18" y2="6"/>
</svg>
</button>
</div>
<nav class="drawer-nav">
<a class="drawer-link" @click.prevent="goHomeAndClose">年度项目规划</a>
<a class="drawer-link" @click.prevent="goPublicityAndClose">项目公示</a>
</nav>
<div class="drawer-divider"></div>
<div class="drawer-tools">
<template v-if="!loggedIn">
<span class="drawer-login" @click="goLoginAndClose">登录</span>
</template>
<template v-else>
<div class="drawer-user">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</div>
<a class="drawer-link" @click.prevent="goAccountAndClose">我的主页</a>
<a class="drawer-link danger" @click.prevent="goLogoutAndClose">退出登录</a>
</template>
</div>
</aside>
</transition>
<PortalNavbar />
<div class="container">
<section class="hero">
@@ -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 = `<svg viewBox="0 150 1200 370" xmlns="http://www.w3.org/2000/svg
</g>
</svg>`
function handleScroll() {
isScrolled.value = window.scrollY > 10
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
loadSpecialPlans()
// portal home 移动端适配: 解除 body min-width (避免手机端 1354px 横向滚动)
document.body.classList.add('home-page-body')
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
document.body.classList.remove('home-page-body')
})
function onHome() { isScrolled.value = false }
function goLogin() { router.push('/login') }
function goUser() { /* placeholder */ }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
// 抽屉版导航: 跳转后关闭抽屉
function goHomeAndClose() { drawerOpen.value = false; onHome() }
function goPublicityAndClose() { drawerOpen.value = false; goPublicity() }
function goLoginAndClose() { drawerOpen.value = false; goLogin() }
function goAccountAndClose() { drawerOpen.value = false; onUserCmd('account') }
async function goLogoutAndClose() { drawerOpen.value = false; await goLogout() }
async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/admin/workbench', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/admin/workbench')
}
}
function goPublicity() { router.push('/publicity') }
function onSubmit() {
if (!loggedIn.value) {
ElMessage.warning('请先登录系统')
+3 -60
View File
@@ -1,40 +1,6 @@
<template>
<div class="pub-page">
<header class="top-nav" :class="topNavClass">
<a class="logo" title="返回首页" @click.prevent="goHome">
<div class="logo-icon"><img src="/logo.png" alt="logo" /></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
<ul class="nav-list">
<li class="nav-item"><a class="nav-link" @click.prevent="goHome">年度项目规划</a></li>
<li class="nav-item"><span class="nav-link active">项目公示</span></li>
</ul>
<div class="top-tools">
<template v-if="!loggedIn">
<span class="login-btn" @click="goLogin">登录</span>
</template>
<template v-else>
<el-dropdown trigger="click" @command="onUserCmd">
<a class="user-link" @click.prevent>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="account">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</header>
<PortalNavbar />
<main class="container">
<div class="page-header">
@@ -106,23 +72,16 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import { bizList } from '@/api/public'
import PortalFooter from '@/components/PortalFooter.vue'
import PortalNavbar from '@/components/PortalNavbar.vue'
const router = useRouter()
const userStore = useUserStore()
const PAGE_SIZE = 10
const isScrolled = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
const ALL_NOTICES = []
const currentKeyword = ref('')
@@ -203,19 +162,6 @@ function prevPage() { if (currentPage.value > 1) { currentPage.value--; window.s
function nextPage() { if (currentPage.value < totalPages.value) { currentPage.value++; window.scrollTo({ top: 0, behavior: 'smooth' }) } }
function goPage(p) { currentPage.value = p; window.scrollTo({ top: 0, behavior: 'smooth' }) }
function goHome() { router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
function goUser() { /* placeholder */ }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/admin/workbench', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/admin/workbench')
}
}
function goDetail(n) {
if (!n || !n.annId) return
router.push({ name: 'publicity-detail', params: { projectId: n.annId } })
@@ -229,9 +175,6 @@ function fmtDate(d) {
const day = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function handleScroll() { isScrolled.value = window.scrollY > 10 }
onMounted(() => window.addEventListener('scroll', handleScroll, { passive: true }))
onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
</script>
<style scoped>
+2 -54
View File
@@ -1,40 +1,6 @@
<template>
<div class="detail-page">
<header class="top-nav" :class="topNavClass">
<a class="logo" title="返回首页" @click.prevent="goHome">
<div class="logo-icon"><img src="/logo.png" alt="logo" /></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
<ul class="nav-list">
<li class="nav-item"><a class="nav-link" @click.prevent="goHome">年度项目规划</a></li>
<li class="nav-item"><a class="nav-link active" @click.prevent="goPublicity">项目公示</a></li>
</ul>
<div class="top-tools">
<template v-if="!loggedIn">
<span class="login-btn" @click="goLogin">登录</span>
</template>
<template v-else>
<el-dropdown trigger="click" @command="onUserCmd">
<a class="user-link" @click.prevent>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="account">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</header>
<PortalNavbar />
<main class="container">
<a class="back-link" @click.prevent="goPublicity">
@@ -183,8 +149,8 @@ import { ref, computed, watch, reactive, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import PortalFooter from '@/components/PortalFooter.vue'
import PortalNavbar from '@/components/PortalNavbar.vue'
import { listBizPerson } from '@/api/business/person'
import { bizGet } from '@/api/public'
import {
@@ -199,10 +165,7 @@ const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isScrolled = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
// 意向按钮按角色显隐:
// sponsor (含 MAIN/SUB) → 仅支持意向; executor (含 MAIN/SUB) → 仅执行意向
// admin / manager / doctor → 两个都隐藏
@@ -359,19 +322,7 @@ async function load() {
}
}
function goHome() { router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
function goUser() { /* placeholder */ }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/admin/workbench', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/admin/workbench')
}
}
const signed = ref(false)
async function checkSigned() {
@@ -572,7 +523,6 @@ function onViewBidAnnouncement() {
}
function onKeydown(e) { if (e.key === 'Escape') showQr.value = false }
function handleScroll() { isScrolled.value = window.scrollY > 10 }
// 下载二维码: qrUrl 是 QRCode.toDataURL 生成的 data:image/png;base64,... 直接 <a download> 即可
function onDownloadQr() {
@@ -586,12 +536,10 @@ function onDownloadQr() {
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('keydown', onKeydown)
load()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('keydown', onKeydown)
})
</script>
@@ -147,6 +147,11 @@ async function onUserCmd(cmd) {
function handleScroll() { isScrolled.value = window.scrollY > 10 }
onMounted(() => {
// 计划详情需登录 (点击验证登录状态): 未登录跳登录, 登录后回跳 (兜底拦截直链访问)
if (!loggedIn.value) {
router.replace({ path: '/login', query: { redirect: route.fullPath } })
return
}
window.addEventListener('scroll', handleScroll, { passive: true })
load()
})
@@ -23,10 +23,10 @@
<el-form-item label="所属公司" prop="orgName">
<el-input v-model="form.orgName" placeholder="所属公司" maxlength="200" :disabled="!!orgId" />
</el-form-item>
<el-form-item label="部门">
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" placeholder="部门" maxlength="50" />
</el-form-item>
<el-form-item label="职务">
<el-form-item label="职务" prop="position">
<el-input v-model="form.position" placeholder="职务" maxlength="50" />
</el-form-item>
</el-form>
@@ -78,7 +78,9 @@ const rules = {
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, 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() {
+1 -1
View File
@@ -60,7 +60,7 @@
:main-cols="['projectNo', 'meetingName']"
>
<el-table-column type="selection" width="44" />
<el-table-column prop="projectNo" label="项目编号" min-width="130" />
<el-table-column prop="projectNo" label="项目编号" min-width="170" />
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip />
<el-table-column label="总期数" prop="totalPeriod" width="80" />
<el-table-column label="期数" prop="periodNo" width="70" />
+1 -1
View File
@@ -59,7 +59,7 @@
:main-cols="['projectNo', 'projectName']"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="projectNo" label="项目编号" min-width="140" fixed>
<el-table-column prop="projectNo" label="项目编号" min-width="170" fixed>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openDetail(row)">{{ row.projectNo }}</el-link></template></el-table-column>
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
@@ -51,7 +51,7 @@
>
<!-- SUB 子账号无需勾选 (不参与分配) -->
<el-table-column v-if="!isSub" type="selection" width="40" />
<el-table-column label="项目编号" width="160">
<el-table-column label="项目编号" width="170">
<template #default="{ row }">
<a class="link">{{ row.projectNo }}</a>
</template>
+4 -4
View File
@@ -29,11 +29,11 @@ export default defineConfig(({ mode }) => {
host: '0.0.0.0',
proxy: {
[apiPath]: {
// 远程后端: risingdoctor.com/hg-api (HTTPS, 远程 RuoYi v3.9.2)
target: 'https://risingdoctor.com',
// 本地后端: localhost:8080 (RuoYi context-path=/, 去掉 /dev-api 前缀直连)
target: 'http://localhost:8080',
changeOrigin: true,
secure: true,
rewrite: (p) => p.replace(new RegExp('^' + apiPath), '/hg-api')
secure: false,
rewrite: (p) => p.replace(new RegExp('^' + apiPath), '')
}
}
}