feat: 生产部署 + 会议全链路 (执行方分配/费用结算/签到脱敏/H5相机)

- 生产 nginx 三路径: ry-vue3 /hg/, ry-h5 /camera/, API /hg-api, .env 分环境
- 签约链接/摄像头 base-url 走 yml, 不再硬编码 /hg 与 localhost
- 新增 biz_project_executor_assign (镜像 sponsor_assign) 执行方项目级分配
- 会议费用后台汇总 FeeCalcScheduler + 结算回写项目金额 + 状态流转调度
- 签到表拍照高斯模糊脱敏 extra_oss_url, 新增 PosterService/StageDeriver
- 清理 biz_support_intent 旧表 (6 Java/XML 删除)
- ry-h5 相机黑屏修复: 显式 video.play() + 动态 apiBase 上传
- 资源: simhei 字体 / logo.png / qrcode_1.png / favicon.ico

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-24 01:00:09 +08:00
co-authored by Claude
parent 80a276e661
commit 904e28710f
123 changed files with 6008 additions and 1706 deletions
+150 -50
View File
@@ -1,9 +1,16 @@
<template>
<div class="sign-fill">
<div v-loading="loading">
<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">
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form">
<!-- 参会人信息 -->
<div class="section-title">参会人信息</div>
<!-- 大标题: 会议名称 + 期数 -->
<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>
@@ -16,9 +23,15 @@
<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="医务职称">
<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-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
</template>
</el-select>
</el-form-item>
<el-form-item label="账户名称(持卡人姓名)" prop="accountName">
<el-input v-model="form.accountName" placeholder="请输入持卡人姓名" maxlength="100" />
</el-form-item>
@@ -53,8 +66,6 @@
/>
</el-form-item>
<!-- 劳务信息 (只读, admin 预填) -->
<div class="section-title">本次劳务</div>
<el-form-item label="劳务形式">
<div class="labor-form-cell">
<el-checkbox-group v-model="laborFormSelected">
@@ -65,15 +76,6 @@
</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>
@@ -88,13 +90,18 @@
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 { 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'
const route = useRoute()
const router = useRouter()
const attendeeId = computed(() => Number(route.query.attendeeId) || null)
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)
@@ -120,6 +127,19 @@ const form = reactive({
})
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: [
@@ -135,6 +155,73 @@ const rules = {
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 || u.userName || '',
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('参数缺失, 请从工作台进入')
@@ -162,25 +249,43 @@ async function load() {
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) {
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) : ''
}
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
}
} 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 || []
meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
} catch (e) {
ElMessage.error(e?.msg || '加载失败')
} finally {
@@ -195,16 +300,13 @@ async function onSubmit() {
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)
laborForm = laborFormSelected.value
.map(x => (x === '__other__' ? (laborFormOther.value || '').trim() : x))
.filter(x => x !== '' && x !== '__other__')
.join(',')
}
submitting.value = true
@@ -228,23 +330,21 @@ function goBack() {
router.push('/doctor/home')
}
onMounted(load)
onMounted(init)
</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;
}
/* 标题: 会议名称 + 期数 */
.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; }
/* 未受邀提示 */
.not-invited { padding: 60px 20px; text-align: center; }
.not-invited-text { font-size: 16px; color: #606266; margin-bottom: 20px; }
/* 开户地址 3 列布局 */
.region-row {