feat: 会议批量审核 + 公示按发布时间倒序 + 现场照片 dialog 预览 + 重复报错精确

- 会议列表 manager 批量合规审核 (批量通过/退回, best-effort 逐条回执)
- 公示列表按 publish_time 倒序, 发布/开通写本地 publish_time (修 toISOString UTC 时区 bug)
- 签到表/前全景/后全景「查看照片」改 dialog 预览 (复用 Preview.vue)
- 手机号/用户名重复报错精确提示 (后端 fieldMap + 前端 duplicateTip)
- 医生首页欢迎语改「欢迎使用合规系统」
This commit is contained in:
郭庆泰
2026-08-26 00:41:33 +08:00
parent ea2024d083
commit b5b3e3a213
15 changed files with 387 additions and 115 deletions
@@ -1,7 +1,9 @@
package com.ruoyi.business.controller;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizMeetingMaterial;
@@ -283,22 +285,72 @@ public class BizMeetingController extends BaseController {
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-compliance")
public AjaxResult auditCompliance(@PathVariable("meetingId") Long meetingId, @RequestBody AuditBody body) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType)) throw new ServiceException("仅合规人员可操作");
if (!"manager".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
throw new ServiceException("仅合规人员可操作");
}
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
boolean approved = Boolean.TRUE.equals(body.getApproved());
if (!approved && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空");
}
return success(doComplianceAudit(m, approved, body.getOpinion()));
}
/**
* 批量合规审核 (role_type=manager): 一次对多个会议执行材料一级审核.
* <p>
* body: { "meetingIds": [..], "approved": true|false, "opinion": "..." }
* <p>best-effort: 逐条审核, 状态不符的会议跳过并回执失败原因, 不整批回滚.
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/batch-audit-compliance")
public AjaxResult batchAuditCompliance(@RequestBody BatchAuditBody body) {
if (!"manager".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
throw new ServiceException("仅合规人员可操作");
}
List<Long> meetingIds = body.getMeetingIds();
if (meetingIds == null || meetingIds.isEmpty()) {
throw new ServiceException("请选择会议");
}
boolean approved = Boolean.TRUE.equals(body.getApproved());
if (!approved && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空");
}
List<Long> successIds = new ArrayList<>();
List<Map<String, Object>> failures = new ArrayList<>();
for (Long meetingId : meetingIds) {
if (meetingId == null) continue;
try {
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
doComplianceAudit(m, approved, body.getOpinion());
successIds.add(meetingId);
} catch (ServiceException e) {
Map<String, Object> f = new HashMap<>();
f.put("meetingId", meetingId);
f.put("reason", e.getMessage());
failures.add(f);
}
}
Map<String, Object> data = new HashMap<>();
data.put("total", meetingIds.size());
data.put("successCount", successIds.size());
data.put("failCount", failures.size());
data.put("failures", failures);
return success(data);
}
/**
* 内部: 合规审核核心流转 (阶段校验 + 状态写入 + audit_log). 单条/批量共用.
*/
private String doComplianceAudit(BizMeeting m, boolean approved, String opinion) {
Integer compliance = m.getMaterialComplianceApproved();
if (!"SUBMITTED".equals(m.getMaterialAuditStage()) || (compliance != null && compliance == 1)) {
throw new ServiceException("当前阶段不允许合规审核");
}
String result = approved ? "APPROVED" : "REJECTED";
if (approved) {
m.setMaterialComplianceApproved(1);
@@ -310,8 +362,8 @@ public class BizMeetingController extends BaseController {
m.setMaterialAuditTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "MATERIAL", result, body.getOpinion());
return success(result);
appendAuditLog(m, "MATERIAL", result, opinion);
return result;
}
/**
@@ -533,4 +585,17 @@ public class BizMeetingController extends BaseController {
public void setOpinion(String opinion) { this.opinion = opinion; }
}
/** request body for batch audit endpoint */
public static class BatchAuditBody {
private List<Long> meetingIds;
private Boolean approved; // true=通过 false=拒绝
private String opinion; // 意见
public List<Long> getMeetingIds() { return meetingIds; }
public void setMeetingIds(List<Long> meetingIds) { this.meetingIds = meetingIds; }
public Boolean getApproved() { return approved; }
public void setApproved(Boolean approved) { this.approved = approved; }
public String getOpinion() { return opinion; }
public void setOpinion(String opinion) { this.opinion = opinion; }
}
}
@@ -82,9 +82,8 @@ public class BizPublicController extends BaseController {
@GetMapping("/announcements")
public AjaxResult announcements() {
BizProject query = new BizProject();
query.setIsPublished("1");
List<BizProject> list = projectService.selectList(query);
// 公示列表按发布时间倒序 (后端排序), 只返回已发布项目
List<BizProject> list = projectService.selectPublicAnnouncements();
return success(list);
}
@@ -9,6 +9,8 @@ public interface BizProjectMapper
{
BizProject selectByPrimaryKey(Long projectId);
List<BizProject> selectList(BizProject entity);
/** 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后) */
List<BizProject> selectPublicAnnouncements();
/** sponsor 端专属: projectIds + LEFT JOIN 当前 login 用户评分, 用于评分回显 */
List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
@@ -10,6 +10,8 @@ public interface IBizProjectService
{
BizProject getById(Long projectId);
List<BizProject> selectList(BizProject entity);
/** 公开门户公示列表: 已发布, 按发布时间倒序 */
List<BizProject> selectPublicAnnouncements();
/** sponsor 端专属: LEFT JOIN 当前 login 用户评分回显 */
List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
@@ -43,6 +43,9 @@ public class BizProjectServiceImpl implements IBizProjectService
public List<BizProject> selectList(BizProject entity)
{ return bizProjectMapper.selectList(entity); }
@Override
public List<BizProject> selectPublicAnnouncements()
{ return bizProjectMapper.selectPublicAnnouncements(); }
@Override
public List<BizProject> selectSponsorList(BizProject entity)
{ return bizProjectMapper.selectSponsorList(entity); }
@Override
@@ -28,6 +28,7 @@
<result property="isFinished" column="is_finished" />
<result property="isSettled" column="is_settled" />
<result property="isPublished" column="is_published" />
<result property="publishTime" column="publish_time" />
<result property="createBy" column="create_by" />
<result property="createUserId" column="create_user_id" />
<result property="createUserName" column="create_user_name" />
@@ -56,7 +57,7 @@
(p.total_amount - ifnull(p.manage_fee, 0)
- (select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)
- (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)) as available_amount,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.publish_time, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
@@ -81,7 +82,7 @@
(p.total_amount - ifnull(p.manage_fee, 0)
- (select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)
- (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)) as available_amount,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.publish_time,
p.sponsor_org_id, p.lead_user_id, p.is_bid_project,
p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor,
p.start_time, p.end_time, p.submit_deadline_days,
@@ -356,6 +357,14 @@
</where>
order by project_id desc
</select>
<!-- 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新发布在前, publish_time 为空排最后) -->
<select id="selectPublicAnnouncements" resultMap="BizProjectResult">
<include refid="selectFields"/>
where p.is_deleted = 0 and p.is_published = '1'
order by p.publish_time desc, p.project_id desc
</select>
<insert id="insert" parameterType="BizProject">
insert into biz_project
<trim prefix="(" suffix=")" suffixOverrides=",">
@@ -372,6 +381,7 @@
<if test="isFinished != null and isFinished != ''">is_finished,</if>
<if test="isSettled != null and isSettled != ''">is_settled,</if>
<if test="isPublished != null and isPublished != ''">is_published,</if>
<if test="publishTime != null">publish_time,</if>
<if test="sponsorOrgId != null">sponsor_org_id,</if>
<if test="leadUserId != null">lead_user_id,</if>
<if test="isBidProject != null and isBidProject != ''">is_bid_project,</if>
@@ -411,6 +421,7 @@
<if test="isFinished != null and isFinished != ''">#{isFinished},</if>
<if test="isSettled != null and isSettled != ''">#{isSettled},</if>
<if test="isPublished != null and isPublished != ''">#{isPublished},</if>
<if test="publishTime != null">#{publishTime},</if>
<if test="sponsorOrgId != null">#{sponsorOrgId},</if>
<if test="leadUserId != null">#{leadUserId},</if>
<if test="isBidProject != null and isBidProject != ''">#{isBidProject},</if>
@@ -462,6 +473,7 @@
<if test="isFinished != null and isFinished != ''">is_finished = #{isFinished},</if>
<if test="isSettled != null and isSettled != ''">is_settled = #{isSettled},</if>
<if test="isPublished != null and isPublished != ''">is_published = #{isPublished},</if>
<if test="publishTime != null">publish_time = #{publishTime},</if>
<if test="sponsorOrgId != null">sponsor_org_id = #{sponsorOrgId},</if>
<if test="leadUserId != null">lead_user_id = #{leadUserId},</if>
<if test="isBidProject != null and isBidProject != ''">is_bid_project = #{isBidProject},</if>
@@ -184,6 +184,7 @@ public class GlobalExceptionHandler
fieldMap.put("expert_id_card", "身份证号");
fieldMap.put("unit_name", "单位名称");
fieldMap.put("org_name", "机构名称");
fieldMap.put("person_phone", "手机号");
if (key != null && key.startsWith("uk_")) {
String field = key.substring(3);
+16 -3
View File
@@ -21,11 +21,14 @@
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
<el-icon><CameraFilled /></el-icon>&nbsp;扫码拍照
</el-button>
<a v-if="modelValue" :href="modelValue" target="_blank" class="cam-link">查看照片</a>
<el-button v-if="modelValue" link type="primary" size="small" @click="openPreview">查看照片</el-button>
<span v-else class="cam-empty">未上传</span>
<span v-if="polling" class="cam-polling">等待手机回传</span>
</div>
<!-- 照片预览: 复用全局 Preview.vue (dialog 内预览, 图片/PDF 自适应, 非新窗口) -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
<el-dialog
v-model="visible"
:title="`扫码拍照 - ${label}`"
@@ -52,6 +55,7 @@ import request from '@/utils/request'
import { ElMessage } from 'element-plus'
import { CameraFilled } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import Preview from '@/components/Preview.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -73,6 +77,9 @@ const visible = ref(false)
const qrUrl = ref('')
const qrLoading = ref(false)
const polling = ref(false)
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
let pollTimer = null
let baseline = ''
@@ -142,6 +149,14 @@ function onClosed() {
stopPolling()
}
/** 打开照片预览 dialog (复用全局 Preview.vue, 图片/PDF 自适应) */
function openPreview() {
if (!props.modelValue) return
previewUrl.value = props.modelValue
previewTitle.value = props.label
previewOpen.value = true
}
onBeforeUnmount(stopPolling)
</script>
@@ -151,8 +166,6 @@ onBeforeUnmount(stopPolling)
display: flex; align-items: center; gap: 12px;
min-height: 36px;
}
.cam-link { color: var(--brand-primary); text-decoration: none; font-size: 13px; }
.cam-link:hover { text-decoration: underline; }
.cam-empty { color: #c0c4cc; font-size: 13px; }
.cam-polling { color: #e6a23c; font-size: 12px; }
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="welcome-bar">
<div class="welcome-text">
<h2>下午好,{{ displayName }}专家</h2>
<p>欢迎使用项目管理系统</p>
<p>欢迎使用合规系统</p>
</div>
<div class="welcome-time">
<div class="now">{{ nowTime }}</div>
+8 -2
View File
@@ -213,6 +213,12 @@ function isDuplicateError(msg) {
return /已存在|重复|duplicate|uk_person_phone/i.test(msg || '')
}
// 按后端原因精确提示: 登录账号(用户名) vs 手机号 (后端分别抛 "登录账号已存在" / DB uk_person_phone)
function duplicateTip(msg) {
if (/登录账号|用户名|login/i.test(msg || '')) return '登录账号已存在,请更换'
return '该手机号已存在,请更换'
}
async function onSave() {
try {
await formRef.value.validate()
@@ -248,12 +254,12 @@ async function onSave() {
}
} else {
const msg = res?.msg || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
if (isDuplicateError(msg)) ElMessage.warning(duplicateTip(msg))
else ElMessage.error(msg)
}
} catch (e) {
const msg = e?.msg || e?.message || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
if (isDuplicateError(msg)) ElMessage.warning(duplicateTip(msg))
else ElMessage.error(msg)
} finally {
saving.value = false
+7 -1
View File
@@ -145,6 +145,12 @@ const fileInputRef = ref(null)
function defaultRoleRow() { return { role: '', customName: '', amount: 0 } }
function defaultNotice(key, label) { return { key, label, url: '', name: '' } }
// 当前本地时间 "yyyy-MM-dd HH:mm:ss" (发布用, 与 el-date-picker 的本地时间口径一致; 不能用 toISOString 的 UTC)
function nowLocalDatetime() {
const d = new Date()
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// 项目负责人候选 (合规管理员, sys_user.role_type='manager')
const managerOptions = ref([])
@@ -333,7 +339,7 @@ async function submit(mode = 'save') {
// 发布时设置 is_published='1' + publish_time (同步后端字段)
if (mode === 'publish') {
payload.isPublished = '1'
payload.publishTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
payload.publishTime = nowLocalDatetime()
}
// 多执行方分配已移至"项目分配"页面单独管理
try {
+83 -1
View File
@@ -35,9 +35,9 @@
<!-- ========== 批量按钮 (admin/manager 共用: 批量提交 + 批量删除; sponsor 隐藏) ========== -->
<div v-if="isRole('admin', 'manager')" class="batch-bar">
<el-button type="danger" :disabled="!selectedIds.length" @click="onBatchDelete">批量删除</el-button>
<el-button type="primary" :disabled="!selectedIds.length" @click="onBatchDownloadService">批量下载会务</el-button>
<el-button type="primary" :disabled="!selectedIds.length" @click="onBatchDownloadLabor">批量下载劳务</el-button>
<el-button v-if="isRole('manager')" type="primary" :disabled="!selectedIds.length" @click="onBatchAudit">批量审核</el-button>
<span v-if="selectedIds.length" class="filter-tip">已选 {{ selectedIds.length }} </span>
</div>
@@ -119,6 +119,28 @@
<el-button type="primary" :loading="approvalSaving" @click="onApprovalConfirm">确定</el-button>
</template>
</el-dialog>
<!-- 合规人员(manager) 批量审核 dialog: 对选中的待合规审核会议批量通过/退回 -->
<el-dialog v-model="batchAuditOpen" title="批量合规审核" width="560px">
<el-form label-width="100px">
<el-form-item label="审核范围">
<span>已选 {{ selectedIds.length }} 个会议, 其中待合规审核 {{ batchEligibleIds.length }} </span>
</el-form-item>
<el-form-item label="审核结果">
<el-radio-group v-model="batchAuditForm.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">退回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见">
<el-input v-model="batchAuditForm.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="退回时意见必填, 将通知执行方整改" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchAuditOpen = false">取消</el-button>
<el-button type="primary" :loading="batchAuditSaving" @click="onBatchAuditConfirm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -364,6 +386,66 @@ async function onApprovalConfirm() {
}
}
// ========== 合规人员(manager) 批量审核 ==========
const batchAuditOpen = ref(false)
const batchAuditSaving = ref(false)
const batchAuditForm = reactive({ approved: true, opinion: '' })
const batchEligibleIds = ref([])
// 待合规审核判据: material_audit_stage=SUBMITTED 且 compliance_approved≠1 (与详情页 canComplianceAudit 一致)
function isCompliancePending(row) {
return row.materialAuditStage === 'SUBMITTED' && !isOneVal(row.materialComplianceApproved)
}
function onBatchAudit() {
if (!selectedIds.value.length) return ElMessage.warning('请先勾选会议')
// 从当前页 rows 里过滤出「待合规审核」的选中会议 (selection 只来自当前页)
const eligible = rows.value.filter(r => selectedIds.value.includes(r.meetingId) && isCompliancePending(r))
batchEligibleIds.value = eligible.map(r => r.meetingId)
if (!batchEligibleIds.value.length) {
ElMessage.warning('所选会议中没有处于「待合规审核」状态的, 无法批量审核')
return
}
batchAuditForm.approved = true
batchAuditForm.opinion = ''
batchAuditOpen.value = true
}
async function onBatchAuditConfirm() {
if (!batchAuditForm.approved && !batchAuditForm.opinion.trim()) {
ElMessage.warning('退回时意见不能为空')
return
}
batchAuditSaving.value = true
try {
const r = await request.post('/business/meeting/batch-audit-compliance', {
meetingIds: batchEligibleIds.value,
approved: batchAuditForm.approved,
opinion: batchAuditForm.opinion
})
const d = r?.data || {}
const successCount = d.successCount ?? 0
const failCount = d.failCount ?? 0
const failures = d.failures || []
if (failCount > 0) {
const names = failures.map(f => `会议ID ${f.meetingId}: ${f.reason}`).join('\n')
ElMessageBox.alert(
`批量审核完成\n成功 ${successCount} 个, 失败 ${failCount}\n\n失败明细:\n${names}`,
'批量审核结果',
{ type: successCount > 0 ? 'warning' : 'error', confirmButtonText: '知道了' }
)
} else {
ElMessage.success(`批量审核完成, 成功 ${successCount}`)
}
batchAuditOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '批量审核失败')
} finally {
batchAuditSaving.value = false
}
}
// ========== 会务下载 (OSS 端打 zip: 后端 staging copy + 阿里云 FC 打包) ==========
async function onDownloadService(row) {
try {
+161 -92
View File
@@ -42,30 +42,48 @@
</div>
<div class="filter-bar">
<input v-model="currentKeyword" @keydown.enter="applyFilter" type="text" class="filter-input" placeholder="">
<div class="filter-input-wrap">
<svg class="filter-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7"/>
<path d="M21 21l-4.3-4.3"/>
</svg>
<input v-model="currentKeyword" @keydown.enter="applyFilter" type="text" class="filter-input" placeholder="搜索项目名称">
</div>
<button class="filter-btn" @click="applyFilter">查询</button>
<button class="filter-btn" style="background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);" @click="resetFilter">重置</button>
<button class="filter-btn filter-btn-plain" @click="resetFilter">重置</button>
</div>
<div class="notice-list">
<template v-if="pageItems.length === 0">
<div style="padding: 40px 0; text-align: center; color: #9ca3af; font-size: 13px;">暂无数据</div>
<div class="notice-empty">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
</svg>
<span>暂无公示项目</span>
</div>
</template>
<template v-else>
<div class="notice-header">
<span class="notice-header-no">序号</span>
<span class="notice-header-title">项目</span>
<span class="notice-header-date">开始日期</span>
<span class="notice-header-end">截止日期</span>
<span class="notice-header-title">项目名称</span>
<span class="notice-header-date">发布日期</span>
</div>
<div v-for="(n, i) in pageItems" :key="n.annId || i" class="notice-item" @click="goDetail(n)">
<span class="notice-item-no">{{ String((currentPage - 1) * PAGE_SIZE + i + 1).padStart(3, '0') }}</span>
<span class="notice-title">
<span v-if="n.projectNo" style="color:#909399;margin-right:8px;font-size:13px">{{ n.projectNo }}</span>
{{ n.title }}
<svg class="notice-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6l6 6-6 6"/>
</svg>
</span>
<span class="notice-date">
<svg class="notice-date-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{{ fmtDate(n.publishTime) }}
</span>
<span class="notice-date">{{ fmtDate(n.date) }}</span>
<span class="notice-end">{{ fmtDate(n.endTime) }}</span>
</div>
</template>
</div>
@@ -121,8 +139,7 @@ async function load() {
allRows.value = list.map(r => {
return {
annId: r.projectId,
date: r.startTime,
endTime: r.endTime,
publishTime: r.publishTime,
type: '公示',
projectNo: r.projectNo || '',
projectName: r.projectName,
@@ -430,30 +447,47 @@ main.container {
/* ========== 筛选条 ========== */
.filter-bar {
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
margin-bottom: 0;
padding: 14px 0;
border-bottom: 1px solid #f0f2f5;
display: flex;
align-items: center;
gap: 14px;
gap: 12px;
font-size: 13px;
}
.filter-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.filter-icon {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
}
.filter-input {
height: 30px;
padding: 0 10px;
border: 1px solid #d1d5db;
background: #fff;
height: 34px;
padding: 0 12px 0 34px;
border: 1px solid #dde1e6;
border-radius: 17px;
background: #f7f8fa;
font-size: 13px;
color: #1f2937;
font-family: inherit;
outline: none;
width: 220px;
transition: border-color 0.2s;
width: 240px;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.filter-input:focus {
border-color: var(--brand-primary);
background: #fff;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05);
}
.filter-input::placeholder {
@@ -461,20 +495,33 @@ main.container {
}
.filter-btn {
height: 30px;
padding: 0 18px;
height: 34px;
padding: 0 20px;
background: var(--brand-primary);
color: #fff;
border: none;
font-size: 12px;
border: 1px solid var(--brand-primary);
border-radius: 17px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
letter-spacing: 1px;
transition: background 0.2s;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.filter-btn:hover {
background: var(--brand-primary-deep);
border-color: var(--brand-primary-deep);
}
.filter-btn-plain {
background: #fff;
color: var(--brand-primary);
}
.filter-btn-plain:hover {
background: #f7f8fa;
border-color: var(--brand-primary);
color: var(--brand-primary);
}
/* ========== 公示列表 ========== */
@@ -482,40 +529,14 @@ main.container {
padding: 0;
}
.notice-item {
.notice-empty {
display: flex;
align-items: flex-start;
padding: 14px 0;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s;
gap: 24px;
}
.notice-item:hover {
background: #fafbfc;
}
.notice-item:hover .notice-title {
color: var(--brand-primary);
}
.notice-date {
flex-shrink: 0;
width: 100px;
font-size: 13px;
color: #6b7280;
font-family: ui-monospace, "Courier New", monospace;
padding-top: 2px;
}
.notice-title {
flex: 1;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 64px 0;
color: #c0c6cf;
font-size: 14px;
color: #1f2937;
line-height: 1.7;
transition: color 0.2s;
min-width: 0;
}
/* 表头: 与 notice-item 相同的 flex + gap + 列宽, 保证列对齐 */
@@ -523,23 +544,12 @@ main.container {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 0;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
font-size: 13px;
padding: 14px 20px;
border-bottom: 1px solid #eef0f3;
font-size: 12px;
font-weight: 600;
color: #4b5563;
}
.notice-header-no {
flex-shrink: 0;
width: 32px;
text-align: center;
}
.notice-header-date {
flex-shrink: 0;
width: 100px;
color: #9ca3af;
letter-spacing: 1px;
}
.notice-header-title {
@@ -547,29 +557,88 @@ main.container {
min-width: 0;
}
.notice-header-end {
.notice-header-date {
flex-shrink: 0;
width: 100px;
width: 130px;
text-align: right;
}
/* 右侧截止时间: 等宽数字 + 灰色, 与日期列同宽对齐 */
.notice-end {
flex-shrink: 0;
width: 100px;
font-size: 13px;
color: #6b7280;
font-family: ui-monospace, "Courier New", monospace;
padding-top: 2px;
.notice-item {
position: relative;
display: flex;
align-items: center;
gap: 24px;
padding: 16px 20px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s;
}
.notice-item-no {
.notice-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 52%;
border-radius: 2px;
background: var(--brand-primary);
opacity: 0;
transition: opacity 0.2s ease;
}
.notice-item:hover {
background: #f7f9fb;
}
.notice-item:hover::before {
opacity: 1;
}
.notice-item:hover .notice-title {
color: var(--brand-primary);
}
.notice-title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 500;
color: #1f2937;
line-height: 1.6;
transition: color 0.2s;
}
.notice-arrow {
flex-shrink: 0;
width: 32px;
text-align: center;
color: var(--brand-primary);
opacity: 0;
transform: translateX(-6px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.notice-item:hover .notice-arrow {
opacity: 1;
transform: translateX(0);
}
.notice-date {
flex-shrink: 0;
width: 130px;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
font-size: 12px;
color: #9ca3af;
font-family: ui-monospace, "Courier New", monospace;
padding-top: 2px;
color: #8a919c;
}
.notice-date-icon {
color: #b6bcc6;
}
/* ========== 分页 ========== */
+8 -2
View File
@@ -212,6 +212,12 @@ function isDuplicateError(msg) {
return /已存在|重复|duplicate|uk_person_phone/i.test(msg || '')
}
// 按后端原因精确提示: 登录账号(用户名) vs 手机号 (后端分别抛 "登录账号已存在" / DB uk_person_phone)
function duplicateTip(msg) {
if (/登录账号|用户名|login/i.test(msg || '')) return '登录账号已存在,请更换'
return '该手机号已存在,请更换'
}
async function onSave() {
try {
await formRef.value.validate()
@@ -247,12 +253,12 @@ async function onSave() {
}
} else {
const msg = res?.msg || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
if (isDuplicateError(msg)) ElMessage.warning(duplicateTip(msg))
else ElMessage.error(msg)
}
} catch (e) {
const msg = e?.msg || e?.message || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
if (isDuplicateError(msg)) ElMessage.warning(duplicateTip(msg))
else ElMessage.error(msg)
} finally {
saving.value = false
+8 -2
View File
@@ -241,6 +241,12 @@ const detail = ref({})
const detailOpen = ref(false)
function formatMoney(v) { return Number(v || 0).toFixed(2) }
// 当前本地时间 "yyyy-MM-dd HH:mm:ss" (发布用, 与后端 GMT+8 一致; 不能用 toISOString 的 UTC)
function nowLocalDatetime() {
const d = new Date()
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
async function load() {
loading.value = true
@@ -387,7 +393,7 @@ async function onBatchOpen() {
try { await ElMessageBox.confirm(`确定对选中的 ${selected.value.length} 个项目批量开通吗?`, '批量开通', { type: 'warning' }) } catch { return }
let ok = 0
for (const r of selected.value) {
try { await bizUpdate('project', { projectId: r.projectId, isPublished: '1' }); ok++ } catch {}
try { await bizUpdate('project', { projectId: r.projectId, isPublished: '1', publishTime: nowLocalDatetime() }); ok++ } catch {}
}
ElMessage.success(`已批量开通 ${ok}`)
load()
@@ -405,7 +411,7 @@ async function onFinish(row) {
async function onOpen(row) {
try { await ElMessageBox.confirm(`确定开通「${row.projectName}」吗?`, '开通', { type: 'warning' }) } catch { return }
try {
await bizUpdate('project', { projectId: row.projectId, isPublished: '1' })
await bizUpdate('project', { projectId: row.projectId, isPublished: '1', publishTime: nowLocalDatetime() })
ElMessage.success('已开通')
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }