feat: 多处页面改造 + 新增劳务协议模板配置

主要改动:
- biz_meeting_attendee 中间表 + doctor/expert 角色数据隔离
- biz_meeting_attendee 加 handsign/labor_protocol 字段 (劳务协议改造)
- biz_labor_protocol_template 配置表 + admin 页面 (从 HeguiConstants 迁移)
- biz_expert.expertId 改 Long + IdGenerator 生成 (去自增)
- Login.vue / PublicityDetail.vue / Home.vue 等多处 bug 修复 + UI 改进
- 新增 page-tech-review 报告 3 篇 (_self/*.md)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-19 21:06:49 +08:00
co-authored by Claude
parent ec4a8aa8f0
commit e496f34393
44 changed files with 2143 additions and 130 deletions
+10
View File
@@ -0,0 +1,10 @@
import request from '@/utils/request'
/**
* 当前登录用户的专家信息 (按 token 拿 userId 查 biz_expert)
* 返回 biz_expert 完整记录, 含 name / phone / department / title / workUnit 等
* 若当前用户不是专家 / 没注册过, 返回 null
*/
export function getMyExpertProfile() {
return request({ url: '/business/expert/profile', method: 'get' })
}
@@ -0,0 +1,30 @@
import request from '@/utils/request'
/**
* 劳务协议模板 API
* 全局共享, admin 在 /admin/labor-protocol 管理
*/
export function listLaborProtocolTemplates(params) {
return request({ url: '/business/laborProtocolTemplate/list', method: 'get', params })
}
export function getLaborProtocolTemplate(id) {
return request({ url: `/business/laborProtocolTemplate/${id}`, method: 'get' })
}
export function getDefaultLaborProtocolTemplate() {
return request({ url: '/business/laborProtocolTemplate/default', method: 'get' })
}
export function listEnabledLaborProtocolTemplates() {
return request({ url: '/business/laborProtocolTemplate/allEnabled', method: 'get' })
}
export function createLaborProtocolTemplate(data) {
return request({ url: '/business/laborProtocolTemplate', method: 'post', data })
}
export function updateLaborProtocolTemplate(data) {
return request({ url: '/business/laborProtocolTemplate', method: 'put', data })
}
export function deleteLaborProtocolTemplate(id) {
return request({ url: `/business/laborProtocolTemplate/${id}`, method: 'delete' })
}
export function setDefaultLaborProtocolTemplate(id) {
return request({ url: `/business/laborProtocolTemplate/${id}/default`, method: 'put' })
}
@@ -0,0 +1,25 @@
import request from '@/utils/request'
/**
* 当前登录用户的"待签署"会议列表 (handsign 或 labor_protocol 任一为空)
* 返回 [{id, meetingId, meetingName, startTime, ...}, ...]
*/
export function listUnsignedMeetingProtocols() {
return request({ url: '/business/meetingAttendee/unsigned', method: 'get' })
}
/**
* 更新手写签名 (Base64 字符串, 直接存 DB longtext)
* @param {number|string} id 中间表主键
* @param {string} handsign Base64 编码的签名图片
*/
export function updateHandsign(id, handsign) {
return request({ url: `/business/meetingAttendee/${id}/handsign`, method: 'put', data: { handsign } })
}
/**
* 更新劳务协议 URL (OSS 文件 URL, 由前端 OssImageUploader 上传后传入)
*/
export function updateLaborProtocol(id, laborProtocol) {
return request({ url: `/business/meetingAttendee/${id}/laborProtocol`, method: 'put', data: { laborProtocol } })
}
+2 -1
View File
@@ -82,7 +82,8 @@ const MENU = {
]},
{ path: '/admin/manage', title: '网站管理', icon: Setting, children: [
{ path: '/admin/article', title: '协议管理', icon: Files },
{ path: '/admin/special-plan', title: '专项计划管理', icon: Compass }
{ path: '/admin/special-plan', title: '专项计划管理', icon: Compass },
{ path: '/admin/labor-protocol', title: '劳务协议配置', icon: Document }
]},
{ path: '/admin/account', title: '账号信息', icon: User }
],
+1
View File
@@ -47,6 +47,7 @@ const routes = [
{ path: 'special-plan', name: 'admin-special-plan', component: () => import('@/views/admin/BizSpecialPlanAdmin.vue'), meta: { title: '专项计划管理' } },
{ path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } },
{ path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } },
{ path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } },
{ path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } }
]
},
+1 -1
View File
@@ -260,7 +260,7 @@ function fmtTime(d) {
// ===== 审核 =====
const auditOpen = ref(false)
// result: '2'=通过, '3'=拒绝 (BizAuditStatusEnum: 0未提交 1待审核 2通过 3拒绝)
const auditForm = reactive({ expertId: '', name: '', phone: '', result: '2', opinion: '' })
const auditForm = reactive({ expertId: null, name: '', phone: '', result: '2', opinion: '' })
function onAudit(row) {
auditForm.expertId = row.expertId
auditForm.name = row.name
+219
View File
@@ -0,0 +1,219 @@
<template>
<div class="page-card admin-labor-protocol">
<div class="breadcrumb">首页 / 网站管理 / 劳务协议配置</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="模板名">
<el-input v-model="q.templateName" placeholder="输入模板名" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width:120px">
<el-option label="启用" value="Y" />
<el-option label="禁用" value="N" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button type="success" @click="openCreate">新建模板</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="templateName" label="模板名" min-width="180" show-overflow-tooltip />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column label="默认" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.defaultFlag === 'Y'" type="success">默认</el-tag>
<span v-else style="color:#c0c4cc"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'Y' ? 'success' : 'info'">{{ row.status === 'Y' ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" link type="warning" :disabled="row.defaultFlag === 'Y'" @click="setDefault(row)">设为默认</el-button>
<el-button size="small" link type="danger" @click="removeRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="q.pageNum"
v-model:page-size="q.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
<!-- 新建/编辑 dialog -->
<el-dialog v-model="editOpen" :title="form.id ? '编辑模板' : '新建模板'" width="780px" destroy-on-close>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="模板名" prop="templateName">
<el-input v-model="form.templateName" placeholder="如: 默认劳务协议" maxlength="100" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="默认模板">
<el-switch v-model="form.defaultFlag" active-value="Y" inactive-value="N" />
<span style="margin-left:12px;color:#909399;font-size:12px">开启后其他模板自动取消默认</span>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio value="Y">启用</el-radio>
<el-radio value="N">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="模板内容" prop="templateContent">
<el-input v-model="form.templateContent" type="textarea" :rows="14"
placeholder="HTML 模板, 支持占位符: {name} {phone} {会议名称} {会议时间} {会议地址} {劳务形式} {费用总额} ..." />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="选填" maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editOpen = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const q = reactive({ templateName: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const r = await request({ url: '/business/laborProtocolTemplate/list', method: 'get', params: { ...q } })
rows.value = (r.data && r.data.rows) || r.rows || []
total.value = (r.data && r.data.total) || r.total || 0
} finally { loading.value = false }
}
function reset() {
q.templateName = ''
q.status = ''
q.pageNum = 1
load()
}
const editOpen = ref(false)
const submitting = ref(false)
const formRef = ref(null)
const form = reactive({
id: null,
templateName: '',
templateContent: '',
defaultFlag: 'N',
sortOrder: 0,
status: 'Y',
remark: ''
})
const rules = {
templateName: [{ required: true, message: '请输入模板名', trigger: 'blur' }],
templateContent: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
function openCreate() {
Object.assign(form, {
id: null,
templateName: '',
templateContent: '',
defaultFlag: 'N',
sortOrder: 0,
status: 'Y',
remark: ''
})
editOpen.value = true
}
function openEdit(row) {
Object.assign(form, {
id: row.id,
templateName: row.templateName,
templateContent: row.templateContent,
defaultFlag: row.defaultFlag || 'N',
sortOrder: row.sortOrder || 0,
status: row.status || 'Y',
remark: row.remark || ''
})
editOpen.value = true
}
async function submit() {
await formRef.value.validate()
submitting.value = true
try {
if (form.id) {
await request({ url: '/business/laborProtocolTemplate', method: 'put', data: form })
ElMessage.success('已更新')
} else {
await request({ url: '/business/laborProtocolTemplate', method: 'post', data: form })
ElMessage.success('已新建')
}
editOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally { submitting.value = false }
}
async function setDefault(row) {
try {
await ElMessageBox.confirm(`确认将"${row.templateName}"设为默认模板? 其他默认模板将自动取消`, '提示', { type: 'warning' })
} catch { return }
try {
await request({ url: `/business/laborProtocolTemplate/${row.id}/default`, method: 'put' })
ElMessage.success('已设为默认')
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
async function removeRow(row) {
if (row.defaultFlag === 'Y') {
return ElMessage.warning('默认模板不能删除, 请先取消默认')
}
try {
await ElMessageBox.confirm(`确认删除"${row.templateName}"?`, '提示', { type: 'warning' })
} catch { return }
try {
await request({ url: `/business/laborProtocolTemplate/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) { ElMessage.error(e?.msg || '删除失败') }
}
onMounted(load)
</script>
<style scoped>
.admin-labor-protocol { padding: 16px; max-width: 1400px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.el-textarea__inner) { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; }
</style>
+2
View File
@@ -267,6 +267,7 @@ async function afterLogin(token, displayName, fallbackRole) {
userId: u.userId,
userName: u.userName || displayName,
nickName: u.nickName || displayName,
phonenumber: u.phonenumber || '',
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
@@ -277,6 +278,7 @@ async function afterLogin(token, displayName, fallbackRole) {
userId: null,
userName: displayName,
nickName: displayName,
phonenumber: '',
accountType: 'MAIN',
parentUserId: null,
role: fallbackRole
+2 -2
View File
@@ -18,10 +18,10 @@
<el-input v-model="form.workUnit" placeholder="请输入工作单位(医院全称)" />
</el-form-item>
<el-form-item label="科室" prop="department">
<DoctorDeptSelect v-model="form.department" placeholder="请输入所在科室" />
<DoctorDeptSelect v-model="form.department" value-field="label" placeholder="请输入所在科室" />
</el-form-item>
<el-form-item label="职称" prop="doctorTitle">
<DoctorTitleSelect v-model="form.doctorTitle" placeholder="请输入职称" />
<DoctorTitleSelect v-model="form.doctorTitle" value-field="label" placeholder="请输入职称" />
</el-form-item>
<el-form-item label="手机号码" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" />
+26 -13
View File
@@ -5,7 +5,7 @@
<!-- 欢迎栏 -->
<div class="welcome-bar">
<div class="welcome-text">
<h2>下午好,{{ store.user?.userName || '专家' }}专家</h2>
<h2>下午好,{{ displayName }}专家</h2>
<p>欢迎使用项目管理系统</p>
</div>
<div class="welcome-time">
@@ -37,13 +37,11 @@
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a>
</h2>
<ul class="simple-list">
<li class="simple-item" v-for="s in pendingAgreements" :key="s.planId">
<li class="simple-item" v-for="s in pendingAgreements" :key="s.id">
<div class="item-main">
<span class="item-title">{{ s.planName }}</span>
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
</div>
<span class="item-status" :class="{ done: s.status === '2' }">
{{ s.status === '2' ? '已通过' : (s.status === '3' ? '已退回' : (s.status === '1' ? '审核中' : '待提交')) }}
</span>
<span class="item-status">待签署</span>
</li>
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
</ul>
@@ -72,15 +70,23 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/store/user'
import { bizList, listMyMessages } from '@/api/public'
import { getMyExpertProfile } from '@/api/business/expert'
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
const store = useUserStore()
const upcomingMeetings = ref([])
const pendingAgreements = ref([])
const notices = ref([])
// 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号))
const expertName = ref('')
// 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家'
const displayName = computed(() =>
expertName.value || store.user?.nickName || store.user?.userName || '专家'
)
const nowTime = ref('')
const nowDate = ref('')
@@ -109,13 +115,20 @@ async function load() {
upcomingMeetings.value = (data?.rows || []).slice(0, 5)
} catch (e) { upcomingMeetings.value = [] }
// 待签署协议 (改走 biz_project_plan, 后端 doctor 角色已自动按当前用户过滤)
// 当前用户的专家真实姓名 (用于欢迎栏)
try {
const { data } = await bizList('projectPlan', { pageNum: 1, pageSize: 5 })
pendingAgreements.value = (data?.rows || []).slice(0, 5).map(s => ({
planId: s.planId,
planName: s.planName,
status: s.status
const { data } = await getMyExpertProfile()
expertName.value = data?.name || ''
} catch (e) { expertName.value = '' }
// 待签署协议 (v3: 改走 biz_meeting_attendee, 任一未签即显示)
try {
const { data } = await listUnsignedMeetingProtocols()
pendingAgreements.value = (data || []).slice(0, 5).map(s => ({
id: s.id, // biz_meeting_attendee.id (用于签署接口)
meetingId: s.meetingId,
meetingName: s.meetingName,
startTime: s.startTime
}))
} catch (e) { pendingAgreements.value = [] }
+1 -1
View File
@@ -4,7 +4,7 @@
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" maxlength="11" /></el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
+3 -3
View File
@@ -278,7 +278,7 @@ function fmtTime(d) {
const editOpen = ref(false)
const editFormRef = ref(null)
const editForm = reactive({
expertId: '', name: '', phone: '', workUnit: '', department: '', title: '',
expertId: null, name: '', phone: '', workUnit: '', department: '', title: '',
practiceCertUrl: '', titleCertUrl: ''
})
const editRules = {
@@ -289,7 +289,7 @@ const editRules = {
title: [{ required: true, message: '请选择职称', trigger: 'change' }]
}
function openAdd() {
Object.assign(editForm, { expertId: '', name: '', phone: '', workUnit: '', department: '', title: '', practiceCertUrl: '', titleCertUrl: '' })
Object.assign(editForm, { expertId: null, name: '', phone: '', workUnit: '', department: '', title: '', practiceCertUrl: '', titleCertUrl: '' })
editOpen.value = true
}
// 修改按钮: 跳转独立页 /manager/experts/edit/:id (参考 /admin/experts/new)
@@ -316,7 +316,7 @@ async function submitEdit() {
// ========== 审核 dialog ==========
const auditOpen = ref(false)
// result: '2'=通过, '3'=拒绝 (BizAuditStatusEnum: 0未提交 1待审核 2通过 3拒绝)
const auditForm = reactive({ expertId: '', name: '', phone: '', result: '2', opinion: '' })
const auditForm = reactive({ expertId: null, name: '', phone: '', result: '2', opinion: '' })
function onAudit(row) {
auditForm.expertId = row.expertId
auditForm.name = row.name
-2
View File
@@ -410,8 +410,6 @@ function confirmDeleteAnn() {
publishUrl: '',
invitationUrl: '',
supportLetterUrl: '',
noticeUrl: '',
scheduleUrl: '',
isPublished: '0'
})
.then(() => { ElMessage.success(`已删除项目 ${delAnnTargetRow.value.projectNo} 的公告`); delAnnModalOpen.value = false; load() })
+2 -6
View File
@@ -239,12 +239,10 @@ async function loadProject() {
if (m) { n.url = m.url || ''; n.name = m.name || '' }
}
} else {
// 兼容老数据
// 兼容老数据 (DB 实测只剩 invitationUrl/supportLetterUrl/publishUrl 3 个独立列)
const noticeMap = {
invitation: p.invitationUrl,
supportLetter: p.supportLetterUrl,
notice: p.noticeUrl,
schedule: p.scheduleUrl,
publish: p.publishUrl
}
for (const n of form.notices) {
@@ -307,7 +305,7 @@ async function submit(mode = 'save') {
leadUserId: form.leadUserId,
isBidProject: form.isBidProject
}
// 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/noticeUrl/scheduleUrl)
// 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/publishUrl 独立列)
payload.publicityFiles = JSON.stringify(uploaded.map(n => ({
type: n.key,
label: n.label,
@@ -318,8 +316,6 @@ async function submit(mode = 'save') {
for (const n of uploaded) {
if (n.key === 'invitation') payload.invitationUrl = n.url
else if (n.key === 'supportLetter') payload.supportLetterUrl = n.url
else if (n.key === 'notice') payload.noticeUrl = n.url
else if (n.key === 'schedule') payload.scheduleUrl = n.url
}
// 发布时设置 is_published='1' + publish_time (同步后端字段)
if (mode === 'publish') {
-2
View File
@@ -164,8 +164,6 @@ async function load() {
const urlMap = {
invitation: r.invitationUrl,
support: r.supportLetterUrl,
notice: r.noticeUrl,
schedule: r.scheduleUrl,
publish: r.publishUrl
}
return {
+118 -25
View File
@@ -98,10 +98,10 @@
</svg>
<span>{{ signed ? '已报名' : (signing ? '报名中…' : '立即报名') }}</span>
</button>
<button class="action-btn primary" :disabled="supportSubmitting || supportSubmitted" @click="onSupportIntent">
<button v-if="canShowSupportBtn" class="action-btn primary" :disabled="supportSubmitting || supportSubmitted" @click="onSupportIntent">
<span>{{ supportSubmitted ? '已支持' : (supportSubmitting ? '提交中…' : '表达支持意向') }}</span>
</button>
<button class="action-btn primary" :disabled="executionSubmitting || executionSubmitted" @click="onExecutionIntent">
<button v-if="canShowExecutionBtn" class="action-btn primary" :disabled="executionSubmitting || executionSubmitted" @click="onExecutionIntent">
<span>{{ executionSubmitted ? '已表达意向' : (executionSubmitting ? '提交中…' : '表达执行意向') }}</span>
</button>
<button class="action-btn primary share-btn" @click="showQr = true">
@@ -193,13 +193,13 @@
destroy-on-close
>
<el-form :model="guestDialog.form" label-width="100px">
<el-form-item label="姓名 *" required>
<el-form-item label="姓名" required>
<el-input v-model="guestDialog.form.name" placeholder="请输入姓名" maxlength="50" clearable />
</el-form-item>
<el-form-item label="手机号 *" required>
<el-form-item label="手机号" required>
<el-input v-model="guestDialog.form.phone" placeholder="请输入手机号" maxlength="11" clearable />
</el-form-item>
<el-form-item label="工作单位 *" required>
<el-form-item label="工作单位" required>
<el-input v-model="guestDialog.form.workUnit" placeholder="请输入工作单位" maxlength="200" clearable />
</el-form-item>
<el-form-item label="部门">
@@ -223,6 +223,8 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import { listBizPerson } from '@/api/business/person'
import { bizGet } from '@/api/public'
import {
submitPublicitySupportIntent,
submitPublicityExecutionIntent,
@@ -239,6 +241,20 @@ 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 / leader / manager / doctor → 两个都隐藏
// 匿名 → 两个都显示 (走 guestDialog)
const canShowSupportBtn = computed(() => {
const r = userStore.user?.role || ''
if (!r) return true
return r === 'sponsor'
})
const canShowExecutionBtn = computed(() => {
const r = userStore.user?.role || ''
if (!r) return true
return r === 'executor'
})
const showQr = ref(false)
const qrUrl = ref('')
@@ -266,12 +282,10 @@ watch(showQr, async (v) => {
const ann = ref(null)
const activeTab = ref('invitation')
// 按 URL 字段定义 4 个固定 tab (key 必须与 biz_project 字段名后缀一致)
// 按 URL 字段定义固定 tab (key 必须与 biz_project 字段名后缀一致)
const TAB_DEFS = [
{ key: 'invitation', label: '邀请函', urlKey: 'invitationUrl' },
{ key: 'supportLetter', label: '支持函', urlKey: 'supportLetterUrl' },
{ key: 'notice', label: '通知', urlKey: 'noticeUrl' },
{ key: 'schedule', label: '日程', urlKey: 'scheduleUrl' }
{ key: 'supportLetter', label: '支持函', urlKey: 'supportLetterUrl' }
]
// 只显示项目里"实际有文件 URL"的 tab
@@ -433,12 +447,11 @@ const supportSubmitted = ref(false)
const executionSubmitting = ref(false)
const executionSubmitted = ref(false)
// 持久化"已提交"标记 - 已登录按 sys_user.phonenumber, 未登录无身份只能本次会话判定
// 持久化"已提交"标记 - 已登录按 sys_user.phone 查后端 hasIntent
async function checkIntentSubmitted(type) {
const proj = ann.value || {}
const projectId = proj.projectId || proj.id
if (!projectId) return false
// 已登录: 用登录用户的手机号去查
const phone = userStore.user?.phonenumber || userStore.user?.phoneNumber
if (!phone) return false
try {
@@ -465,10 +478,49 @@ function resetGuestForm() {
guestDialog.form = { name: '', phone: '', workUnit: '', department: '', position: '' }
}
// 已登录用户 → 从 userStore + biz_person + biz_org 回填全部 5 个字段
// 流程: userStore 拿 name/phone → /business/person/list?userId= 拿 department/position/orgId → /business/org/{orgId} 拿 orgName(作 workUnit)
// 任何一步失败都优雅降级, 不阻塞 dialog 打开
async function prefillGuestFormFromUser() {
const u = userStore.user || {}
guestDialog.form = {
name: u.nickName || u.userName || '',
phone: u.phonenumber || u.phoneNumber || '',
workUnit: '',
department: '',
position: ''
}
const userId = u.userId
if (!userId) return // 没 userId (兜底), 不查 biz_person
// 1) 拉 biz_person
let person = null
try {
const { data } = await listBizPerson({ userId, pageNum: 1, pageSize: 1 })
const rows = data?.rows || []
person = rows[0] || null
} catch { /* 静默失败: dialog 仍打开, 用户手填 */ }
if (!person) return
guestDialog.form.department = person.department || ''
guestDialog.form.position = person.position || ''
// 2) 拉 biz_org → workUnit (优先用 JOIN 出来的 orgName; 没有再单查)
if (person.orgName) {
guestDialog.form.workUnit = person.orgName
} else if (person.orgId) {
try {
const { data: org } = await bizGet('org', person.orgId)
guestDialog.form.workUnit = org?.orgName || ''
} catch { /* 静默 */ }
}
}
function onSupportIntent() {
if (!canShowSupportBtn.value) return // 防御: 防止 v-if 被绕过
if (supportSubmitting.value || supportSubmitted.value) return
if (loggedIn.value) {
doSubmitIntent('support')
guestDialog.type = 'support'
guestDialog.title = '表达支持意向'
guestDialog.open = true
prefillGuestFormFromUser() // 异步, 不 await; dialog 已先打开, 数据慢慢填进去
} else {
resetGuestForm()
guestDialog.type = 'support'
@@ -478,9 +530,13 @@ function onSupportIntent() {
}
function onExecutionIntent() {
if (!canShowExecutionBtn.value) return // 防御
if (executionSubmitting.value || executionSubmitted.value) return
if (loggedIn.value) {
doSubmitIntent('execution')
guestDialog.type = 'execution'
guestDialog.title = '表达执行意向'
guestDialog.open = true
prefillGuestFormFromUser() // 异步, 不 await
} else {
resetGuestForm()
guestDialog.type = 'execution'
@@ -502,21 +558,14 @@ async function onGuestDialogConfirm() {
finally { guestDialog.submitting = false }
}
// 实际提交: 已登录不带 5 字段 (后端从 sys_user 取 user_id, 但姓名/手机号/单位仍需 dialog 收集 — 故未登录分支专门处理;
// 已登录分支: 仍然弹 dialog 让用户填 5 字段, 仅 user_id 自动回填, 不复用登录信息是因为匿名流程设计的字段是访客视角的"姓名/手机号/单位/部门/职务",
// 与登录专家视角的"姓名/手机号/工作单位/科室/职称" 不完全一致; 但支持/执行意向两表字段一致, 所以走 dialog)
// 实际提交: 已登录用户由 onSupportIntent/onExecutionIntent 直接打开 dialog,
// dialog 提交时调 onGuestDialogConfirm → 传 fields → 走 doSubmitIntent(type, fields)
async function doSubmitIntent(type, fields) {
const proj = ann.value || {}
const projectId = proj.projectId || proj.id
if (!projectId) return ElMessage.warning('项目ID缺失,无法提交')
// fields 可能为空 (已登录快速通道) → 触发 dialog
if (!fields) {
resetGuestForm()
guestDialog.type = type
guestDialog.title = type === 'support' ? '表达支持意向' : '表达执行意向'
guestDialog.open = true
return
}
// 防御: fields 缺失时不开 dialog (dialog 已在 onSupportIntent/onExecutionIntent 里打开)
if (!fields) return
const setter = type === 'support' ? s => { supportSubmitting.value = s } : s => { executionSubmitting.value = s }
const markDone = type === 'support' ? () => { supportSubmitted.value = true } : () => { executionSubmitted.value = true }
setter(true)
@@ -743,7 +792,40 @@ onBeforeUnmount(() => {
* {
.logo,
.nav-list,
.nav-item,
.nav-link,
.logo-text,
.logo-title,
.logo-subtitle,
.top-tools,
.footer-main,
.footer-brand,
.footer-col,
.footer-col h4,
.brand-row,
.footer-desc,
.footer-col a,
.footer-col p,
.footer-qr,
.footer-logo,
.qr-image,
.qr-label,
.footer-bottom,
.footer-brand-name,
.footer-brand-en,
.action-bar,
.modal,
.modal-qr,
.modal-qr-wrap,
.modal-title,
.modal-tip,
.modal-close,
.qr-corner,
.qr-loading,
.qr-dot,
.modal-tip {
margin: 0;
padding: 0;
box-sizing: border-box;
@@ -870,7 +952,18 @@ a { color: inherit; text-decoration: none; }
display: flex;
align-items: center;
gap: 16px;
color: #fff;
}
.top-tools .user-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #fff;
font-size: 14px;
cursor: pointer;
transition: opacity .2s;
}
.top-tools .user-link:hover { opacity: .85; }
.login-btn {
padding: 7px 20px;