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 + 供应商/招标接口切生产域名
512 lines
19 KiB
Vue
512 lines
19 KiB
Vue
<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> |