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:
郭庆泰
2026-08-20 21:02:56 +08:00
parent 3198c408b7
commit 1e86865a40
68 changed files with 3034 additions and 2617 deletions
+10 -20
View File
@@ -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
}
+76 -1
View File
@@ -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>
+350
View File
@@ -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>
+287
View File
@@ -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>
+20
View File
@@ -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>