refactor: 合并 biz_support_unit/biz_execution_unit/biz_service_org 为 biz_org, 删除 2 张孤儿表, 改 biz_person.work_unit → org_id FK

主要改动:
- SQL: biz_support_unit → biz_org, 加 org_type, 加 business_nature; 删 biz_execution_unit, biz_service_org
- SQL: biz_project.support_unit_id/name + service_org_id/name → org_id/name/type
- SQL: biz_meeting.support_unit_name → org_name
- SQL: biz_person.work_unit → org_id (FK)
- 后端: 新 BizOrg entity/mapper/service/controller
- 后端: BizProject/BizMeeting 字段重命名
- 后端: 删 12 个 BizSupportUnit/BizExecutionUnit/BizServiceOrg Java 文件
- 后端: BizPersonImportVO.workUnit → orgName (导入时查 biz_org 取 org_id)
- 后端: BizAuthController.registerExecutor 完整实现 (原 registerSupplier stub)
- 前端: 新 admin/Orgs.vue + manager/Orgs.vue (原 SupportUnits)
- 前端: RegisterExecutor.vue (原 RegisterSupplier, 单页 2 步)
- 前端: sponsor/executor/manager 多个文件 workUnit → orgId/orgName 重命名
- 前端: 统一 supplier → executor, 业务命名 sponsor(赞助方) / executor(执行方=供应商)
- 前端: 全工程 execution → executor (company type / role / person unit_type)
This commit is contained in:
郭庆泰
2026-08-15 12:41:10 +08:00
commit cf5790229a
694 changed files with 102090 additions and 0 deletions
+549
View File
@@ -0,0 +1,549 @@
<template>
<div class="page-card">
<!-- 面包屑 -->
<div class="breadcrumb">
<span @click="goHome" class="link">首页</span>
<span class="sep">/</span>
<span>账号信息</span>
</div>
<!-- 页面标题 -->
<h1 class="page-title">账号信息</h1>
<!-- Tab 导航 -->
<el-tabs v-model="tab" class="account-tabs">
<!-- 个人信息 -->
<el-tab-pane label="个人信息" name="profile">
<div class="card">
<!-- 头像 + 用户信息 -->
<div class="avatar-block">
<div class="avatar-circle" :class="{ 'has-file': avatarUrl, 'is-uploading': avatarUploading }" @click="onAvatarClick">
<img v-if="avatarUrl" :src="avatarUrl" class="avatar-img" />
<template v-else>{{ avatarChar }}</template>
<span class="avatar-tip">{{ avatarUploading ? '上传中...' : '点击更换' }}</span>
</div>
<div class="avatar-meta">
<div class="name">{{ form.name || '专家账号' }}</div>
<div class="role">专家账号</div>
<div class="phone-row">
<span class="phone-label">联系电话:</span>
<span class="phone-value">{{ form.phone || '未设置' }}</span>
<el-button class="btn-edit-phone" link type="primary" @click="openPhoneDialog">
<el-icon><EditPen /></el-icon> 修改
</el-button>
</div>
</div>
</div>
<!-- 基本资料 (2 ) -->
<div class="section-title">基本资料</div>
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
<el-form-item label="专家姓名" :required="true">
<el-input v-model="form.name" placeholder="请填写姓名" />
</el-form-item>
<el-form-item label="科室" :required="true">
<doctor-dept-select v-model="form.department" value-field="label" placeholder="输入关键词搜索" filterable />
</el-form-item>
<el-form-item label="地区" :required="true">
<area-cascader v-model="form.region" format="string" join-sep="/" placeholder="请选择省/市/区" />
</el-form-item>
<el-form-item label="证件号码" :required="true">
<el-input v-model="form.idCardNo" placeholder="请填写证件号码" />
</el-form-item>
<el-form-item label="职称" :required="true">
<doctor-title-select v-model="form.doctorTitle" value-field="label" placeholder="输入关键词搜索" filterable />
</el-form-item>
<el-form-item label="工作单位" :required="true">
<el-input v-model="form.workUnit" placeholder="请填写工作单位" />
</el-form-item>
</el-form>
<!-- 银行信息 -->
<div class="section-title">银行信息</div>
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
<el-form-item label="开户姓名" :required="true">
<el-input v-model="form.bankName" placeholder="请填写开户姓名" />
</el-form-item>
<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>
<el-form-item label="开户支行" :required="true">
<el-input v-model="form.bankAddress" placeholder="请填写开户支行" />
</el-form-item>
</el-form>
<!-- 资质证件 -->
<div class="section-title">资质证件</div>
<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"
:dir="'ry8080/idcard/'"
/>
</el-form-item>
<el-form-item label="执业证书/证明">
<oss-image-uploader
v-model="form.licenseCertUrl"
:dir="'ry8080/license/'"
placeholder="+"
/>
</el-form-item>
<el-form-item label="职称证明">
<oss-image-uploader
v-model="form.titleCertUrl"
:dir="'ry8080/title/'"
placeholder="+"
/>
</el-form-item>
</el-form>
<div class="form-error">{{ saveError }}</div>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button type="default" @click="onCancel">取消</el-button>
</div>
</div>
</el-tab-pane>
<!-- 修改密码 -->
<el-tab-pane label="修改密码" name="password">
<div class="card">
<el-form :model="pwd" label-position="right" label-width="120px" class="password-form">
<el-form-item label="原密码" :required="true">
<el-input v-model="pwd.old" type="password" show-password placeholder="请输入原密码" />
</el-form-item>
<el-form-item label="新密码" :required="true">
<el-input v-model="pwd.new" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
<el-form-item label="确认密码" :required="true">
<el-input v-model="pwd.confirm" type="password" show-password placeholder="请再次输入新密码" />
</el-form-item>
<div class="form-error">{{ pwdError }}</div>
<div class="form-actions">
<el-button type="primary" @click="onChangePwd">保存</el-button>
<el-button type="default" @click="onCancelPwd">取消</el-button>
</div>
</el-form>
</div>
</el-tab-pane>
</el-tabs>
<!-- 隐藏的 avatar 上传 input -->
<input ref="avatarInput" type="file" hidden accept="image/*" @change="onAvatarFileChange" />
<!-- 修改手机号 dialog (独立于其它字段) -->
<el-dialog v-model="phoneDialogVisible" title="修改联系电话" width="420px" :close-on-click-modal="false">
<el-form :model="phoneForm" label-width="100px">
<el-form-item label="新手机号" :required="true">
<el-input v-model="phoneForm.phone" placeholder="请输入新手机号" />
</el-form-item>
<el-form-item label="短信验证码" :required="true">
<div class="sms-row">
<el-input v-model="phoneForm.smsCode" placeholder="请输入验证码" />
<el-button class="btn-sms" :disabled="phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
<div class="form-error">{{ phoneError }}</div>
<template #footer>
<el-button type="default" @click="phoneDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="phoneSaving" @click="onChangePhone">确认</el-button>
</template>
</el-form>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { EditPen } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import request from '@/utils/request'
import { uploadToOss } from '@/utils/oss'
import AreaCascader from '@/components/AreaCascader.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import IdCardUploader from '@/components/IdCardUploader.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
const store = useUserStore()
const tab = ref('profile')
// 短信验证码状态 (修改手机号 dialog 用)
const phoneDialogVisible = ref(false)
const phoneSmsCountdown = ref(0)
let phoneSmsTimer = null
const phoneSmsUuid = ref('')
const phoneForm = reactive({ phone: '', smsCode: '' })
const phoneError = ref('')
const phoneSaving = ref(false)
const form = reactive({
name: '',
phone: '',
workUnit: '',
department: '',
doctorTitle: '',
region: '',
idCardNo: '',
idCardFrontUrl: '',
idCardBackUrl: '',
licenseCertUrl: '',
titleCertUrl: '',
bankName: '',
bankCardNo: '',
bankProvince: '',
bankCity: '',
bankAddress: ''
})
const pwd = reactive({ old: '', new: '', confirm: '' })
const pwdError = ref('')
const saveError = ref('')
const saving = ref(false)
const avatarUrl = ref(store.user?.avatar || '')
const avatarChar = computed(() => (form.name || '专').slice(0, 1))
const avatarUploading = ref(false)
const avatarInput = ref(null)
let snapshot = ref(null)
function onAvatarClick() {
if (avatarUploading.value) return
avatarInput.value?.click()
}
async function onAvatarFileChange(e) {
const file = e.target.files?.[0]
if (!file) return
e.target.value = '' // 重置以便可重选
if (!file.type.startsWith('image/')) {
return ElMessage.warning('请选择图片文件')
}
if (file.size > 5 * 1024 * 1024) {
return ElMessage.warning('图片大小不能超过 5MB')
}
avatarUploading.value = true
try {
const url = await uploadToOss(file, 'ry8080/avatar/')
await request({
url: '/system/user/profile/avatarUrl',
method: 'put',
data: { avatarUrl: url }
})
avatarUrl.value = url
if (store.user) {
store.user.avatar = url
// 同步 localStorage, 否则刷新后头像丢失
localStorage.setItem('ry_user', JSON.stringify(store.user))
}
ElMessage.success('头像更新成功')
} catch (err) {
ElMessage.error(err?.msg || '头像上传失败')
} finally {
avatarUploading.value = false
}
}
function goHome() {
if (window.parent && window.parent.document.getElementById('contentFrame')) {
window.parent.goHome?.()
}
}
async function loadExpertProfile() {
try {
const res = await request({ url: '/business/expert/profile', method: 'get' })
const d = res.data
if (!d) return
form.name = d.name || ''
form.phone = d.phone || ''
form.workUnit = d.workUnit || ''
form.department = d.department || ''
form.doctorTitle = d.title || ''
form.region = d.region || ''
form.idCardNo = d.idCard || ''
form.bankCardNo = d.bankCard || ''
form.bankName = d.bankName || ''
form.bankProvince = d.bankProvince || ''
form.bankCity = d.bankCity || ''
form.bankAddress = d.bankAddress || ''
form.idCardFrontUrl = d.idCardFrontUrl || ''
form.idCardBackUrl = d.idCardBackUrl || ''
form.licenseCertUrl = d.practiceCertUrl || ''
form.titleCertUrl = d.titleCertUrl || ''
} catch (e) {
console.warn('loadExpertProfile:', e?.msg)
}
}
async function loadUserProfile() {
try {
const res = await request({ url: '/system/user/profile', method: 'get' })
const u = res.data
if (!u) return
if (store.user) {
// 同步 store (顺便同步 localStorage, 否则刷新头像丢失)
store.user.avatar = u.avatar || store.user.avatar
store.user.userName = u.userName || store.user.userName
store.user.nickName = u.nickName || store.user.nickName
store.user.phonenumber = u.phonenumber || store.user.phonenumber
localStorage.setItem('ry_user', JSON.stringify(store.user))
}
avatarUrl.value = u.avatar || avatarUrl.value
} catch (e) {
console.warn('loadUserProfile:', e?.msg)
}
}
onMounted(async () => {
form.name = store.user?.userName || ''
form.phone = store.user?.phonenumber || ''
avatarUrl.value = store.user?.avatar || ''
await Promise.all([loadUserProfile(), loadExpertProfile()])
snapshot = ref(JSON.parse(JSON.stringify(form)))
})
function onAvatarChange(url) {
avatarUrl.value = url
if (store.user) store.user.avatar = url
}
async function onSave() {
saveError.value = ''
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankProvince', 'bankCity', 'bankAddress']
for (const k of required) {
if (!(form[k] || '').toString().trim()) {
return ElMessage.warning('请填写完整必填字段')
}
}
// 字段名映射 (前端 → 后端 BizExpert):
// idCardNo → idCard, bankCardNo → bankCard, doctorTitle → title,
// licenseCertUrl → practiceCertUrl
const payload = {
name: form.name,
phone: form.phone,
workUnit: form.workUnit,
department: form.department,
title: form.doctorTitle,
region: form.region,
idCard: form.idCardNo,
bankCard: form.bankCardNo,
bankName: form.bankName,
bankProvince: form.bankProvince,
bankCity: form.bankCity,
bankAddress: form.bankAddress,
idCardFrontUrl: form.idCardFrontUrl,
idCardBackUrl: form.idCardBackUrl,
practiceCertUrl: form.licenseCertUrl,
titleCertUrl: form.titleCertUrl
}
saving.value = true
try {
await request({
url: '/business/expert/profile',
method: 'put',
data: payload
})
snapshot.value = JSON.parse(JSON.stringify(form))
ElMessage.success('个人信息已保存')
} catch (e) {
saveError.value = e?.msg || '保存失败'
ElMessage.error(saveError.value)
} finally {
saving.value = false
}
}
function onCancel() {
if (snapshot.value) {
Object.assign(form, JSON.parse(JSON.stringify(snapshot.value)))
}
ElMessage.info('已恢复修改前内容')
}
function onChangePwd() {
pwdError.value = ''
if (!pwd.old) return (pwdError.value = '请先填写原密码')
if (pwd.new.length < 6) return (pwdError.value = '新密码长度不能少于 6 位')
if (pwd.new !== pwd.confirm) return (pwdError.value = '两次输入的新密码不一致')
request({
url: '/system/user/profile/updatePwd',
method: 'put',
data: { oldPassword: pwd.old, newPassword: pwd.new }
}).then(() => {
ElMessage.success('密码修改成功')
pwd.old = pwd.new = pwd.confirm = ''
}).catch(e => {
pwdError.value = e?.msg || '密码修改失败'
ElMessage.error(pwdError.value)
})
}
function onCancelPwd() {
pwd.old = pwd.new = pwd.confirm = ''
pwdError.value = ''
}
// 手机号 dialog
function openPhoneDialog() {
phoneForm.phone = ''
phoneForm.smsCode = ''
phoneError.value = ''
phoneDialogVisible.value = true
}
async function sendSms() {
const phone = (phoneForm.phone || '').trim()
if (!/^(1[0-9])\d{9}$/.test(phone)) {
return ElMessage.warning('请输入正确的手机号')
}
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
}
async function onChangePhone() {
phoneError.value = ''
if (!phoneForm.phone || !phoneForm.smsCode || !phoneSmsUuid.value) {
return (phoneError.value = '请填写完整信息')
}
phoneSaving.value = true
try {
await request({
url: '/system/user/profile/changePhone',
method: 'post',
data: {
phone: phoneForm.phone,
smsCode: phoneForm.smsCode,
uuid: phoneSmsUuid.value
}
})
form.phone = phoneForm.phone
if (store.user) store.user.phonenumber = phoneForm.phone
ElMessage.success('手机号修改成功')
phoneDialogVisible.value = false
} catch (e) {
phoneError.value = e?.msg || '修改失败'
} finally {
phoneSaving.value = false
}
}
onUnmounted(() => {
if (phoneSmsTimer) clearInterval(phoneSmsTimer)
})
</script>
<style scoped>
.page-card { padding: 16px; background: #fff; border-radius: 4px; border: 1px solid #ebeef5; }
.breadcrumb { font-size: 13px; color: #909399; margin-bottom: 8px; }
.breadcrumb .link { color: #409eff; cursor: pointer; }
.breadcrumb .link:hover { text-decoration: underline; }
.breadcrumb .sep { margin: 0 6px; }
.page-title { font-size: 20px; font-weight: 600; color: #262626; margin: 0 0 16px; }
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.account-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }
.card {
background: #fff;
border-radius: 4px;
padding: 20px 24px;
border: 1px solid #ebeef5;
margin-top: 16px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #262626;
margin: 16px 0 12px;
padding-left: 8px;
border-left: 3px solid #409eff;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
}
.form-grid-single {
grid-template-columns: 1fr;
}
.form-grid :deep(.el-form-item.grid-span-2) {
grid-column: span 2;
}
.form-grid :deep(.el-form-item) { margin-bottom: 14px; }
.form-grid :deep(.el-form-item__label) { color: #606266; }
/* 头像区 (含电话显示) */
.avatar-block { display: flex; align-items: center; gap: 24px; padding: 16px 0 24px; border-bottom: 1px dashed #ebeef5; margin-bottom: 24px; }
.avatar-circle {
width: 80px; height: 80px; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-size: 28px; font-weight: 600;
cursor: pointer; flex-shrink: 0;
overflow: hidden;
position: relative;
transition: transform 0.15s;
}
.avatar-circle:hover { transform: scale(1.04); }
.avatar-circle.is-uploading { cursor: wait; opacity: 0.75; }
.avatar-circle .avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-tip {
font-size: 11px; opacity: 0.85; margin-top: 4px;
position: absolute; bottom: 4px; left: 0; right: 0;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.avatar-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.avatar-meta .name { font-size: 18px; font-weight: 600; color: #262626; }
.avatar-meta .role { font-size: 13px; color: #909399; }
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; margin-top: 4px; }
.phone-label { color: #909399; }
.phone-value { color: #262626; font-weight: 500; }
.btn-edit-phone { padding: 0 4px; }
/* 银行信息 + 密码 form 单列 */
.password-form { max-width: 480px; }
.password-form :deep(.el-form-item) { margin-bottom: 16px; }
.form-error { color: #f56c6c; font-size: 13px; margin: 4px 0 12px; min-height: 18px; }
.form-actions { margin-top: 20px; display: flex; gap: 12px; }
/* 手机号 dialog 验证码 row */
.sms-row { display: flex; gap: 8px; align-items: center; width: 100%; }
.sms-row .el-input { flex: 1; }
.btn-sms { flex-shrink: 0; }
</style>
+177
View File
@@ -0,0 +1,177 @@
<template>
<div class="page-card doctor-home">
<div class="breadcrumb">首页</div>
<!-- 欢迎栏 -->
<div class="welcome-bar">
<div class="welcome-text">
<h2>下午好,{{ store.user?.userName || '专家' }}专家</h2>
<p>欢迎使用项目管理系统</p>
</div>
<div class="welcome-time">
<div class="now">{{ nowTime }}</div>
<div class="date">{{ nowDate }}</div>
</div>
</div>
<!-- 待参加的会议 + 待签署的协议 -->
<div class="cols-row">
<div class="section">
<h2 class="section-title">
待参加的会议
<a class="more" @click.prevent="$router.push('/doctor/meetings')">更多 </a>
</h2>
<ul class="simple-list">
<li class="simple-item" v-for="m in upcomingMeetings" :key="m.meetingId" @click="$router.push('/doctor/meetings')">
<div class="item-main">
<span class="item-title">{{ m.meetingName || m.title }}</span>
</div>
<span class="item-status">{{ formatTime(m.startTime) }}</span>
</li>
<li v-if="!upcomingMeetings.length" class="empty">暂无待参加会议</li>
</ul>
</div>
<div class="section">
<h2 class="section-title">
待签署的协议
<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.subId">
<div class="item-main">
<span class="item-title">{{ s.title }}</span>
</div>
<span class="item-status" :class="{ done: s.status === 'done' }">
{{ s.status === 'done' ? '已完成' : '待签署' }}
</span>
</li>
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
</ul>
</div>
</div>
<!-- 通知消息 -->
<section class="section">
<h2 class="section-title">
通知消息
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 </a>
</h2>
<ul class="notice-list">
<li class="notice-item" :class="{ read: n.read }" v-for="n in notices" :key="n.id" @click="n.read = true">
<div class="dot"></div>
<div class="body">
<div class="title">{{ n.title }}</div>
<div class="text">{{ n.text }}</div>
</div>
<span class="time">{{ n.time }}</span>
</li>
<li v-if="!notices.length" class="empty">暂无通知</li>
</ul>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/store/user'
import { bizList, listMyMessages } from '@/api/public'
const store = useUserStore()
const upcomingMeetings = ref([])
const pendingAgreements = ref([])
const notices = ref([])
const nowTime = ref('')
const nowDate = ref('')
let timer = null
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
const today = new Date()
const diff = Math.floor((d - today) / 86400000)
if (diff === 0) return d.toTimeString().slice(0, 5)
if (diff === 1) return '明天'
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function updateClock() {
const now = new Date()
nowTime.value = now.toTimeString().slice(0, 5)
nowDate.value = now.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })
}
async function load() {
// 待参加会议
try {
const { data } = await bizList('meeting', { pageNum: 1, pageSize: 5 })
upcomingMeetings.value = (data?.rows || []).slice(0, 5)
} catch (e) { upcomingMeetings.value = [] }
// 待签署协议
try {
const { data } = await bizList('submission', { pageNum: 1, pageSize: 5 })
pendingAgreements.value = (data?.rows || []).slice(0, 5).map(s => ({
subId: s.subId,
title: s.title,
status: s.status === '审核通过' ? 'done' : 'pending'
}))
} catch (e) { pendingAgreements.value = [] }
// 通知
try {
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
id: a.id,
title: a.title,
text: a.text,
time: a.time,
read: a.read || i >= 2
}))
} catch (e) { notices.value = [] }
}
onMounted(() => {
updateClock()
timer = setInterval(updateClock, 60000)
load()
})
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
</script>
<style scoped>
.doctor-home { padding: 16px 20px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.welcome-bar { background: var(--brand-primary); border-radius: 4px; padding: 20px 24px; color: #fff; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.welcome-text h2 { font-size: 20px; font-weight: 600; margin-bottom: 6px; }
.welcome-text p { font-size: 13px; opacity: 0.85; }
.welcome-time .now { font-size: 14px; font-weight: 500; }
.welcome-time .date { font-size: 12px; opacity: 0.75; margin-top: 4px; }
.section { background: #fff; padding: 16px 20px; margin-bottom: 16px; border: 1px solid #f0f0f0; border-radius: 4px; }
.section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin: 0 0 12px; display: flex; align-items: center; justify-content: space-between; }
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; font-weight: normal; cursor: pointer; }
.more:hover { text-decoration: underline; }
.cols-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.cols-row .section { margin-bottom: 16px; }
.simple-list { list-style: none; border-top: 1px solid #f0f0f0; }
.simple-item { padding: 10px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; }
.simple-item:last-child { border-bottom: none; }
.simple-item:hover .item-title { color: var(--brand-primary); }
.item-main { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.item-title { font-size: 13px; color: #1a1a1a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.item-status { flex-shrink: 0; font-size: 12px; color: #8c8c8c; }
.item-status.done { color: #52c41a; }
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
.notice-item { padding: 12px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; cursor: pointer; }
.notice-item:last-child { border-bottom: none; }
.notice-item:hover .title { color: var(--brand-primary); }
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
.notice-item .body { flex: 1; min-width: 0; }
.notice-item .title { font-size: 13px; color: #1a1a1a; }
.notice-item.read .title { color: #8c8c8c; }
.notice-item .text { font-size: 12px; color: #595959; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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; } }
</style>
+241
View File
@@ -0,0 +1,241 @@
<template>
<div class="page-card">
<div class="breadcrumb">首页 / 我参与的会议</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
<el-form-item label="当前阶段">
<el-select v-model="q.currentStage" placeholder="请选择" clearable>
<el-option label="未开始" value="未开始" />
<el-option label="执行中" value="执行中" />
<el-option label="已结束" value="已结束" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="160" />
<el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
<el-table-column prop="currentStage" label="当前阶段" width="120">
<template #default="{ row }">
<el-tag :type="stageTag(row.currentStage)" size="small">{{ row.currentStage || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" :disabled="!row.invitationUrl" @click="onDownloadFile(row)">下载</el-button>
<el-button v-if="row.laborSigned !== '1'" link type="primary" @click="onSign(row)">签署劳务</el-button>
<el-button v-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
style="margin-top: 12px; text-align: right;"
/>
<!-- 签署劳务 dialog: 未签状态显示, PDF 上传 -->
<el-dialog v-model="signOpen" title="签署劳务" width="560px">
<el-form label-width="100px">
<el-form-item label="项目编号">{{ signForm.projectNo }}</el-form-item>
<el-form-item label="会议名称">{{ signForm.meetingName }}</el-form-item>
<el-form-item label="签字 PDF">
<el-upload
drag
action="#"
:before-upload="beforeSignUpload"
:http-request="uploadSignPdf"
:file-list="signFileList"
:on-remove="onSignRemove"
accept=".pdf"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将签字 PDF 拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">仅支持 PDF, 20MB</div>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="signOpen=false">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="!signForm.signedPdfUrl" @click="submitSign">签署劳务</el-button>
</template>
</el-dialog>
<!-- 查看劳务 dialog: 已签状态显示, PDF iframe 预览 -->
<el-dialog v-model="laborOpen" title="查看劳务" width="720px">
<el-descriptions :column="1" border>
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="会议名称">{{ detail.meetingName }}</el-descriptions-item>
<el-descriptions-item label="签署状态">已签署</el-descriptions-item>
<el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="pdf-preview">
<iframe v-if="detail.signedPdfUrl" :src="detail.signedPdfUrl" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
<div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div>
</div>
<template #footer>
<el-button v-if="detail.signedPdfUrl" link type="primary" @click="downloadPdf(detail.signedPdfUrl, detail.meetingName)">下载</el-button>
<el-button @click="laborOpen=false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bizList, bizUpdate } from '@/api/public'
import { uploadToOss } from '@/utils/oss'
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const rows = ref([])
const loading = ref(false)
const detail = ref({})
const laborOpen = ref(false)
const signOpen = ref(false)
const signForm = reactive({ meetingId: '', projectNo: '', meetingName: '', signedPdfUrl: '' })
const signFileList = ref([])
const submitting = ref(false)
async function load() {
loading.value = true
try {
const { data } = await bizList('meeting', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = data?.rows || []
page.total = data?.total || 0
} catch (e) {
rows.value = []
page.total = 0
} finally { loading.value = false }
}
function reset() {
q.projectNo = ''
q.meetingName = ''
q.currentStage = ''
page.pageNum = 1
load()
}
function stageTag(s) {
if (s === '已结束') return 'info'
if (s === '执行中') return 'success'
return 'warning'
}
function onViewLabor(row) {
detail.value = row
laborOpen.value = true
}
function onView(row) {
detail.value = row
// 复用 detail dialog: 复用 meetingInfo 字段直接展示
// 这里直接展示一个只读 dialog, 没有劳务签署按钮
ElMessageBox.alert(
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${row.currentStage || '-'}`,
'会议详情',
{ confirmButtonText: '关闭' }
)
}
function onDownloadFile(row) {
if (!row.invitationUrl) { ElMessage.warning('该会议暂无邀请函附件'); return }
const a = document.createElement('a')
a.href = row.invitationUrl
a.download = `${row.meetingName || '会议'}_邀请函.pdf`
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function onSign(row) {
signForm.meetingId = row.meetingId
signForm.projectNo = row.projectNo || ''
signForm.meetingName = row.meetingName || ''
signForm.signedPdfUrl = ''
signFileList.value = []
signOpen.value = true
}
function beforeSignUpload(file) {
const max = 20 * 1024 * 1024
if (file.size > max) { ElMessage.error('文件不能超过 20MB'); return false }
if (!file.name.toLowerCase().endsWith('.pdf')) { ElMessage.error('仅支持 PDF 格式'); return false }
return true
}
async function uploadSignPdf(opts) {
try {
const url = await uploadToOss(opts.file, 'ry8080/labor/signed/')
signForm.signedPdfUrl = url
ElMessage.success('签字 PDF 上传成功')
} catch (e) {
ElMessage.error('上传失败: ' + (e?.message || e))
}
}
function onSignRemove() {
signForm.signedPdfUrl = ''
signFileList.value = []
}
async function submitSign() {
if (!signForm.signedPdfUrl) return ElMessage.warning('请先上传签字 PDF')
submitting.value = true
try {
await bizUpdate('meeting', {
meetingId: signForm.meetingId,
laborSigned: '1',
signedPdfUrl: signForm.signedPdfUrl,
signedTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
ElMessage.success('签署成功')
signOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || '签署失败')
} finally {
submitting.value = false
}
}
function downloadPdf(url, name) {
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = `${name || '劳务'}_${Date.now()}.pdf`
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
load()
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
.pdf-preview { margin-top: 12px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-table .el-button) { border-radius: 4px; }
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
</style>
+106
View File
@@ -0,0 +1,106 @@
<template>
<div class="page-card">
<div class="breadcrumb">首页 / 消息通知</div>
<ul class="notice-list">
<li v-for="n in rows" :key="n.id" class="notice-item" :class="{ read: n.read }">
<div class="dot"></div>
<div class="body" @click="openDetail(n)">
<div class="title"><span class="cat" :class="catClass(n.category)">{{ n.category }}</span>{{ n.title }}</div>
<div class="text">{{ n.text }}</div>
</div>
<span class="time">{{ n.time }}</span>
</li>
<li v-if="!rows.length" class="empty">暂无消息</li>
</ul>
<el-dialog v-model="detailOpen" :title="detail.title || '消息详情'" width="520px">
<div class="detail-body">
<div class="meta">
<span class="cat" :class="catClass(detail.category)">{{ detail.category }}</span>
<span class="time">{{ detail.time }}</span>
</div>
<div class="text">{{ detail.text || '-' }}</div>
</div>
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { bizList, listMyMessages } from '@/api/public'
const rows = ref([])
const detail = ref({})
const detailOpen = ref(false)
async function load() {
try {
const r = await listMyMessages({ limit: 50 }); const list = r.rows
rows.value = list.map(a => ({
id: a.id,
category: a.category,
title: a.title,
text: a.text,
time: a.time,
read: a.read
}))
} catch (e) { rows.value = [] }
}
function mapCategory(t) {
if (t === 'system') return '系统通知'
if (t === 'audit') return '审核结果'
if (t === 'project') return '项目动态'
if (t === 'meeting') return '会议提醒'
return '通知'
}
function catClass(c) {
if (c === '系统通知') return 'sys'
if (c === '审核结果') return 'audit'
if (c === '项目动态') return 'proj'
if (c === '会议提醒') return 'meet'
return ''
}
function formatAgo(t) {
const diff = (Date.now() - new Date(t).getTime()) / 1000
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
return new Date(t).toLocaleDateString('zh-CN')
}
function openDetail(n) {
detail.value = n
detailOpen.value = true
}
onMounted(load)
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.notice-list { list-style: none; }
.notice-item { padding: 14px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; }
.notice-item:last-child { border-bottom: none; }
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
.notice-item .body { flex: 1; min-width: 0; cursor: pointer; }
.notice-item .title { font-size: 13px; color: #1a1a1a; display: flex; align-items: center; gap: 8px; }
.notice-item.read .title { color: #8c8c8c; }
.notice-item .text { font-size: 12px; color: #595959; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
.notice-item:hover .title { color: var(--brand-primary); }
.cat { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 2px; background: #f0f0f0; color: #595959; }
.cat.sys { background: #e6f7ff; color: var(--brand-primary); }
.cat.audit { background: #f9f0ff; color: #722ed1; }
.cat.proj { background: #fff7e6; color: #fa8c16; }
.cat.meet { background: #f6ffed; color: #52c41a; }
.empty { padding: 24px; text-align: center; color: #bfbfbf; font-size: 13px; }
.detail-body .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.detail-body .text { font-size: 14px; color: #1a1a1a; line-height: 1.6; white-space: pre-wrap; }
</style>
+150
View File
@@ -0,0 +1,150 @@
<template>
<div class="page-card">
<div class="breadcrumb">首页 / 我报名的项目</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="项目名称"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<div class="filter-tip">*下载项目公示的红头文件</div>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="160" />
<el-table-column prop="projectName" label="项目名称" min-width="240" show-overflow-tooltip />
<el-table-column prop="meetingName" label="会议名称" min-width="220" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<span class="status">{{ row.status || '已报名' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" @click="onPublicity(row)">查看公示</el-button>
<el-button link type="primary" :disabled="!hasFile(row)" @click="onDownload(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
style="margin-top: 12px; text-align: right;"
/>
<el-dialog v-model="detailOpen" title="项目详情" width="560px">
<el-descriptions :column="2" border>
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm || '-' }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="赞助公司" :span="2">{{ detail.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="总场次">{{ detail.totalSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="已执行">{{ detail.doneSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="未执行">{{ detail.todoSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="总金额">{{ detail.totalAmount || 0 }}</el-descriptions-item>
<el-descriptions-item label="开始时间" :span="2">{{ detail.startTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="结束时间" :span="2">{{ detail.endTime || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { bizList, bizAdd } from '@/api/public'
import request from '@/utils/request'
import { useUserStore } from '@/store/user'
const router = useRouter()
const userStore = useUserStore()
const q = reactive({ projectNo: '', projectName: '', meetingName: '' })
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const rows = ref([])
const loading = ref(false)
const detail = ref({})
const detailOpen = ref(false)
async function load() {
loading.value = true
try {
const { data } = await request.get('/business/project/myProjects', { params: { ...q, pageNum: page.pageNum, pageSize: page.pageSize } })
rows.value = data?.rows || []
page.total = data?.total || 0
} catch (e) { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() {
q.projectNo = ''
q.projectName = ''
q.meetingName = ''
page.pageNum = 1
load()
}
function hasFile(row) {
// 兼容 publicityFiles JSON 数组或旧字段
if (Array.isArray(row.publicityFiles) && row.publicityFiles.length) return true
return !!(row.publishUrl || row.supportLetterUrl || row.invitationUrl)
}
function onView(row) {
detail.value = row
detailOpen.value = true
}
function onPublicity(row) {
// 跳转到公开门户的项目公示详情
const id = row.projectId || row.publishId || row.id
if (!id) { ElMessage.warning('该项目暂无公示信息'); return }
router.push(`/publicity/${id}`)
}
function onDownload(row) {
// 优先 publicityFiles JSON 数组, 否则兼容旧字段
let url = ''
let name = ''
if (Array.isArray(row.publicityFiles) && row.publicityFiles.length) {
const f = row.publicityFiles[0]
url = f.url; name = f.name || (f.url ? f.url.split('/').pop() : '')
} else {
url = row.publishUrl || row.supportLetterUrl || row.invitationUrl
name = url ? url.split('/').pop() : ''
}
if (!url) { ElMessage.warning('该项目尚未发布公示,暂无可下载文件'); return }
const a = document.createElement('a')
a.href = url
a.download = name || `公示_${row.projectNo || Date.now()}`
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
load()
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
.filter-tip { background: #fffbe6; border: 1px solid #ffe58f; color: #ad6800; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin-bottom: 12px; }
.status { font-size: 13px; color: #595959; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-table .el-button) { border-radius: 4px; }
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
</style>
@@ -0,0 +1,193 @@
<template>
<div class="submission-detail">
<!-- 面包屑 ( SubmissionNew 一致) -->
<div class="breadcrumb">
<a @click="goBack">我的项目设计投稿</a> &gt; 投稿详情
</div>
<!-- 表单卡片 ( SubmissionNew 同款) -->
<el-card v-loading="loading" shadow="never" class="detail-card">
<el-form label-width="120px" class="readonly-form">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="策划方案名称">
<el-input :model-value="display.title" readonly />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目方向">
<el-input :model-value="display.direction" readonly />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目形式">
<el-input :model-value="display.projectForm" readonly />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目类别">
<el-input :model-value="display.projectCategory" readonly />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="设计文件">
<div class="readonly-cell">
<template v-if="detail.designFileUrl">
<el-link type="primary" :href="detail.designFileUrl" target="_blank">查看</el-link>
<el-link type="primary" style="margin-left: 12px" :href="detail.designFileUrl" :download="downloadName">下载</el-link>
</template>
<span v-else>-</span>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="备注">
<el-input :model-value="display.remark" type="textarea" :rows="4" readonly />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="状态">
<el-tag v-if="detail.status !== null && detail.status !== undefined && detail.status !== ''" :type="statusType(detail.status)" disable-transitions>
{{ statusLabel(detail.status) }}
</el-tag>
<span v-else>-</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="投稿时间">
<el-input :model-value="display.createTime" readonly />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 操作按钮 ( SubmissionNew 同位置同样式) -->
<div class="detail-actions">
<el-button @click="goBack">返回</el-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref({})
// 显示用: 空值显示 "-", 时间格式化
function fmt(v) {
if (v === null || v === undefined || v === '') return '-'
return v
}
function fmtTime(t) {
if (!t) return '-'
try { return new Date(t).toLocaleString('zh-CN', { hour12: false }) } catch { return t }
}
// 状态 → el-tag 类型映射
const STATUS_TYPE_MAP = {
'待审核': 'warning',
'审核通过': 'success',
'已退回': 'danger',
'未结题': 'info',
'已结题': 'success'
}
function statusType(s) {
return STATUS_TYPE_MAP[s] || 'info'
}
const display = computed(() => ({
title: fmt(detail.value.title),
direction: fmt(detail.value.direction),
projectForm: fmt(detail.value.projectForm),
projectCategory: fmt(detail.value.projectCategory),
remark: fmt(detail.value.remark),
status: fmt(detail.value.status),
createTime: fmtTime(detail.value.createTime)
}))
function goBack() {
router.push({ path: '/doctor/submissions', query: { _t: Date.now() } })
}
function downloadName() {
const url = detail.value.designFileUrl || ''
const last = url.split('/').pop()
return last || `submission-${detail.value.subId}`
}
async function load() {
const subId = route.params.subId
if (!subId) { ElMessage.warning('参数缺失'); goBack(); return }
loading.value = true
try {
const res = await request.get(`/business/submission/${subId}`)
if (res?.code === 200) {
detail.value = res.data || {}
} else {
ElMessage.error(res?.msg || '加载失败')
}
} catch (e) {
console.error('[submission-detail] load failed', e)
ElMessage.error(e?.msg || '加载失败')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.submission-detail { max-width: 1000px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
.detail-card { border-radius: 6px; }
.detail-card :deep(.el-card__body) { padding: 24px 28px; }
/* 只读表单样式 - 模拟 el-input 视觉, 但只显示文字 */
.readonly-form :deep(.el-form-item) { margin-bottom: 22px; }
.readonly-form :deep(.el-form-item__label) { color: #606266; font-weight: normal; }
.readonly-cell {
min-height: 40px;
line-height: 38px;
padding: 0 14px;
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 4px;
color: #262626;
font-size: 14px;
word-break: break-all;
}
.remark-cell {
min-height: 110px;
line-height: 1.6;
padding: 12px 14px;
white-space: pre-wrap;
}
.readonly-form :deep(.el-link) { font-weight: 500; }
.detail-actions { margin-top: 16px; }
:deep(.el-button) { border-radius: 4px; }
</style>
+280
View File
@@ -0,0 +1,280 @@
<template>
<div class="submission-new">
<!-- 面包屑 -->
<div class="breadcrumb">
<a @click="goBack">我的项目设计投稿</a> &gt; {{ isEdit ? '修改项目设计投稿' : '新建项目设计投稿' }}
</div>
<!-- 表单卡片 -->
<el-card v-loading="loadingDetail" shadow="never" class="form-card">
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="策划方案名称" prop="title">
<el-input v-model="form.title" placeholder="请输入策划方案名称" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目方向" prop="direction">
<el-select v-model="form.direction" placeholder="请选择" style="width:100%">
<el-option label="学术项目" value="学术项目" />
<el-option label="科研项目" value="科研项目" />
<el-option label="共识" value="共识" />
<el-option label="指南" value="指南" />
<el-option label="会议项目" value="会议项目" />
<el-option label="对外交流" value="对外交流" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目形式" prop="projectForm">
<el-select v-model="form.projectForm" placeholder="请选择" style="width:100%">
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
<el-option label="线上+线下" value="线上+线下" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目类别" prop="projectCategory">
<el-select v-model="form.projectCategory" placeholder="请选择" style="width:100%">
<el-option label="学术会议类" value="学术会议类" />
<el-option label="专项科研类" value="专项科研类" />
<el-option label="调研征集类" value="调研征集类" />
<el-option label="慈善帮扶类" value="慈善帮扶类" />
<el-option label="标准制定类" value="标准制定类" />
<el-option label="患者援助类" value="患者援助类" />
<el-option label="专业培训类" value="专业培训类" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="设计文件" prop="designFileUrl">
<el-upload
class="design-uploader"
drag
action="#"
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
:show-file-list="true"
:file-list="designFileList"
:before-upload="beforeDesignUpload"
:http-request="uploadDesign"
:on-remove="onDesignRemove"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将设计文件拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">支持 PDF / Word / 图片, 单文件 20MB</div>
</el-upload>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="4" placeholder="请输入备注 (0/500)" maxlength="500" show-word-limit />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 操作按钮 -->
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button @click="confirmCancel">取消</el-button>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import request from '@/utils/request'
import { uploadToOss } from '@/utils/oss'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref(null)
const saving = ref(false)
const loadingDetail = ref(false)
const designFileList = ref([])
const designUploading = ref(false)
const isEdit = !!route.params.subId
const form = reactive({
subId: null,
title: '',
direction: '',
projectForm: '',
projectCategory: '',
designFileUrl: '',
status: 'DRAFT',
remark: ''
})
const rules = {
title: [{ required: true, message: '请输入策划方案名称', trigger: 'blur' }],
direction: [{ required: true, message: '请选择项目方向', trigger: 'change' }],
projectForm: [{ required: true, message: '请选择项目形式', trigger: 'change' }],
projectCategory: [{ required: true, message: '请选择项目类别', trigger: 'change' }],
designFileUrl: [{ required: true, message: '请上传设计文件', trigger: 'change' }]
}
function beforeDesignUpload(file) {
const max = 20 * 1024 * 1024
if (file.size > max) { ElMessage.error('设计文件不能超过 20MB'); return false }
const accept = ['.pdf', '.doc', '.docx', '.png', '.jpg', '.jpeg']
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
if (!accept.includes(ext)) { ElMessage.error('仅支持 PDF / Word / 图片格式'); return false }
return true
}
async function uploadDesign(opts) {
const file = opts.file
designUploading.value = true
try {
const url = await uploadToOss(file, 'ry8080/submission/design/')
form.designFileUrl = url
designFileList.value = [{ name: file.name, url }]
ElMessage.success('设计文件上传成功')
} catch (e) {
ElMessage.error('上传失败: ' + (e?.message || e))
// 上传失败时清空 file-list,避免 el-upload 保留失败文件
designFileList.value = []
} finally {
designUploading.value = false
}
}
function onDesignRemove() {
form.designFileUrl = ''
designFileList.value = []
}
async function loadDetail() {
const subId = route.params.subId
if (!subId) return
loadingDetail.value = true
try {
const res = await request.get(`/business/submission/${subId}`)
if (res?.code === 200 && res.data) {
const d = res.data
form.subId = d.subId
form.title = d.title || ''
form.direction = d.direction || ''
form.projectForm = d.projectForm || ''
form.projectCategory = d.projectCategory || ''
form.designFileUrl = d.designFileUrl || ''
form.status = d.status && String(d.status).trim() ? String(d.status).trim().toUpperCase() : 'DRAFT'
form.remark = d.remark || ''
// 已上传的设计文件回显到 file-list
if (d.designFileUrl) {
const name = d.designFileUrl.split('/').pop() || `${d.title || '设计文件'}.pdf`
designFileList.value = [{ name, url: d.designFileUrl }]
}
} else {
ElMessage.error(res?.msg || '加载失败')
goBack()
}
} catch (e) {
console.error('[submission-new/edit] loadDetail failed', e)
ElMessage.error(e?.msg || '加载失败')
goBack()
} finally {
loadingDetail.value = false
}
}
function goBack() {
router.push({ path: '/doctor/submissions', query: { _t: Date.now() } })
}
function confirmCancel() {
// 检查表单是否有内容
const hasContent = form.title || form.direction || form.projectForm || form.projectCategory || form.designFileUrl || form.remark
if (!hasContent) { goBack(); return }
ElMessageBox.confirm('确定取消新建?未保存的内容将丢失', '提示', { type: 'warning' })
.then(() => goBack())
.catch(() => {})
}
async function onSave() {
try {
await formRef.value.validate()
} catch (e) {
ElMessage.warning('请填写必填项')
return
}
saving.value = true
try {
let payload = { ...form }
if (!isEdit) {
// 新建: 强制设为 DRAFT(待提交)
payload.status = 'DRAFT'
// 写入当前登录用户信息
payload.submitterId = userStore.user?.userId || null
payload.submitterName = userStore.user?.userName || userStore.user?.nickName || ''
} else if (!payload.status) {
// 修改: 如果状态丢失, 兜底 DRAFT(待提交)
payload.status = 'DRAFT'
}
let res
if (isEdit) {
// 修改: 走 PUT /business/submission
res = await request.put('/business/submission', payload)
} else {
// 新建: 走 POST /business/submission
res = await request.post('/business/submission', payload)
}
if (res?.code === 200) {
ElMessage.success(isEdit ? '修改成功' : '新建成功')
goBack()
} else {
ElMessage.error(res?.msg || (isEdit ? '修改失败' : '保存失败'))
}
} catch (e) {
console.error('[submission-new] save failed', e)
ElMessage.error(e?.msg || (isEdit ? '修改失败' : '保存失败'))
} finally {
saving.value = false
}
}
onMounted(() => {
if (isEdit) loadDetail()
})
</script>
<style scoped>
.submission-new { max-width: 1000px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
.form-card { border-radius: 6px; }
:deep(.form-card .el-card__body) { padding: 24px 28px; }
.form-actions { margin-top: 16px; display: flex; gap: 8px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-button) { border-radius: 4px; }
:deep(.el-input__wrapper), :deep(.el-textarea__inner) { border-radius: 4px; }
/* 设计文件上传控件: 占满表单行宽 */
.design-uploader { width: 100%; }
.design-uploader :deep(.el-upload) { width: 100%; }
.design-uploader :deep(.el-upload-dragger) { width: 100%; }
</style>
+177
View File
@@ -0,0 +1,177 @@
<template>
<div class="page-card">
<div class="breadcrumb">首页 / 我的项目设计投稿</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="投稿名称"><el-input v-model="q.title" placeholder="输入投稿名称" clearable style="width: 200px" /></el-form-item>
<el-form-item label="学科方向"><el-input v-model="q.direction" placeholder="输入学科方向" clearable style="width: 160px" /></el-form-item>
<el-form-item label="形式">
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width: 160px">
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
<el-option label="线上+线下" value="线上+线下" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="请选择" clearable style="width: 180px">
<el-option v-for="opt in STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="备注"><el-input v-model="q.remark" placeholder="输入备注" clearable style="width: 200px" /></el-form-item>
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="onCreate">新建投稿</el-button>
<el-button @click="onBatch" :disabled="!selected.length">批量提交</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column prop="title" label="投稿名称" min-width="220" show-overflow-tooltip />
<el-table-column prop="direction" label="学科方向" width="120" />
<el-table-column prop="projectForm" label="形式" width="100" />
<el-table-column label="设计文件" width="100">
<template #default="{ row }">
<el-link v-if="row.designFileUrl" type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.status !== null && row.status !== undefined && row.status !== ''" :type="statusType(row.status)" disable-transitions>
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button v-if="canModify(row)" link type="primary" @click="onEdit(row)">修改</el-button>
<el-button v-if="canSubmit(row)" link type="primary" @click="onSubmit(row)">提交</el-button>
<el-button link type="primary" :disabled="!row.designFileUrl" @click="onDownload(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
style="margin-top: 12px; text-align: right;"
/>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { bizList, bizUpdate } from '@/api/public'
import { STATUS_OPTIONS, statusLabel, statusType, isLocked } from '@/utils/submissionStatus'
const router = useRouter()
const q = reactive({ title: '', direction: '', projectForm: '', status: '', remark: '' })
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const rows = ref([])
const loading = ref(false)
const selected = ref([])
async function load() {
loading.value = true
try {
const { data } = await bizList('submission', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = data?.rows || []
page.total = data?.total || 0
} catch (e) { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() {
q.title = ''
q.direction = ''
q.projectForm = ''
q.status = ''
q.remark = ''
page.pageNum = 1
load()
}
// 原型规则: "待审核和审核通过后, 操作只显示查看按钮, 无法进行修改"
// 字典驱动: 通过 isLocked() 判断, true 表示锁定(待审核 0 / 审核通过 1)
function canModify(row) {
return !isLocked(row.status)
}
function canSubmit(row) {
// 锁定的不能再次提交; 其他状态(未结题/已结题/已退回/待提交)均可提交进入待审核
return !isLocked(row.status)
}
function onSelectionChange(arr) { selected.value = arr }
function onCreate() {
router.push({ path: '/doctor/submission/new' })
}
function onView(row) {
router.push({ path: `/doctor/submission/detail/${row.subId}` })
}
function onEdit(row) {
router.push({ path: `/doctor/submission/edit/${row.subId}` })
}
function onDownload(row) {
if (!row.designFileUrl) { ElMessage.warning('该投稿暂无设计文件'); return }
window.open(row.designFileUrl, '_blank')
}
async function onSubmit(row) {
if (row.status === 'APPROVED') { ElMessage.info('该投稿已审核通过,无需重复提交'); return }
try {
await ElMessageBox.confirm(`确定要提交投稿「${row.title}」吗?提交后将进入审核流程`, '提交确认', { type: 'warning' })
} catch { return }
try {
// 提交后状态变为 PENDING(待审核)
await bizUpdate('submission', { subId: row.subId, status: 'PENDING' })
ElMessage.success('已提交,等待审核')
load()
} catch (e) { ElMessage.error(e?.msg || '提交失败') }
}
async function onBatch() {
if (!selected.value.length) { ElMessage.warning('请先勾选要提交的投稿'); return }
try {
await ElMessageBox.confirm(`确定要批量提交选中的 ${selected.value.length} 条投稿吗?`, '批量提交', { type: 'warning' })
} catch { return }
let ok = 0
for (const r of selected.value) {
// 锁定状态 (PENDING 待审核 / APPROVED 审核通过) 跳过提交
if (isLocked(r.status)) continue
try { await bizUpdate('submission', { subId: r.subId, status: 'PENDING' }); ok++ } catch {}
}
ElMessage.success(`已提交 ${ok}`)
load()
}
load()
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.status { font-size: 13px; color: #595959; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-table .el-button) { border-radius: 4px; }
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
</style>