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
-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;