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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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> > 投稿详情
|
||||
</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>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="submission-new">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb">
|
||||
<a @click="goBack">我的项目设计投稿</a> > {{ 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user