Files
guoju0808/ry-vue3/src/views/doctor/SignFill.vue
T
郭庆泰 56615f9806 feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强
OA ecology:
- BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档
- ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配
- application-prod/test.yml: ecology 只读库数据源

会议邀请函/分享:
- InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享
- PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页

参会人/执行意向:
- BizMeetingAttendee*: 参会人模块增强
- BizExecutionIntent*: 执行意向增强

签到/劳务/导入:
- BizSignServiceImpl + SignFill.vue: 签到劳务
- FieldMismatch/ImportMismatch: 导入校验 VO

公示/会议详情:
- PublicityDetail.vue / MeetingDetail.vue 等前端增强

配置:
- test 短信 mock + 供应商/招标接口切生产域名
2026-09-03 20:12:15 +08:00

512 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="sign-fill">
<div v-if="notInvited" class="not-invited">
<div class="not-invited-text">您暂未被邀请请联系执会人员</div>
<el-button type="primary" @click="goBack">返回</el-button>
</div>
<div v-else v-loading="loading">
<!-- 已签署: 再次打开签署链接, 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer) -->
<div v-if="signed" class="signed-pdf">
<div class="sign-header">
<div class="sign-header-title">{{ meetingName || '劳务协议' }}</div>
<div v-if="periodDisplay" class="sign-header-period">期数{{ periodDisplay }}</div>
</div>
<PdfViewer v-if="laborPdfUrl" :url="laborPdfUrl" class="signed-pdf-viewer" />
<div v-else class="signed-pdf-empty">协议已签署暂无可展示的 PDF</div>
<div class="signed-pdf-actions">
<el-button v-if="laborPdfUrl" type="primary" @click="downloadPdf">下载劳务协议</el-button>
<el-button @click="goBack">返回</el-button>
</div>
</div>
<el-form v-else :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form">
<!-- 大标题: 会议名称 + 期数 -->
<div class="sign-header">
<div class="sign-header-title">{{ meetingName || '劳务协议签署' }}</div>
<div class="sign-header-period">期数{{ periodDisplay || '-' }}</div>
</div>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" maxlength="50" />
</el-form-item>
<el-form-item label="工作单位" prop="workUnit">
<el-input v-model="form.workUnit" placeholder="请输入工作单位" maxlength="200" />
</el-form-item>
<el-form-item label="科室" prop="department">
<el-input v-model="form.department" placeholder="请输入科室" maxlength="100" />
</el-form-item>
<el-form-item label="医务职称">
<el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 100%">
<el-option v-for="t in titleOptions" :key="t" :value="t" :label="t" />
<template #footer>
<el-input v-if="showTitleOther" v-model="titleOther" placeholder="其他职称" maxlength="50" @blur="form.title = titleOther" />
<el-link :underline="false" v-else type="primary" @click="showTitleOther = true">+ 其他</el-link>
</template>
</el-select>
</el-form-item>
<el-form-item label="账户名称(持卡人姓名)" prop="accountName">
<el-input v-model="form.accountName" placeholder="请输入持卡人姓名" maxlength="100" />
</el-form-item>
<el-form-item label="身份证号" prop="idCard">
<el-input v-model="form.idCard" placeholder="请输入身份证号" maxlength="50" />
</el-form-item>
<el-form-item label="银行卡号" prop="bankCard">
<el-input v-model="form.bankCard" placeholder="请输入银行卡号" maxlength="30" />
</el-form-item>
<el-form-item label="银行名称" prop="bankName">
<el-input v-model="form.bankName" placeholder="请输入银行名称" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行(支行)" prop="bankBranch">
<el-input v-model="form.bankBranch" placeholder="请输入开户行名称" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行地址" prop="bankRegion">
<AreaCascader
v-model="form.bankRegion"
format="string"
join-sep="/"
:max-level="2"
placeholder="请选择省/市"
/>
</el-form-item>
<el-form-item label="银行详细地址" prop="bankAddress">
<el-input v-model="form.bankAddress" placeholder="如: 海淀支行 / XX 路 1 号" maxlength="200" />
</el-form-item>
<el-form-item label="身份证附件">
<IdCardUploader
v-model="form.idCardAttachments"
:dir="'ry8080/idcard/'"
/>
</el-form-item>
<el-form-item label="劳务形式">
<div class="labor-form-cell">
<el-checkbox-group v-model="laborFormSelected">
<el-checkbox v-for="opt in laborFormOptions" :key="opt.value" :value="opt.value" :label="opt.label" />
</el-checkbox-group>
<div v-if="laborFormSelected.includes('__other__')" class="other-input">
<el-input v-model="laborFormOther" placeholder="请输入其他劳务形式" maxlength="100" />
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">下一步</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getSignInfo, saveSignProfile, resolveSign } from '@/api/business/sign'
import { getInfo } from '@/api/auth'
import { useUserStore } from '@/store/user'
import IdCardUploader from '@/components/IdCardUploader.vue'
import AreaCascader from '@/components/AreaCascader.vue'
import PdfViewer from '@/components/PdfViewer.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const attendeeId = ref(null)
const meetingId = computed(() => route.query.meetingId ? Number(route.query.meetingId) : null)
const notInvited = ref(false)
const loading = ref(false)
const submitting = ref(false)
const formRef = ref(null)
// 已签署状态: 再次打开签署链接时直接展示 PDF
const signed = ref(false)
const laborPdfUrl = ref('')
// 劳务形式 (checkboxOther 多选)
const laborFormOptions = ref([])
const laborFormSelected = ref([])
const laborFormOther = ref('')
// 职称 (selectOther, 含 "其他" 输入)
const titleOptions = ref([])
const showTitleOther = ref(false)
const titleOther = ref('')
const form = reactive({
name: '', phone: '', workUnit: '', department: '',
accountName: '', idCard: '', bankCard: '', bankName: '', bankBranch: '',
bankRegion: '', // AreaCascader v-model (string 格式, "省/市")
bankAddress: '', // 银行详细地址
idCardAttachments: '', // 身份证正反面 URL CSV (IdCardUploader)
title: ''
})
const feeDisplay = reactive({ preTax: '—', tax: '—', fee: '—' })
// 会议大标题 (会议名称 + 期数), 由后端 getSignInfo 返回
const meetingName = ref('')
const periodNo = ref(null)
const totalPeriods = ref(null)
const periodDisplay = computed(() => {
const p = periodNo.value
const t = totalPeriods.value
if (p == null && t == null) return ''
if (p == null) return `${t}`
if (t == null) return `${p}`
return `${p}/${t}`
})
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[0-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
department: [{ required: true, message: '请输入科室', trigger: 'blur' }],
accountName: [{ required: true, message: '请输入持卡人姓名', trigger: 'blur' }],
idCard: [{ required: true, message: '请输入身份证号', trigger: 'blur' }],
bankCard: [{ required: true, message: '请输入银行卡号', trigger: 'blur' }],
bankName: [{ required: true, message: '请输入银行名称', trigger: 'blur' }],
bankBranch: [{ required: true, message: '请输入开户行支行', trigger: 'blur' }]
}
/** 扫码带 token 直登 / 无 token 校验登录; 返回 true 表示已就绪 */
async function ensureLogin() {
const token = route.query.token
if (token) {
// 扫码带 token: 用新 token 重新登录 (覆盖本地已登录态)
userStore.setToken(token)
try {
const info = await getInfo()
const u = info.user || {}
const role = u.roleType
if (!role) {
ElMessage.error('账号角色未配置, 请联系管理员')
router.replace({ name: 'login' })
return false
}
userStore.setUser({
userId: u.userId,
userName: u.userName || u.nickName || '',
nickName: u.nickName || '',
phonenumber: u.phonenumber || '',
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
return true
} catch (e) {
ElMessage.error('登录已失效, 请重新登录')
router.replace({ name: 'login' })
return false
}
}
// 无 token: 必须已登录
if (userStore.user) return true
ElMessage.error('请先登录')
router.replace({ name: 'login', query: { redirect: route.fullPath } })
return false
}
async function init() {
loading.value = true
const ok = await ensureLogin()
if (!ok) return
// 解析 attendeeId: 优先 URL 里的 attendeeId; 没有则按 meetingId 查
let aid = route.query.attendeeId ? Number(route.query.attendeeId) : null
if (!aid && meetingId.value) {
try {
const { data } = await resolveSign(meetingId.value)
if (!data || !data.attendeeId) {
notInvited.value = true
loading.value = false
return
}
aid = data.attendeeId
// 标题: 有 meetingId → 按 meetingId 查 (resolve 已返回会议名/期数)
meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
} catch (e) {
ElMessage.error(e?.msg || '加载失败')
loading.value = false
return
}
}
attendeeId.value = aid
await load()
}
async function load() {
if (!attendeeId.value) {
ElMessage.error('参数缺失, 请从工作台进入')
router.push('/doctor/home')
return
}
loading.value = true
try {
const { data } = await getSignInfo(attendeeId.value)
// 已签署: 再次打开签署链接 → 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer)
if (data.signed || (data.laborProtocol && data.laborProtocol.trim())) {
signed.value = true
laborPdfUrl.value = data.laborProtocol || ''
meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
return
}
// 预填 (current 优先, 否则用 defaults)
const cur = data.current || {}
const def = data.defaults || {}
Object.assign(form, {
name: cur.name || def.name || '',
phone: cur.phone || def.phone || '',
workUnit: cur.workUnit || def.workUnit || '',
department: cur.department || def.department || '',
accountName: cur.accountName || '',
idCard: cur.idCard || def.idCard || '',
bankCard: cur.bankCard || def.bankCard || '',
bankName: cur.bankName || def.bankName || '',
bankBranch: cur.bankBranch || '',
bankRegion: cur.bankRegion || def.bankRegion || '',
bankAddress: cur.bankAddress || def.bankAddress || '',
idCardAttachments: cur.idCardAttachments || '',
title: cur.title || def.title || ''
})
// 先设置选项 (项目角色 + "其他"), 解析劳务形式需要用它区分"项目角色"和"其他自定义"
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label }))
titleOptions.value = data.titleOptions || []
// 劳务形式回显: 兼容旧 JSON 数组 (["主席","__other__:xx"]) + 新逗号分隔 ("主席,主持,xx")
laborFormSelected.value = []
laborFormOther.value = ''
if (cur.laborForm) {
let items = []
if (typeof cur.laborForm === 'string' && cur.laborForm.trim().startsWith('[')) {
try { const p = JSON.parse(cur.laborForm); if (Array.isArray(p)) items = p } catch (e) { items = [cur.laborForm] }
} else {
items = String(cur.laborForm).split(',').map(s => s.trim()).filter(Boolean)
}
const known = new Set(laborFormOptions.value.map(o => o.value))
items.forEach(it => {
const s = String(it)
if (s === '__other__' || s.startsWith('__other__:')) {
if (!laborFormSelected.value.includes('__other__')) laborFormSelected.value.push('__other__')
if (s.startsWith('__other__:')) laborFormOther.value = s.substring('__other__:'.length)
} else if (known.has(s)) {
if (!laborFormSelected.value.includes(s)) laborFormSelected.value.push(s)
} else {
// 不在项目角色里 → 当作"其他"自定义
if (!laborFormSelected.value.includes('__other__')) laborFormSelected.value.push('__other__')
laborFormOther.value = s
}
})
}
feeDisplay.preTax = cur.feePreTax || '—'
feeDisplay.tax = cur.tax || '—'
feeDisplay.fee = cur.fee || '—'
meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
} catch (e) {
ElMessage.error(e?.msg || '加载失败')
} finally {
loading.value = false
}
}
async function onSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
if (!form.idCardAttachments || form.idCardAttachments.split(',').every(s => !s.trim())) {
ElMessage.warning('请上传身份证正反面')
return
}
// 劳务形式序列化: 逗号分隔字符串, "其他"自定义内容作为普通项
let laborForm = ''
if (laborFormSelected.value.length) {
laborForm = laborFormSelected.value
.map(x => (x === '__other__' ? (laborFormOther.value || '').trim() : x))
.filter(x => x !== '' && x !== '__other__')
.join(',')
}
submitting.value = true
try {
await saveSignProfile(attendeeId.value, {
...form,
laborForm
})
ElMessage.success('已保存, 即将进入签署页')
setTimeout(() => {
router.push('/doctor/sign-contract?attendeeId=' + attendeeId.value)
}, 500)
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally {
submitting.value = false
}
}
function downloadPdf() {
if (!laborPdfUrl.value) return
const a = document.createElement('a')
a.href = laborPdfUrl.value
a.download = `${meetingName.value || '劳务协议'}.pdf`
a.target = '_blank'
document.body.appendChild(a); a.click(); document.body.removeChild(a)
}
function goBack() {
router.push('/doctor/home')
}
onMounted(init)
</script>
<style scoped>
/* 手机端全屏表单 (无 navbar / 无 page-card) */
.sign-fill { padding: 0; }
.sign-fill-form { padding: 16px; }
/* 大标题: 会议名称 + 期数 */
.sign-header { margin: 4px 0 20px; padding-bottom: 14px; border-bottom: 1px solid #f0f0f0; }
.sign-header-title { font-size: 20px; font-weight: 600; color: #1a1a1a; line-height: 1.4; }
.sign-header-period { margin-top: 6px; font-size: 14px; color: #595959; }
/* 已签署: 直接展示 PDF (PdfViewer 内部自适应宽度/高度 + 滚动) */
.signed-pdf { display: flex; flex-direction: column; }
.signed-pdf-viewer { width: 100%; }
.signed-pdf-empty { padding: 48px 16px; text-align: center; color: #909399; }
.signed-pdf-actions { margin-top: 16px; display: flex; gap: 12px; }
/* 未受邀提示 */
.not-invited { padding: 60px 20px; text-align: center; }
.not-invited-text { font-size: 16px; color: #606266; margin-bottom: 20px; }
/* 开户地址 3 列布局 */
.region-row {
display: flex;
gap: 8px;
width: 100%;
}
.region-row .el-input { flex: 1; }
/* 劳务形式多选 + 其他输入 */
.labor-form-cell { width: 100%; }
.labor-form-cell .other-input { margin-top: 8px; }
/* mobile 上下结构: label 在 input 上方 */
.sign-fill-form :deep(.el-form-item__label) {
display: block;
float: none;
text-align: left;
width: auto !important;
padding: 0 0 6px 0;
font-size: 13px;
color: #303133;
}
.sign-fill-form :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.sign-fill-form :deep(.el-form-item) {
margin-bottom: 16px;
}
/* 身份证附件 2 个框排成 1 行 */
.sign-fill-form :deep(.ht-image-upload-content) {
display: inline-block;
}
.sign-fill-form :deep(.ht-image-upload-content + .ht-image-upload-content) {
margin-left: 12px;
}
/* mobile 端地址 3 个输入框 1 行 */
.region-row { display: flex; gap: 8px; }
.region-row .el-input { flex: 1; }
/* ========================================
移动端适配 (≤768px)
- 卡片 padding 收窄
- 工具栏横向滚动
- filter-form: label 与输入框横向
- 表格字号收紧
======================================== */
@media (max-width: 768px) {
/* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 */
.toolbar {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* filter-form: 横向 (label 左 + input 右) */
.filter-form {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.filter-form :deep(.el-form-item__label) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important;
min-width: 0 !important;
}
/* 强制所有控件全宽 */
.filter-form :deep(.el-select),
.filter-form :deep(.el-input),
.filter-form :deep(.el-date-editor),
.filter-form :deep(.el-button),
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
}
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
}
</style>