feat: 合并 admin/manager 重复页 + 公示意向 + 劳务签署
页合并 (admin/manager 共用, route.path 角色感知): - expert: Experts.vue + ExpertNew.vue (list/new/edit/view 一套) - sponsor-orgs: SponsorOrgs.vue - sponsor-people: SponsorPeople.vue + SponsorPersonNew.vue + SponsorPersonDetail.vue - executor-orgs: ExecutorOrgs.vue - executor-people: ExecutorPeople.vue + ExecutorPersonNew.vue + ExecutorPersonDetail.vue - 给 SponsorPeople / ExecutorPeople op 列补上 查看/编辑 按钮 (潜在 bug 修复) - 详情弹窗统一用 el-descriptions 风格 (替代 admin 旧版 ElMessageBox.alert) 后端: - BizSignController + BizSignServiceImpl + PdfService (劳务签署 PDF 流程) - BizPublicityIntentController (公示意向: 支持/执行) - BizPublicitySupportIntent / BizPublicityExecutionIntent ExportVo - BizMeetingAttendee 字段合并 (bank_region 替代 bankProvince/bankCity) - BizExpert 字段合并 (id_card_attachments CSV, bank_region) - Excel 导入模板精简 (开户行 1 列) 前端: - doctor/SignFill + SignContract + SignSuccess (劳务签署 3 页流程) - ImportResultDialog 通用组件 - IdCardUploader 重构 (单 v-model:idCardAttachments, CSV 格式) - AreaCascader 调整 - Login.vue + AdminLayout.vue + PortalShell.vue + Home.vue 适配 DB: 字段合并 (id_card_front/back → id_card_attachments, bank_province/city → bank_region)
This commit is contained in:
@@ -67,11 +67,8 @@
|
||||
<el-form-item label="银行卡号" :required="true">
|
||||
<el-input v-model="form.bankCardNo" placeholder="请填写银行卡号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户省" :required="true">
|
||||
<el-input v-model="form.bankProvince" placeholder="请填写开户省" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户市" :required="true">
|
||||
<el-input v-model="form.bankCity" placeholder="请填写开户市" />
|
||||
<el-form-item label="开户省/市" :required="true">
|
||||
<area-cascader v-model="form.bankRegion" format="string" join-sep="/" :max-level="2" placeholder="请选择省/市" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户支行" :required="true">
|
||||
<el-input v-model="form.bankAddress" placeholder="请填写开户支行" />
|
||||
@@ -83,8 +80,7 @@
|
||||
<el-form :model="form" class="form-grid form-grid-single" label-position="right" label-width="120px">
|
||||
<el-form-item label="身份证照片">
|
||||
<id-card-uploader
|
||||
v-model:front-url="form.idCardFrontUrl"
|
||||
v-model:back-url="form.idCardBackUrl"
|
||||
v-model="form.idCardAttachments"
|
||||
:dir="'ry8080/idcard/'"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -198,14 +194,12 @@ const form = reactive({
|
||||
doctorTitle: '',
|
||||
region: '',
|
||||
idCardNo: '',
|
||||
idCardFrontUrl: '',
|
||||
idCardBackUrl: '',
|
||||
idCardAttachments: '',
|
||||
licenseCertUrl: '',
|
||||
titleCertUrl: '',
|
||||
bankName: '',
|
||||
bankCardNo: '',
|
||||
bankProvince: '',
|
||||
bankCity: '',
|
||||
bankRegion: '',
|
||||
bankAddress: ''
|
||||
})
|
||||
|
||||
@@ -279,11 +273,9 @@ async function loadExpertProfile() {
|
||||
form.idCardNo = d.idCard || ''
|
||||
form.bankCardNo = d.bankCard || ''
|
||||
form.bankName = d.bankName || ''
|
||||
form.bankProvince = d.bankProvince || ''
|
||||
form.bankCity = d.bankCity || ''
|
||||
form.bankRegion = d.bankRegion || ''
|
||||
form.bankAddress = d.bankAddress || ''
|
||||
form.idCardFrontUrl = d.idCardFrontUrl || ''
|
||||
form.idCardBackUrl = d.idCardBackUrl || ''
|
||||
form.idCardAttachments = d.idCardAttachments || ''
|
||||
form.licenseCertUrl = d.practiceCertUrl || ''
|
||||
form.titleCertUrl = d.titleCertUrl || ''
|
||||
} catch (e) {
|
||||
@@ -325,7 +317,7 @@ function onAvatarChange(url) {
|
||||
|
||||
async function onSave() {
|
||||
saveError.value = ''
|
||||
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankProvince', 'bankCity', 'bankAddress']
|
||||
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankRegion', 'bankAddress']
|
||||
for (const k of required) {
|
||||
if (!(form[k] || '').toString().trim()) {
|
||||
return ElMessage.warning('请填写完整必填字段')
|
||||
@@ -344,11 +336,9 @@ async function onSave() {
|
||||
idCard: form.idCardNo,
|
||||
bankCard: form.bankCardNo,
|
||||
bankName: form.bankName,
|
||||
bankProvince: form.bankProvince,
|
||||
bankCity: form.bankCity,
|
||||
bankRegion: form.bankRegion,
|
||||
bankAddress: form.bankAddress,
|
||||
idCardFrontUrl: form.idCardFrontUrl,
|
||||
idCardBackUrl: form.idCardBackUrl,
|
||||
idCardAttachments: form.idCardAttachments,
|
||||
practiceCertUrl: form.licenseCertUrl,
|
||||
titleCertUrl: form.titleCertUrl
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<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.id">
|
||||
<li class="simple-item" v-for="(s, idx) in pendingAgreements" :key="s.id" @click="showQrcode(s, idx)">
|
||||
<div class="item-main">
|
||||
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
|
||||
</div>
|
||||
@@ -66,12 +66,30 @@
|
||||
<li v-if="!notices.length" class="empty">暂无通知</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<el-dialog v-model="qrcodeOpen" title="扫码签署劳务协议" width="380px" align-center destroy-on-close>
|
||||
<div class="qrcode-wrap">
|
||||
<div v-if="qrcodeLoading" v-loading="true" class="qrcode-loading"></div>
|
||||
<img v-else-if="qrcodeUrl" :src="qrcodeUrl" class="qrcode-img" />
|
||||
<div class="qrcode-tip">用手机扫码进入「会议 {{ pendingAgreements[currentQrcodeIdx]?.meetingName || '' }}」劳务协议</div>
|
||||
<div class="qrcode-hint">医生需先在手机端登录账号</div>
|
||||
<el-link v-if="qrcodeMobileUrl" type="primary" :underline="false" class="qrcode-copy" @click="copyQrcodeUrl">
|
||||
复制链接
|
||||
</el-link>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="qrcodeOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
import { getMyExpertProfile } from '@/api/business/expert'
|
||||
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
|
||||
@@ -92,6 +110,55 @@ const nowTime = ref('')
|
||||
const nowDate = ref('')
|
||||
let timer = null
|
||||
|
||||
// 二维码弹窗
|
||||
const qrcodeOpen = ref(false)
|
||||
const qrcodeUrl = ref('')
|
||||
const qrcodeMobileUrl = ref('')
|
||||
const qrcodeLoading = ref(false)
|
||||
const currentQrcodeIdx = ref(0)
|
||||
async function showQrcode(row, idx) {
|
||||
currentQrcodeIdx.value = idx ?? 0
|
||||
qrcodeOpen.value = true
|
||||
qrcodeLoading.value = true
|
||||
try {
|
||||
// 二维码内容 = 该会议对应的填写页 URL (扫码直达, 医生已登录后直接进入)
|
||||
// Vue Router hash 模式: URL 必须带 #/ (如 http://localhost:5173/#/doctor/sign-fill?attendeeId=3)
|
||||
const fullUrl = window.location.origin + '/#/doctor/sign-fill?attendeeId=' + row.id
|
||||
qrcodeMobileUrl.value = fullUrl
|
||||
qrcodeUrl.value = await QRCode.toDataURL(fullUrl, {
|
||||
width: 240,
|
||||
margin: 2,
|
||||
color: { dark: '#1a1a1a', light: '#ffffff' }
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '生成二维码失败')
|
||||
qrcodeOpen.value = false
|
||||
} finally {
|
||||
qrcodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyQrcodeUrl() {
|
||||
if (!qrcodeMobileUrl.value) return
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(qrcodeMobileUrl.value)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = qrcodeMobileUrl.value
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
ElMessage.success('已复制链接, 发到手机浏览器打开')
|
||||
} catch (e) {
|
||||
ElMessage.error('复制失败, 请手动长按二维码识别')
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
@@ -187,4 +254,12 @@ onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||||
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
|
||||
.empty { padding: 16px 0; text-align: center; color: #bfbfbf; font-size: 13px; }
|
||||
@media (max-width: 768px) { .cols-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* 二维码弹窗 */
|
||||
.qrcode-wrap { text-align: center; padding: 12px 0; }
|
||||
.qrcode-loading { width: 240px; height: 240px; margin: 0 auto; }
|
||||
.qrcode-img { width: 240px; height: 240px; }
|
||||
.qrcode-tip { font-size: 14px; color: #1a1a1a; margin-top: 16px; }
|
||||
.qrcode-hint { font-size: 12px; color: #999; margin-top: 6px; }
|
||||
.qrcode-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="sign-contract-page">
|
||||
<!-- 协议正文 (滚动区) -->
|
||||
<div class="contract-scroll">
|
||||
<div v-html="contentHtml" class="html-wrap"></div>
|
||||
</div>
|
||||
|
||||
<!-- 底部固定栏 (签名确认 + 提交按钮) -->
|
||||
<div class="bottom-bar">
|
||||
<!-- 签名确认区 -->
|
||||
<div class="sign-field" @click="openSignPad">
|
||||
<span class="sign-field-label">签名确认</span>
|
||||
<div class="sign-field-value">
|
||||
<img v-if="sigPath" :src="sigPath" class="sign-thumb" />
|
||||
<span v-else class="sign-placeholder">点击此处确认签名</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="submit-wrap">
|
||||
<el-button type="primary" class="submit-btn" :loading="submitting" @click="submitAct">提交</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 签名弹层 (Element Plus Drawer 从底部弹出, 对齐 hwt-web van-popup position=bottom) -->
|
||||
<el-drawer
|
||||
v-model="sigOpen"
|
||||
direction="btt"
|
||||
size="auto"
|
||||
:show-close="false"
|
||||
:with-header="false"
|
||||
custom-class="sign-sheet-drawer"
|
||||
>
|
||||
<div class="sheet-grabber"></div>
|
||||
<div class="sign-pad-head">
|
||||
<div class="sign-pad-head-left">
|
||||
<span class="pad-cancel" @click="sigOpen = false">取消</span>
|
||||
<span class="pad-clear" @click="clearSign">清除</span>
|
||||
</div>
|
||||
<span class="pad-confirm" @click="saveSign">确认</span>
|
||||
</div>
|
||||
<div class="sign-canvas-wrap">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
width="600"
|
||||
height="200"
|
||||
class="sign-canvas"
|
||||
@mousedown="onStart"
|
||||
@mousemove="onMove"
|
||||
@mouseup="onEnd"
|
||||
@mouseleave="onEnd"
|
||||
@touchstart.prevent="onTouchStart"
|
||||
@touchmove.prevent="onTouchMove"
|
||||
@touchend="onEnd"
|
||||
/>
|
||||
<div class="sign-hint">请在上方区域填写您的姓名</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSignContract, submitSign } from '@/api/business/sign'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const attendeeId = computed(() => Number(route.query.attendeeId) || null)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const contentHtml = ref('')
|
||||
const sigOpen = ref(false)
|
||||
const sigPath = ref('')
|
||||
|
||||
// canvas 签名板
|
||||
const canvasRef = ref(null)
|
||||
let ctx = null
|
||||
let drawing = false
|
||||
let lastX = 0, lastY = 0
|
||||
|
||||
async function load() {
|
||||
if (!attendeeId.value) {
|
||||
ElMessage.error('参数缺失, 请从工作台进入')
|
||||
router.push('/doctor/home')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getSignContract(attendeeId.value)
|
||||
contentHtml.value = data || '<p style="color:#999">暂无协议内容</p>'
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openSignPad() {
|
||||
sigOpen.value = true
|
||||
// 等 dialog 渲染后初始化 canvas 上下文
|
||||
nextTickInitCanvas()
|
||||
}
|
||||
|
||||
function nextTickInitCanvas() {
|
||||
setTimeout(() => {
|
||||
if (!canvasRef.value || ctx) return
|
||||
ctx = canvasRef.value.getContext('2d')
|
||||
ctx.strokeStyle = '#000'
|
||||
ctx.lineWidth = 5
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function getPos(evt) {
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
const scaleX = canvasRef.value.width / rect.width
|
||||
const scaleY = canvasRef.value.height / rect.height
|
||||
let x, y
|
||||
if (evt.touches && evt.touches.length) {
|
||||
x = (evt.touches[0].clientX - rect.left) * scaleX
|
||||
y = (evt.touches[0].clientY - rect.top) * scaleY
|
||||
} else {
|
||||
x = (evt.clientX - rect.left) * scaleX
|
||||
y = (evt.clientY - rect.top) * scaleY
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function onStart(evt) {
|
||||
drawing = true
|
||||
const { x, y } = getPos(evt)
|
||||
lastX = x; lastY = y
|
||||
}
|
||||
function onMove(evt) {
|
||||
if (!drawing) return
|
||||
const { x, y } = getPos(evt)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(lastX, lastY)
|
||||
ctx.lineTo(x, y)
|
||||
ctx.stroke()
|
||||
lastX = x; lastY = y
|
||||
}
|
||||
function onEnd() {
|
||||
drawing = false
|
||||
}
|
||||
function onTouchStart(evt) {
|
||||
const t = evt.touches[0]
|
||||
onStart({ clientX: t.clientX, clientY: t.clientY, touches: evt.touches })
|
||||
}
|
||||
function onTouchMove(evt) {
|
||||
const t = evt.touches[0]
|
||||
onMove({ clientX: t.clientX, clientY: t.clientY, touches: evt.touches })
|
||||
}
|
||||
|
||||
function clearSign() {
|
||||
if (!ctx || !canvasRef.value) return
|
||||
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||
}
|
||||
|
||||
function saveSign() {
|
||||
if (!canvasRef.value) return
|
||||
const dataUrl = canvasRef.value.toDataURL('image/png')
|
||||
if (isCanvasBlank(canvasRef.value)) {
|
||||
sigOpen.value = false
|
||||
return
|
||||
}
|
||||
sigPath.value = dataUrl
|
||||
// 合同正文里 乙方签字处 实时回显签名 (替换 {手写签名} 占位符)
|
||||
contentHtml.value = contentHtml.value.replaceAll(
|
||||
"{手写签名}",
|
||||
`<img style='width: 60px; height: 30px' src='${dataUrl}' />`
|
||||
)
|
||||
sigOpen.value = false
|
||||
}
|
||||
|
||||
// 检测 canvas 是否空白
|
||||
function isCanvasBlank(canvas) {
|
||||
const c = document.createElement('canvas')
|
||||
c.width = canvas.width
|
||||
c.height = canvas.height
|
||||
const cctx = c.getContext('2d')
|
||||
cctx.drawImage(canvas, 0, 0)
|
||||
const data = cctx.getImageData(0, 0, c.width, c.height).data
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function submitAct() {
|
||||
if (!sigPath.value) {
|
||||
ElMessage.warning('请先签名')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const { data } = await submitSign(attendeeId.value, sigPath.value, contentHtml.value)
|
||||
router.replace({
|
||||
name: 'doctor-sign-success',
|
||||
query: { full: data.fullPdfUrl || '', masked: data.maskedPdfUrl || '' }
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 整页容器 */
|
||||
.sign-contract-page {
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 180px; /* 预留底部固定栏高度 */
|
||||
}
|
||||
|
||||
/* 协议正文滚动区 (对齐 hwt-web scroll-view) */
|
||||
.contract-scroll {
|
||||
margin: 24px 16px 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 底部固定栏 (粘在屏幕最下面) */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 12px 16px 16px;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
z-index: 10;
|
||||
}
|
||||
.html-wrap {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* 签名确认区 (对齐 hwt-web van-field) */
|
||||
.sign-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #ebedf0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sign-field-label {
|
||||
font-weight: 700;
|
||||
color: #ff4d4f;
|
||||
white-space: nowrap;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sign-field-value {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
}
|
||||
.sign-thumb {
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.sign-placeholder {
|
||||
color: #969799;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 提交按钮 (对齐 hwt-web van-button block) */
|
||||
.submit-wrap {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 签名弹层 (Element Plus Drawer 从底部弹出, 对齐 hwt-web van-popup position=bottom) */
|
||||
.sign-sheet-drawer :deep(.el-drawer) {
|
||||
border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.sign-sheet-drawer :deep(.el-drawer__header) {
|
||||
display: none;
|
||||
}
|
||||
.sign-sheet-drawer :deep(.el-drawer__body) {
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
.sheet-grabber {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: #d9d9d9;
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
.sign-pad-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
.sign-pad-head-left {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.pad-cancel {
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.pad-clear {
|
||||
color: #ff4d4f;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.pad-confirm {
|
||||
color: var(--brand-primary, #1e3a8a);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.sign-canvas-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #000;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.sign-canvas {
|
||||
border: 3px dashed #ccc;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: #fff;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
.sign-hint {
|
||||
margin: 20px auto;
|
||||
color: #969799;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="sign-fill">
|
||||
<div v-loading="loading">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form">
|
||||
<!-- 参会人信息 -->
|
||||
<div class="section-title">参会人信息</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>
|
||||
|
||||
<!-- 银行账户 -->
|
||||
<div class="section-title">银行账户</div>
|
||||
<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>
|
||||
|
||||
<!-- 劳务信息 (只读, admin 预填) -->
|
||||
<div class="section-title">本次劳务</div>
|
||||
<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 label="医务职称">
|
||||
<el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 240px">
|
||||
<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-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
|
||||
</template>
|
||||
</el-select>
|
||||
</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 } from '@/api/business/sign'
|
||||
import IdCardUploader from '@/components/IdCardUploader.vue'
|
||||
import AreaCascader from '@/components/AreaCascader.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const attendeeId = computed(() => Number(route.query.attendeeId) || null)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
// 劳务形式 (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: '—' })
|
||||
|
||||
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' }]
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!attendeeId.value) {
|
||||
ElMessage.error('参数缺失, 请从工作台进入')
|
||||
router.push('/doctor/home')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getSignInfo(attendeeId.value)
|
||||
// 预填 (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 || ''
|
||||
})
|
||||
if (cur.laborForm) {
|
||||
try {
|
||||
const parsed = JSON.parse(cur.laborForm)
|
||||
if (Array.isArray(parsed)) {
|
||||
laborFormSelected.value = parsed.filter(x => x !== '__other__' && !x.startsWith('__other__:'))
|
||||
const otherItem = parsed.find(x => x === '__other__' || x.startsWith('__other__:'))
|
||||
if (otherItem) {
|
||||
laborFormSelected.value.push('__other__')
|
||||
laborFormOther.value = otherItem.startsWith('__other__:') ? otherItem.substring('__other__:'.length) : ''
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
feeDisplay.preTax = cur.feePreTax || '—'
|
||||
feeDisplay.tax = cur.tax || '—'
|
||||
feeDisplay.fee = cur.fee || '—'
|
||||
|
||||
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label }))
|
||||
titleOptions.value = data.titleOptions || []
|
||||
} 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
|
||||
}
|
||||
// 劳务形式序列化 (JSON 数组字符串, 含 __other__ + 自定义内容)
|
||||
let laborForm = ''
|
||||
if (laborFormSelected.value.length) {
|
||||
const arr = laborFormSelected.value.map(x => {
|
||||
if (x === '__other__' && laborFormOther.value) {
|
||||
return '__other__:' + laborFormOther.value
|
||||
}
|
||||
return x
|
||||
})
|
||||
laborForm = JSON.stringify(arr)
|
||||
}
|
||||
|
||||
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 goBack() {
|
||||
router.push('/doctor/home')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 手机端全屏表单 (无 navbar / 无 page-card) */
|
||||
.sign-fill { padding: 16px; }
|
||||
|
||||
/* 章节标题: 左边 3px 蓝竖线 */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 16px 0 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--brand-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 开户地址 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; }
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="sign-success">
|
||||
<el-result icon="success" title="签字成功" sub-title="劳务协议已生成" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 手机端全屏成功页 (无 navbar / 无 page-card) */
|
||||
.sign-success {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user