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,152 @@
|
||||
<template>
|
||||
<el-cascader
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:props="cascaderProps"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:show-all-levels="showAllLevels"
|
||||
:collapse-tags="collapseTags"
|
||||
:separator="separator"
|
||||
class="area-cascader"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import areaData from '@/utils/areaData.json'
|
||||
|
||||
/**
|
||||
* 公共地区控件 (el-cascader 封装)
|
||||
* 3 级: 省 / 市 / 区 (基于 china-area-data 标准行政区划)
|
||||
*
|
||||
* 默认 v-model = 地区编码数组 ['110000', '110100', '110101']
|
||||
* 设置 format="string" 时 v-model = joinSep 拼接的字符串, 例 '北京市/北京市/东城区'
|
||||
* (用于直接存 varchar 列, 避免 JSON 序列化)
|
||||
*
|
||||
* 用法:
|
||||
* <area-cascader v-model="form.region" placeholder="请选择地区" />
|
||||
* <area-cascader v-model="form.region" format="string" join-sep="/" />
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Array, String], default: () => [] },
|
||||
placeholder: { type: String, default: '请选择省/市/区' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: true },
|
||||
showAllLevels: { type: Boolean, default: true },
|
||||
collapseTags: { type: Boolean, default: false },
|
||||
separator: { type: String, default: '/' },
|
||||
format: { type: String, default: 'array' }, // 'array' | 'string'
|
||||
joinSep: { type: String, default: '/' },
|
||||
props: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const areaMap = new Map() // value -> {label, parent}
|
||||
const labelToValue = new Map()
|
||||
;(function buildMap(list, parent) {
|
||||
for (const item of list) {
|
||||
areaMap.set(item.value, { label: item.label, parent })
|
||||
labelToValue.set(item.label, item.value)
|
||||
if (item.children) buildMap(item.children, item.value)
|
||||
}
|
||||
})(areaData, null)
|
||||
|
||||
// 把 value 数组翻译成 label 数组
|
||||
function toLabels(values) {
|
||||
if (!Array.isArray(values)) return []
|
||||
return values.map(v => areaMap.get(v)?.label || '')
|
||||
}
|
||||
|
||||
// 把 label 数组翻译成 value 数组 (用于字符串回填)
|
||||
function toValues(labels) {
|
||||
if (!Array.isArray(labels)) return []
|
||||
return labels.map(l => labelToValue.get(l) || '')
|
||||
}
|
||||
|
||||
// 字符串 "北京市/北京市/东城区" → value 数组
|
||||
function stringToValues(str) {
|
||||
if (typeof str !== 'string' || !str) return []
|
||||
const labels = str.split(props.joinSep)
|
||||
return toValues(labels)
|
||||
}
|
||||
|
||||
// 静态引用, 组件树只在加载时构建一次
|
||||
const options = areaData
|
||||
|
||||
const cascaderProps = computed(() => ({
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
expandTrigger: 'hover',
|
||||
checkStrictly: false,
|
||||
multiple: false,
|
||||
...props.props
|
||||
}))
|
||||
|
||||
// 内部 selected 永远是数组 (el-cascader 要求)
|
||||
const selected = ref([])
|
||||
let inited = false
|
||||
|
||||
function syncFromModel() {
|
||||
if (!inited) {
|
||||
// 首次挂载: 从 modelValue 初始化 selected
|
||||
if (props.format === 'string') {
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
selected.value = props.modelValue
|
||||
} else if (typeof props.modelValue === 'string' && props.modelValue) {
|
||||
selected.value = stringToValues(props.modelValue)
|
||||
} else {
|
||||
selected.value = []
|
||||
}
|
||||
} else {
|
||||
selected.value = Array.isArray(props.modelValue) ? props.modelValue : []
|
||||
}
|
||||
inited = true
|
||||
}
|
||||
// 首次挂载之后, 父传回的 modelValue 不再覆盖 selected (selected 由 el-cascader 内部维护)
|
||||
// 但父可能传新值 (loadExpertProfile 等),需要重新解析
|
||||
// 简化策略: 父传空字符串 → 清空; 父传非空且跟当前不同 → 重新解析
|
||||
if (props.format === 'string') {
|
||||
if (typeof props.modelValue === 'string' && !props.modelValue) {
|
||||
selected.value = []
|
||||
} else if (typeof props.modelValue === 'string' && props.modelValue) {
|
||||
const newVals = stringToValues(props.modelValue)
|
||||
// 只有当跟当前 selected 真的不一样时, 才更新
|
||||
if (JSON.stringify(newVals) !== JSON.stringify(selected.value)) {
|
||||
selected.value = newVals
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
selected.value = props.modelValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => syncFromModel(), { immediate: true })
|
||||
|
||||
function handleChange(val) {
|
||||
selected.value = val || []
|
||||
if (props.format === 'string') {
|
||||
const labels = toLabels(val || [])
|
||||
const str = labels.filter(Boolean).join(props.joinSep)
|
||||
emit('update:modelValue', str)
|
||||
emit('change', str)
|
||||
} else {
|
||||
emit('update:modelValue', val || [])
|
||||
emit('change', val || [])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.area-cascader {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<el-select
|
||||
v-model="selected"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:multiple="multiple"
|
||||
:loading="loading"
|
||||
class="dict-select"
|
||||
@change="handleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="valueField === 'label' ? opt.label : opt.value"
|
||||
:disabled="opt.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getActiveDict, DICT_TYPE } from '@/api/dict'
|
||||
|
||||
/**
|
||||
* 公共字典下拉控件
|
||||
* v-model 默认绑定 value (deptId/titleId), 也可改为绑定 label (name 字符串)
|
||||
*
|
||||
* 用法 1 (绑 ID):
|
||||
* <dict-select v-model="form.deptId" dict-type="department" />
|
||||
* <dict-select v-model="form.titleId" dict-type="title" :value-field="'value'" />
|
||||
*
|
||||
* 用法 2 (绑 name 字符串, 兼容旧业务字段如 form.department="内科"):
|
||||
* <dict-select v-model="form.department" dict-type="department" :value-field="'label'" />
|
||||
*
|
||||
* 事件:
|
||||
* @change(value, option) option.raw 含完整字段 (sort/status/...)
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Array], default: null },
|
||||
/** 字典类型, 必须从 DICT_TYPE 取 */
|
||||
dictType: { type: String, required: true, validator: v => Object.values(DICT_TYPE).includes(v) },
|
||||
/** v-model 绑的字段: 'value' (ID) 或 'label' (name 字符串) */
|
||||
valueField: { type: String, default: 'value' },
|
||||
placeholder: { type: String, default: '请选择' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const options = ref([])
|
||||
const loading = ref(false)
|
||||
const selected = ref(props.modelValue ?? (props.multiple ? [] : null))
|
||||
|
||||
watch(() => props.modelValue, v => {
|
||||
selected.value = v ?? (props.multiple ? [] : null)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
options.value = await getActiveDict(props.dictType)
|
||||
} catch (e) {
|
||||
console.warn(`[DictSelect ${props.dictType}] load failed`, e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(val) {
|
||||
selected.value = val
|
||||
const opt = Array.isArray(val)
|
||||
? options.value.filter(o => val.includes(props.valueField === 'label' ? o.label : o.value))
|
||||
: options.value.find(o => (props.valueField === 'label' ? o.label : o.value) === val)
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val, opt)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// 字典类型变化时重拉 (如动态切换)
|
||||
watch(() => props.dictType, load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dict-select { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<dict-select
|
||||
v-model="selected"
|
||||
:dict-type="DICT_TYPE.DEPARTMENT"
|
||||
:value-field="valueField"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:multiple="multiple"
|
||||
class="doctor-dept-select"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import DictSelect from '@/components/DictSelect.vue'
|
||||
import { DICT_TYPE } from '@/api/dict'
|
||||
|
||||
/**
|
||||
* 公共科室下拉控件 (医生/科室字典, 与 RuoYi sys_dept 区分)
|
||||
* v-model 默认绑 ID (deptId), 也可改为绑 name 字符串
|
||||
*
|
||||
* 用法:
|
||||
* <dept-select v-model="form.deptId" />
|
||||
* <dept-select v-model="form.department" value-field="label" filterable placeholder="搜索科室" />
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Array], default: null },
|
||||
/** 'value' (deptId) 或 'label' (科室名称) */
|
||||
valueField: { type: String, default: 'value' },
|
||||
placeholder: { type: String, default: '请选择科室' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const selected = ref(props.modelValue ?? (props.multiple ? [] : null))
|
||||
watch(() => props.modelValue, v => {
|
||||
selected.value = v ?? (props.multiple ? [] : null)
|
||||
})
|
||||
|
||||
function handleChange(val, opt) {
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val, opt)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doctor-dept-select { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<dict-select
|
||||
v-model="selected"
|
||||
:dict-type="DICT_TYPE.TITLE"
|
||||
:value-field="valueField"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:multiple="multiple"
|
||||
class="doctor-title-select"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import DictSelect from '@/components/DictSelect.vue'
|
||||
import { DICT_TYPE } from '@/api/dict'
|
||||
|
||||
/**
|
||||
* 公共医生职称下拉控件
|
||||
* v-model 默认绑 ID (titleId), 也可改为绑 name 字符串
|
||||
*
|
||||
* 用法:
|
||||
* <title-select v-model="form.titleId" />
|
||||
* <title-select v-model="form.doctorTitle" value-field="label" placeholder="选职称" />
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Array], default: null },
|
||||
/** 'value' (titleId) 或 'label' (职称名称) */
|
||||
valueField: { type: String, default: 'value' },
|
||||
placeholder: { type: String, default: '请选择医生职称' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const selected = ref(props.modelValue ?? (props.multiple ? [] : null))
|
||||
watch(() => props.modelValue, v => {
|
||||
selected.value = v ?? (props.multiple ? [] : null)
|
||||
})
|
||||
|
||||
function handleChange(val, opt) {
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val, opt)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doctor-title-select { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
身份证正反面上传控件 (仿 hwt-code gr-id-card, 适配 PC 后台)
|
||||
用法 (双 v-model):
|
||||
<id-card-uploader
|
||||
v-model:front-url="form.idCardFrontUrl"
|
||||
v-model:back-url="form.idCardBackUrl"
|
||||
:dir="'ry8080/idcard/'"
|
||||
/>
|
||||
|
||||
readonly=true 时不显示上传按钮, 点击 thumb 弹 el-image 预览
|
||||
上传走 OSS 直传 (utils/oss.js 的 uploadToOss)
|
||||
-->
|
||||
<template>
|
||||
<div class="ht-image-upload-content" :class="{ 'has-file': frontUrl }" @click="handleClick('front')">
|
||||
<div v-if="!frontUrl && !readonly" class="ht-image-placeholder-wrap">
|
||||
<img class="ht-image-placeholder" :src="frontPlaceholder" />
|
||||
</div>
|
||||
<div v-else-if="!frontUrl && readonly" style="width: 90px;height: 60px; background: #f2f4f5;"></div>
|
||||
<img v-else-if="!readonly" class="ht-image" :src="frontUrl" />
|
||||
<el-image v-else :src="frontUrl" style="width: 90px;height: 60px; display: flex; justify-content: center; align-items: center; background: #f2f4f5;"
|
||||
:preview-src-list="[frontUrl]"></el-image>
|
||||
</div>
|
||||
<div class="ht-image-upload-content" :class="{ 'has-file': backUrl }" style="margin-left:20px" @click="handleClick('back')">
|
||||
<div v-if="!backUrl && !readonly" class="ht-image-placeholder-wrap">
|
||||
<img class="ht-image-placeholder" :src="backPlaceholder" />
|
||||
</div>
|
||||
<div v-else-if="!backUrl && readonly" style="width: 90px;height: 60px; background: #f2f4f5;"></div>
|
||||
<img v-else-if="!readonly" class="ht-image" :src="backUrl" />
|
||||
<el-image v-else :src="backUrl" style="width: 90px;height: 60px; display: flex; justify-content: center; align-items: center; background: #f2f4f5;"
|
||||
:preview-src-list="[backUrl]"></el-image>
|
||||
</div>
|
||||
<!-- hidden file input triggered by thumb click -->
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputFront"
|
||||
accept="image/*"
|
||||
style="display:none"
|
||||
@change="(e) => onFileChange(e, 'front')"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputBack"
|
||||
accept="image/*"
|
||||
style="display:none"
|
||||
@change="(e) => onFileChange(e, 'back')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
|
||||
const props = defineProps({
|
||||
frontUrl: { type: String, default: '' },
|
||||
backUrl: { type: String, default: '' },
|
||||
dir: { type: String, default: 'ry8080/idcard/' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
frontPlaceholder: { type: String, default: '/images/id-front.png' },
|
||||
backPlaceholder: { type: String, default: '/images/id-back.png' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:frontUrl', 'update:backUrl'])
|
||||
|
||||
const fileInputFront = ref(null)
|
||||
const fileInputBack = ref(null)
|
||||
const uploading = ref({ front: false, back: false })
|
||||
|
||||
function handleClick(side) {
|
||||
if (props.readonly) return
|
||||
if (uploading.value[side]) return
|
||||
const el = side === 'front' ? fileInputFront.value : fileInputBack.value
|
||||
el?.click()
|
||||
}
|
||||
|
||||
async function onFileChange(e, side) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
ElMessage.warning('只能上传图片文件')
|
||||
return
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 10) {
|
||||
ElMessage.warning('图片大小不能超过 10MB')
|
||||
return
|
||||
}
|
||||
uploading.value[side] = true
|
||||
try {
|
||||
const url = await uploadToOss(file, props.dir)
|
||||
if (side === 'front') {
|
||||
emit('update:frontUrl', url)
|
||||
} else {
|
||||
emit('update:backUrl', url)
|
||||
}
|
||||
ElMessage.success(side === 'front' ? '身份证正面已上传' : '身份证反面已上传')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value[side] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ht-image-upload-content {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
width: 90px;
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ht-image-upload-content:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
.ht-image-upload-content.has-file {
|
||||
border-style: solid;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
.ht-image-placeholder-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.ht-image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.ht-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f2f4f5;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<!--
|
||||
单图 OSS 上传控件 (90×60 缩略图, 视觉跟 IdCardUploader 一致)
|
||||
用法:
|
||||
<oss-image-uploader
|
||||
v-model="form.licenseCertUrl"
|
||||
:dir="'ry8080/license/'"
|
||||
placeholder="证书"
|
||||
/>
|
||||
|
||||
readonly=true 时不触发上传, 点击缩略图弹 el-image 预览
|
||||
上传走 OSS 直传 (utils/oss.js 的 uploadToOss)
|
||||
-->
|
||||
<template>
|
||||
<div class="ht-image-upload-content" :class="{ 'has-file': modelValue }" @click="handleClick">
|
||||
<div v-if="!modelValue && !readonly" class="ht-image-placeholder-wrap">
|
||||
<span class="placeholder-text">{{ placeholder }}</span>
|
||||
</div>
|
||||
<div v-else-if="!modelValue && readonly" style="width: 90px;height: 60px; background: #f2f4f5;"></div>
|
||||
<img v-else-if="!readonly" class="ht-image" :src="modelValue" />
|
||||
<el-image v-else :src="modelValue" style="width: 90px;height: 60px; display: flex; justify-content: center; align-items: center; background: #f2f4f5;"
|
||||
:preview-src-list="[modelValue]"></el-image>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
accept="image/*"
|
||||
style="display:none"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
dir: { type: String, default: 'ry8080/image/' },
|
||||
placeholder: { type: String, default: '点击上传' },
|
||||
readonly: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
function handleClick() {
|
||||
if (props.readonly || uploading.value) return
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFileChange(e) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
ElMessage.warning('只能上传图片文件')
|
||||
return
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 10) {
|
||||
ElMessage.warning('图片大小不能超过 10MB')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
const url = await uploadToOss(file, props.dir)
|
||||
emit('update:modelValue', url)
|
||||
ElMessage.success('上传成功')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ht-image-upload-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
width: 90px;
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fafafa;
|
||||
}
|
||||
.ht-image-upload-content:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
.ht-image-upload-content.has-file {
|
||||
border-style: solid;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
.ht-image-placeholder-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.placeholder-text {
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
color: #8c8c8c;
|
||||
line-height: 1;
|
||||
}
|
||||
.ht-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f2f4f5;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<el-dialog v-model="open" :title="title" width="780" top="6vh" :close-on-click-modal="false" destroy-on-close>
|
||||
<div v-if="fileUrl" class="preview-box">
|
||||
<!-- 图片 -->
|
||||
<img v-if="kind === 'image'" :src="fileUrl" class="preview-img" :alt="title" />
|
||||
<!-- PDF:iframe 预览,浏览器内置 viewer -->
|
||||
<iframe v-else-if="kind === 'pdf'" :src="fileUrl" class="preview-iframe" />
|
||||
<!-- 其它(doc/xls/zip 等):给下载链接 -->
|
||||
<div v-else class="preview-fallback">
|
||||
<el-icon class="fallback-icon"><Document /></el-icon>
|
||||
<p>该文件类型暂不支持页内预览,请点击下方按钮下载/在新窗口打开查看。</p>
|
||||
<el-button type="primary" @click="openNew">在新窗口打开</el-button>
|
||||
<el-button @click="copyUrl">复制链接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无附件" />
|
||||
<template #footer>
|
||||
<el-button @click="open = false">关闭</el-button>
|
||||
<el-button v-if="fileUrl" type="primary" @click="openNew">在新窗口打开</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
url: { type: String, default: '' },
|
||||
title: { type: String, default: '附件预览' }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const fileUrl = computed(() => props.url || '')
|
||||
|
||||
const kind = computed(() => {
|
||||
const u = fileUrl.value.split('?')[0].toLowerCase()
|
||||
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(u)) return 'image'
|
||||
if (/\.pdf(\?.*)?$/.test(u)) return 'pdf'
|
||||
return 'other'
|
||||
})
|
||||
|
||||
function openNew() {
|
||||
if (!fileUrl.value) return
|
||||
window.open(fileUrl.value, '_blank', 'noopener')
|
||||
}
|
||||
async function copyUrl() {
|
||||
if (!fileUrl.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileUrl.value)
|
||||
ElMessage.success('链接已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-box { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
|
||||
.preview-img { max-width: 100%; max-height: 70vh; object-fit: contain; }
|
||||
.preview-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||
.preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
|
||||
.fallback-icon { font-size: 48px; color: #909399; margin-bottom: 12px; }
|
||||
.preview-fallback p { margin: 12px 0 24px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user