feat: extend SMS signature and setting file support
Build and Deploy Vue3 / build (push) Successful in 1m25s
Build and Deploy Vue3 / deploy (push) Successful in 45s

This commit is contained in:
shiran
2026-07-14 18:17:05 +08:00
parent 2e3574a182
commit fa3bdcbc7e
3 changed files with 375 additions and 50 deletions
+72 -17
View File
@@ -1,7 +1,7 @@
<template> <template>
<el-dialog <el-dialog
v-model="visible" v-model="visible"
title="选择图片" :title="props.acceptAll ? '选择文件' : '选择图片'"
width="900px" width="900px"
append-to-body append-to-body
@close="handleClose" @close="handleClose"
@@ -12,13 +12,13 @@
<el-tab-pane label="文件库" name="fileLibrary"> <el-tab-pane label="文件库" name="fileLibrary">
<div class="file-list-container"> <div class="file-list-container">
<div class="file-list-header"> <div class="file-list-header">
<h4>图片文件库</h4> <h4>{{ props.acceptAll ? '文件库' : '图片文件库' }}</h4>
<div class="header-actions"> <div class="header-actions">
<span v-if="props.multiple && selectedIds.size > 0" class="selected-count"> <span v-if="props.multiple && selectedIds.size > 0" class="selected-count">
已选 {{ selectedIds.size }} 个文件 已选 {{ selectedIds.size }} 个文件
</span> </span>
<el-button type="primary" @click="switchToUpload" :icon="Upload"> <el-button type="primary" @click="switchToUpload" :icon="Upload">
上传新图片 {{ props.acceptAll ? '上传新文件' : '上传新图片' }}
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -52,10 +52,15 @@
</div> </div>
<div class="file-preview"> <div class="file-preview">
<img <img
v-if="isImageFile(file.realName, file.url)"
:src="processImageUrl(file.url)" :src="processImageUrl(file.url)"
:alt="file.realName" :alt="file.realName"
@error="handleImageError" @error="handleImageError"
/> />
<div v-else class="file-type-icon">
<el-icon :size="32"><Document /></el-icon>
<span class="file-ext">{{ getFileExt(file.realName) }}</span>
</div>
</div> </div>
<div class="file-info"> <div class="file-info">
<p class="file-name" :title="file.realName">{{ file.realName }}</p> <p class="file-name" :title="file.realName">{{ file.realName }}</p>
@@ -63,7 +68,7 @@
</div> </div>
</div> </div>
</div> </div>
<el-empty v-if="filteredFileList.length === 0 && !loading" description="暂无图片文件" /> <el-empty v-if="filteredFileList.length === 0 && !loading" :description="props.acceptAll ? '暂无文件' : '暂无图片文件'" />
<!-- 分页 --> <!-- 分页 -->
<div class="pagination-container" v-if="total > 0"> <div class="pagination-container" v-if="total > 0">
@@ -81,14 +86,14 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 上传图片 --> <!-- 上传文件 -->
<el-tab-pane label="上传图片" name="upload"> <el-tab-pane :label="props.acceptAll ? '上传文件' : '上传图片'" name="upload">
<div class="upload-section"> <div class="upload-section">
<el-upload <el-upload
:auto-upload="false" :auto-upload="false"
:show-file-list="false" :show-file-list="false"
:on-change="handleFileChange" :on-change="handleFileChange"
accept="image/*" :accept="props.acceptAll ? undefined : 'image/*'"
multiple multiple
drag drag
> >
@@ -98,7 +103,7 @@
</div> </div>
<template #tip> <template #tip>
<div class="el-upload__tip"> <div class="el-upload__tip">
支持jpgpnggifwebp等图片格式单个文件不超过5MB {{ props.acceptAll ? '支持所有文件类型,单个文件不超过50MB' : '支持jpg、png、gif、webp等图片格式单个文件不超过5MB' }}
</div> </div>
</template> </template>
</el-upload> </el-upload>
@@ -111,7 +116,10 @@
</div> </div>
<div class="pending-list"> <div class="pending-list">
<div v-for="(file, index) in pendingFiles" :key="index" class="pending-item"> <div v-for="(file, index) in pendingFiles" :key="index" class="pending-item">
<img :src="file.previewUrl" class="pending-preview" /> <img v-if="file.isImage" :src="file.previewUrl" class="pending-preview" />
<div v-else class="pending-preview pending-file-icon">
<el-icon :size="20"><Document /></el-icon>
</div>
<span class="pending-name" :title="file.name">{{ file.name }}</span> <span class="pending-name" :title="file.name">{{ file.name }}</span>
<span class="pending-size">{{ formatFileSize(file.size) }}</span> <span class="pending-size">{{ formatFileSize(file.size) }}</span>
<el-button type="danger" link size="small" @click="removePendingFile(index)">移除</el-button> <el-button type="danger" link size="small" @click="removePendingFile(index)">移除</el-button>
@@ -149,7 +157,7 @@
<script setup> <script setup>
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Upload, UploadFilled, Search, Select, Delete } from '@element-plus/icons-vue' import { Upload, UploadFilled, Search, Select, Delete, Document } from '@element-plus/icons-vue'
import { getFileList, getFileDetail, uploadFile } from '@/api/admin/file' import { getFileList, getFileDetail, uploadFile } from '@/api/admin/file'
// Props // Props
@@ -165,6 +173,10 @@ const props = defineProps({
multiple: { multiple: {
type: Boolean, type: Boolean,
default: false default: false
},
acceptAll: {
type: Boolean,
default: false
} }
}) })
@@ -349,22 +361,37 @@ const selectFile = (file) => {
} }
} }
// 判断是否为图片文件
const isImageFile = (name, url) => {
const imgExts = /\.(jpe?g|png|gif|webp|bmp|svg|ico|avif)$/i
if (name && imgExts.test(name)) return true
if (url && imgExts.test(url.split('?')[0])) return true
return false
}
const getFileExt = (name) => {
if (!name) return ''
const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1) return ''
return name.slice(dotIndex + 1).toUpperCase()
}
// 文件选择变化(收集待上传文件) // 文件选择变化(收集待上传文件)
const handleFileChange = (file) => { const handleFileChange = (file) => {
const rawFile = file.raw const rawFile = file.raw
if (!rawFile) return if (!rawFile) return
// 验证文件类型 if (!props.acceptAll) {
const isImage = rawFile.type.startsWith('image/') const isImage = rawFile.type.startsWith('image/')
if (!isImage) { if (!isImage) {
ElMessage.error(`${rawFile.name} 不是图片文件,已跳过`) ElMessage.error(`${rawFile.name} 不是图片文件,已跳过`)
return return
} }
}
// 验证文件大小 const sizeLimit = props.acceptAll ? 50 : 5
const isLt5M = rawFile.size / 1024 / 1024 < 5 if (rawFile.size / 1024 / 1024 > sizeLimit) {
if (!isLt5M) { ElMessage.error(`${rawFile.name} 超过 ${sizeLimit}MB,已跳过`)
ElMessage.error(`${rawFile.name} 超过 5MB,已跳过`)
return return
} }
@@ -372,12 +399,13 @@ const handleFileChange = (file) => {
const exists = pendingFiles.value.some(f => f.name === rawFile.name && f.size === rawFile.size) const exists = pendingFiles.value.some(f => f.name === rawFile.name && f.size === rawFile.size)
if (exists) return if (exists) return
// 添加到待上传列表,生成本地预览URL const fileIsImage = rawFile.type.startsWith('image/')
pendingFiles.value.push({ pendingFiles.value.push({
raw: rawFile, raw: rawFile,
name: rawFile.name, name: rawFile.name,
size: rawFile.size, size: rawFile.size,
previewUrl: URL.createObjectURL(rawFile) isImage: fileIsImage,
previewUrl: fileIsImage ? URL.createObjectURL(rawFile) : ''
}) })
} }
@@ -593,6 +621,25 @@ const handleConfirm = () => {
object-fit: cover; object-fit: cover;
} }
.file-type-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #909399;
}
.file-ext {
font-size: 10px;
font-weight: 600;
color: #606266;
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-info { .file-info {
text-align: center; text-align: center;
} }
@@ -670,6 +717,14 @@ const handleConfirm = () => {
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
} }
.pending-file-icon {
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f7fa;
color: #909399;
}
.pending-name { .pending-name {
flex: 1; flex: 1;
font-size: 13px; font-size: 13px;
+256 -14
View File
@@ -108,6 +108,7 @@
</div> </div>
<div class="sig-card-footer"> <div class="sig-card-footer">
<el-button size="small" @click="handleDetail(item)">详情</el-button>
<el-button type="primary" size="small" @click="handleEdit(item)">编辑</el-button> <el-button type="primary" size="small" @click="handleEdit(item)">编辑</el-button>
<el-button v-if="item.status === 0" type="warning" size="small" @click="handleSubmit(item)">提交审核</el-button> <el-button v-if="item.status === 0" type="warning" size="small" @click="handleSubmit(item)">提交审核</el-button>
<el-button v-if="item.status === 1" type="success" size="small" @click="handleApprove(item)">通过</el-button> <el-button v-if="item.status === 1" type="success" size="small" @click="handleApprove(item)">通过</el-button>
@@ -159,18 +160,52 @@
<el-form-item label="签名标题" prop="title"> <el-form-item label="签名标题" prop="title">
<el-input v-model="form.title" placeholder="如:某某公司" maxlength="30" show-word-limit /> <el-input v-model="form.title" placeholder="如:某某公司" maxlength="30" show-word-limit />
</el-form-item> </el-form-item>
<el-divider content-position="left">申请人资料</el-divider>
<el-form-item label="申请人姓名"> <el-form-item label="申请人姓名">
<el-input v-model="form.applicant_name" placeholder="申请人真实姓名" /> <el-input v-model="form.applicant_name" placeholder="申请人真实姓名(可选)" />
</el-form-item> </el-form-item>
<el-form-item label="身份证号"> <el-form-item label="申请人身份证号" prop="applicant_id_card">
<el-input v-model="form.applicant_id_card" placeholder="申请人身份证号" /> <el-input v-model="form.applicant_id_card" placeholder="申请人身份证号(可选)" />
</el-form-item> </el-form-item>
<el-form-item label="公司名称">
<el-input v-model="form.applicant_company" placeholder="申请人公司(可选)" /> <el-divider content-position="left">主体资料</el-divider>
<el-form-item label="主体名称">
<el-input v-model="form.applicant_company" placeholder="企业/主体名称(可选)" />
</el-form-item>
<el-form-item label="统一社会信用代码">
<el-input v-model="form.credit_code" placeholder="统一社会信用代码(可选)" />
</el-form-item> </el-form-item>
<el-form-item label="营业执照URL"> <el-form-item label="营业执照URL">
<el-input v-model="form.license_url" placeholder="营业执照图片地址(可选)" /> <el-input v-model="form.license_url" placeholder="营业执照图片地址(可选)" />
</el-form-item> </el-form-item>
<el-divider content-position="left">法人资料</el-divider>
<el-form-item label="法人姓名">
<el-input v-model="form.legal_person_name" placeholder="法人姓名(可选)" />
</el-form-item>
<el-form-item label="法人手机号" prop="legal_person_phone">
<el-input v-model="form.legal_person_phone" placeholder="法人手机号(可选)" />
</el-form-item>
<el-form-item label="法人身份证号" prop="legal_person_id_card">
<el-input v-model="form.legal_person_id_card" placeholder="法人身份证号(可选)" />
</el-form-item>
<el-divider content-position="left">经办人资料</el-divider>
<el-form-item label="经办人手机号" prop="agent_phone">
<el-input v-model="form.agent_phone" placeholder="经办人手机号(可选)" />
</el-form-item>
<el-form-item label="身份证正面URL">
<el-input v-model="form.agent_id_card_front_url" placeholder="经办人身份证正面图片地址(可选)" />
</el-form-item>
<el-form-item label="身份证反面URL">
<el-input v-model="form.agent_id_card_back_url" placeholder="经办人身份证反面图片地址(可选)" />
</el-form-item>
<el-divider content-position="left">承诺函</el-divider>
<el-form-item label="承诺函URL">
<el-input v-model="form.commitment_letter_url" placeholder="签署盖章后的承诺函照片地址(可选)" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
@@ -191,6 +226,77 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="detailVisible" title="签名详情" width="640px" append-to-body destroy-on-close>
<div v-if="detailData" class="detail-wrap">
<el-descriptions title="基础信息" :column="2" border size="small">
<el-descriptions-item label="签名标题">{{ detailData.title || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detailData.status)" size="small" effect="dark">{{ statusText(detailData.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户">
<el-link type="primary" :underline="false" @click="$router.push({ path: '/user/detail', query: { user_id: detailData.user_id } })">ID: {{ detailData.user_id }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(detailData.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item v-if="detailData.reject_reason" label="驳回原因" :span="2">
<span class="reject-text">{{ detailData.reject_reason }}</span>
</el-descriptions-item>
</el-descriptions>
<el-descriptions title="申请人资料" :column="2" border size="small" class="detail-section">
<el-descriptions-item label="申请人姓名">{{ detailData.applicant_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请人身份证号">{{ detailData.applicant_id_card || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="主体资料" :column="2" border size="small" class="detail-section">
<el-descriptions-item label="主体名称">{{ detailData.applicant_company || '-' }}</el-descriptions-item>
<el-descriptions-item label="统一社会信用代码">{{ detailData.credit_code || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="法人资料" :column="2" border size="small" class="detail-section">
<el-descriptions-item label="法人姓名">{{ detailData.legal_person_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="法人手机号">{{ detailData.legal_person_phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="法人身份证号" :span="2">{{ detailData.legal_person_id_card || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="经办人资料" :column="2" border size="small" class="detail-section">
<el-descriptions-item label="经办人手机号">{{ detailData.agent_phone || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="detail-section detail-images-title">证件与文件</div>
<div class="detail-images">
<div class="detail-img-item">
<span class="detail-img-label">营业执照</span>
<el-image v-if="detailData.license_url" :src="detailData.license_url" :preview-src-list="[detailData.license_url]" fit="cover" class="detail-img" preview-teleported />
<div v-else class="detail-img-empty">未上传</div>
</div>
<div class="detail-img-item">
<span class="detail-img-label">身份证正面</span>
<el-image v-if="detailData.agent_id_card_front_url" :src="detailData.agent_id_card_front_url" :preview-src-list="[detailData.agent_id_card_front_url]" fit="cover" class="detail-img" preview-teleported />
<div v-else class="detail-img-empty">未上传</div>
</div>
<div class="detail-img-item">
<span class="detail-img-label">身份证反面</span>
<el-image v-if="detailData.agent_id_card_back_url" :src="detailData.agent_id_card_back_url" :preview-src-list="[detailData.agent_id_card_back_url]" fit="cover" class="detail-img" preview-teleported />
<div v-else class="detail-img-empty">未上传</div>
</div>
<div class="detail-img-item">
<span class="detail-img-label">承诺函</span>
<el-image v-if="detailData.commitment_letter_url" :src="detailData.commitment_letter_url" :preview-src-list="[detailData.commitment_letter_url]" fit="cover" class="detail-img" preview-teleported />
<div v-else class="detail-img-empty">未上传</div>
</div>
</div>
<div class="detail-file-row">
<span class="detail-img-label">申请文件</span>
<el-link v-if="detailData.application_file_url" type="primary" :href="detailData.application_file_url" target="_blank">查看申请文件</el-link>
<span v-else class="detail-empty-text">未上传</span>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 表单用户选择器 --> <!-- 表单用户选择器 -->
<UserListSelector <UserListSelector
v-model="userSelectorVisible" v-model="userSelectorVisible"
@@ -229,17 +335,64 @@ const queryParams = reactive({ service_id: null, page: 1, count: 10, user_id: ''
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogType = ref('add') const dialogType = ref('add')
const formRef = ref(null) const formRef = ref(null)
const form = reactive({ service_id: null, user_id: null, title: '', applicant_name: '', applicant_id_card: '', applicant_company: '', license_url: '' })
const createEmptyForm = () => ({
service_id: null,
user_id: null,
title: '',
// 主体资料
applicant_company: '',
credit_code: '',
license_url: '',
// 法人资料
legal_person_name: '',
legal_person_phone: '',
legal_person_id_card: '',
// 经办人资料
applicant_name: '',
agent_phone: '',
applicant_id_card: '',
agent_id_card_front_url: '',
agent_id_card_back_url: '',
commitment_letter_url: ''
})
const form = reactive(createEmptyForm())
const originalEditForm = ref(null)
// 非必填字段:仅当填写时才校验格式,接口未声明必填的字段不设为必填
const validatePhone = (rule, value, callback) => {
if (value && !/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的手机号'))
} else {
callback()
}
}
const validateIdCard = (rule, value, callback) => {
if (value && !/(^\d{15}$)|(^\d{17}[\dXx]$)/.test(value)) {
callback(new Error('请输入正确的身份证号'))
} else {
callback()
}
}
const formRules = { const formRules = {
service_id: [{ required: true, message: '请选择服务', trigger: 'change' }], service_id: [{ required: true, message: '请选择服务', trigger: 'change' }],
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }], user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
title: [{ required: true, message: '请输入签名标题', trigger: 'blur' }] title: [{ required: true, message: '请输入签名标题', trigger: 'blur' }],
legal_person_phone: [{ validator: validatePhone, trigger: 'blur' }],
agent_phone: [{ validator: validatePhone, trigger: 'blur' }],
legal_person_id_card: [{ validator: validateIdCard, trigger: 'blur' }],
applicant_id_card: [{ validator: validateIdCard, trigger: 'blur' }]
} }
const rejectDialogVisible = ref(false) const rejectDialogVisible = ref(false)
const rejectReason = ref('') const rejectReason = ref('')
const currentRow = ref(null) const currentRow = ref(null)
const detailVisible = ref(false)
const detailData = ref(null)
const userSelectorVisible = ref(false) const userSelectorVisible = ref(false)
const selectedUserInfo = ref(null) const selectedUserInfo = ref(null)
const filterUserSelectorVisible = ref(false) const filterUserSelectorVisible = ref(false)
@@ -308,26 +461,42 @@ const clearUser = () => {
selectedUserInfo.value = null selectedUserInfo.value = null
} }
const handleDetail = (row) => {
detailData.value = row
detailVisible.value = true
}
const handleAdd = () => { const handleAdd = () => {
dialogType.value = 'add' dialogType.value = 'add'
selectedUserInfo.value = null selectedUserInfo.value = null
Object.assign(form, { service_id: queryParams.service_id, user_id: null, title: '', applicant_name: '', applicant_id_card: '', applicant_company: '', license_url: '' }) originalEditForm.value = null
Object.assign(form, createEmptyForm(), { service_id: queryParams.service_id })
delete form.signature_id
dialogVisible.value = true dialogVisible.value = true
} }
const handleEdit = (row) => { const handleEdit = (row) => {
dialogType.value = 'edit' dialogType.value = 'edit'
selectedUserInfo.value = { user_id: row.user_id, user_name: `用户${row.user_id}` } selectedUserInfo.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
Object.assign(form, { Object.assign(form, createEmptyForm(), {
service_id: queryParams.service_id, service_id: queryParams.service_id,
signature_id: row.ID, signature_id: row.ID,
user_id: row.user_id, user_id: row.user_id,
title: row.title, title: row.title,
applicant_name: row.applicant_name || '',
applicant_id_card: row.applicant_id_card || '',
applicant_company: row.applicant_company || '', applicant_company: row.applicant_company || '',
license_url: row.license_url || '' credit_code: row.credit_code || '',
license_url: row.license_url || '',
legal_person_name: row.legal_person_name || '',
legal_person_phone: row.legal_person_phone || '',
legal_person_id_card: row.legal_person_id_card || '',
applicant_name: row.applicant_name || '',
agent_phone: row.agent_phone || '',
applicant_id_card: row.applicant_id_card || '',
agent_id_card_front_url: row.agent_id_card_front_url || '',
agent_id_card_back_url: row.agent_id_card_back_url || '',
commitment_letter_url: row.commitment_letter_url || ''
}) })
originalEditForm.value = { ...form }
dialogVisible.value = true dialogVisible.value = true
} }
@@ -336,11 +505,34 @@ const handleFormSubmit = () => {
if (!valid) return if (!valid) return
submitting.value = true submitting.value = true
try { try {
const data = { ...form }
let res let res
if (dialogType.value === 'add') { if (dialogType.value === 'add') {
res = await createSmsSignature(data) res = await createSmsSignature({ ...form })
} else { } else {
const data = {
service_id: form.service_id,
signature_id: form.signature_id
}
const editableFields = [
'title',
'applicant_name',
'applicant_id_card',
'applicant_company',
'license_url',
'credit_code',
'legal_person_name',
'legal_person_phone',
'legal_person_id_card',
'agent_phone',
'agent_id_card_front_url',
'agent_id_card_back_url',
'commitment_letter_url'
]
editableFields.forEach((field) => {
if (form[field] !== originalEditForm.value?.[field]) {
data[field] = form[field]
}
})
res = await updateSmsSignature(data) res = await updateSmsSignature(data)
} }
if (res.data.code === 200) { if (res.data.code === 200) {
@@ -546,4 +738,54 @@ onMounted(async () => {
border-top: 1px solid #f0f2f5; border-top: 1px solid #f0f2f5;
flex-wrap: wrap; flex-wrap: wrap;
} }
/* 详情弹窗 */
.detail-wrap { max-height: 60vh; overflow-y: auto; padding-right: 4px; }
.detail-section { margin-top: 16px; }
.detail-images-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.detail-images {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.detail-img-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-img-label {
font-size: 12px;
color: #909399;
}
.detail-img {
width: 140px;
height: 90px;
border-radius: 6px;
border: 1px solid #ebeef5;
cursor: pointer;
}
.detail-img-empty {
width: 140px;
height: 90px;
border-radius: 6px;
background: #f5f7fa;
border: 1px dashed #dcdfe6;
display: flex;
align-items: center;
justify-content: center;
color: #c0c4cc;
font-size: 12px;
}
.detail-file-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
}
.detail-empty-text { color: #c0c4cc; font-size: 13px; }
</style> </style>
+43 -15
View File
@@ -303,7 +303,7 @@
<div v-else-if="settingForm.type === 'file'" class="file-upload-section"> <div v-else-if="settingForm.type === 'file'" class="file-upload-section">
<div class="file-info-display" v-if="settingForm.value && fileInfo"> <div class="file-info-display" v-if="settingForm.value && fileInfo">
<div class="file-item" :class="{ 'uploading': fileUploading && fileInfo.isLocal }"> <div class="file-item" :class="{ 'uploading': fileUploading && fileInfo.isLocal }">
<div class="file-preview" v-if="fileInfo.url || fileInfo.localUrl" @click="openImageSelector" style="cursor: pointer;"> <div class="file-preview" v-if="isImageFileName(fileInfo.realName || fileInfo.saveName) && (fileInfo.url || fileInfo.localUrl)" @click="openImageSelector" style="cursor: pointer;">
<img :src="fileInfo.localUrl || processImageUrl(fileInfo.url)" :alt="fileInfo.realName" class="preview-image" @click.stop="previewImage(fileInfo.localUrl || fileInfo.url)" @error="handleImageError" /> <img :src="fileInfo.localUrl || processImageUrl(fileInfo.url)" :alt="fileInfo.realName" class="preview-image" @click.stop="previewImage(fileInfo.localUrl || fileInfo.url)" @error="handleImageError" />
<div v-if="fileUploading && fileInfo.isLocal" class="upload-overlay"> <div v-if="fileUploading && fileInfo.isLocal" class="upload-overlay">
<el-icon class="is-loading"><Loading /></el-icon> <el-icon class="is-loading"><Loading /></el-icon>
@@ -313,6 +313,11 @@
<div class="file-preview" v-else @click="openImageSelector" style="cursor: pointer;"> <div class="file-preview" v-else @click="openImageSelector" style="cursor: pointer;">
<div class="file-placeholder"> <div class="file-placeholder">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span v-if="fileInfo.realName" class="file-ext-label">{{ getFileExtLabel(fileInfo.realName) }}</span>
</div>
<div v-if="fileUploading && fileInfo.isLocal" class="upload-overlay">
<el-icon class="is-loading"><Loading /></el-icon>
<span>上传中...</span>
</div> </div>
</div> </div>
<div class="file-details"> <div class="file-details">
@@ -321,8 +326,8 @@
<span class="file-size">{{ formatFileSize(fileInfo.size) }}</span> <span class="file-size">{{ formatFileSize(fileInfo.size) }}</span>
</div> </div>
<div class="file-actions"> <div class="file-actions">
<el-button type="primary" size="small" @click="openImageSelector" :icon="Picture"> <el-button type="primary" size="small" @click="openImageSelector" :icon="FolderOpened">
修改图片 修改文件
</el-button> </el-button>
<el-button type="danger" size="small" @click="clearFile">删除</el-button> <el-button type="danger" size="small" @click="clearFile">删除</el-button>
</div> </div>
@@ -333,10 +338,10 @@
<el-button <el-button
type="primary" type="primary"
@click="openImageSelector" @click="openImageSelector"
:icon="Picture" :icon="FolderOpened"
class="image-selector-btn" class="image-selector-btn"
> >
从文件库选择图片 从文件库选择文件
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -346,7 +351,7 @@
<div class="file-list-header"> <div class="file-list-header">
<span class="list-title">文件列表 ({{ getEditableFormFileList().length }} )</span> <span class="list-title">文件列表 ({{ getEditableFormFileList().length }} )</span>
<div class="header-actions"> <div class="header-actions">
<el-button size="small" @click="openImageSelectorForList" :icon="Picture"> <el-button size="small" @click="openImageSelectorForList" :icon="FolderOpened">
从文件库选择 从文件库选择
</el-button> </el-button>
</div> </div>
@@ -366,7 +371,7 @@
</div> </div>
<div class="file-preview"> <div class="file-preview">
<img <img
v-if="fileItem.url || fileItem.localUrl" v-if="isImageFileName(fileItem.realName || fileItem.saveName) && (fileItem.url || fileItem.localUrl)"
:src="fileItem.localUrl || processImageUrl(fileItem.url)" :src="fileItem.localUrl || processImageUrl(fileItem.url)"
:alt="fileItem.realName" :alt="fileItem.realName"
class="preview-image" class="preview-image"
@@ -375,6 +380,7 @@
/> />
<div v-else class="file-placeholder"> <div v-else class="file-placeholder">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span v-if="fileItem.realName || fileItem.saveName" class="file-ext-label">{{ getFileExtLabel(fileItem.realName || fileItem.saveName) }}</span>
</div> </div>
<div v-if="fileItem.uploading" class="upload-overlay"> <div v-if="fileItem.uploading" class="upload-overlay">
<el-icon class="is-loading"><Loading /></el-icon> <el-icon class="is-loading"><Loading /></el-icon>
@@ -509,7 +515,7 @@
<div class="file-thumb" @click="openStructFileSelector('struct', field.key)"> <div class="file-thumb" @click="openStructFileSelector('struct', field.key)">
<img v-if="structFileUrls[field.key]" :src="structFileUrls[field.key]" @error="(e) => e.target.style.display='none'" /> <img v-if="structFileUrls[field.key]" :src="structFileUrls[field.key]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"> <div v-else class="file-thumb-empty">
<el-icon><Picture /></el-icon> <el-icon><Document /></el-icon>
</div> </div>
</div> </div>
<el-button v-if="structValue[field.key]" type="danger" link size="small" class="file-clear-btn" @click="structValue[field.key] = ''; delete structFileUrls[field.key]"><el-icon><Delete /></el-icon></el-button> <el-button v-if="structValue[field.key]" type="danger" link size="small" class="file-clear-btn" @click="structValue[field.key] = ''; delete structFileUrls[field.key]"><el-icon><Delete /></el-icon></el-button>
@@ -525,7 +531,7 @@
> >
<div class="file-thumb" @click="previewFile(fid)"> <div class="file-thumb" @click="previewFile(fid)">
<img v-if="structFileUrls[field.key + '_' + fi]" :src="structFileUrls[field.key + '_' + fi]" @error="(e) => e.target.style.display='none'" /> <img v-if="structFileUrls[field.key + '_' + fi]" :src="structFileUrls[field.key + '_' + fi]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div> <div v-else class="file-thumb-empty"><el-icon><Document /></el-icon></div>
</div> </div>
<el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct', field.key, null, -1, fi)"><el-icon><Delete /></el-icon></el-button> <el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct', field.key, null, -1, fi)"><el-icon><Delete /></el-icon></el-button>
</div> </div>
@@ -591,7 +597,7 @@
<div v-else-if="sub.type === 'file'" class="struct-file-picker"> <div v-else-if="sub.type === 'file'" class="struct-file-picker">
<div class="file-thumb" @click="openStructFileSelector('struct-nested', field.key, sub.key)"> <div class="file-thumb" @click="openStructFileSelector('struct-nested', field.key, sub.key)">
<img v-if="structFileUrls[field.key + '.' + sub.key]" :src="structFileUrls[field.key + '.' + sub.key]" @error="(e) => e.target.style.display='none'" /> <img v-if="structFileUrls[field.key + '.' + sub.key]" :src="structFileUrls[field.key + '.' + sub.key]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div> <div v-else class="file-thumb-empty"><el-icon><Document /></el-icon></div>
</div> </div>
<el-button v-if="getNestedValue(field.key)[sub.key]" type="danger" link size="small" class="file-clear-btn" @click="getNestedValue(field.key)[sub.key] = ''; delete structFileUrls[field.key + '.' + sub.key]"><el-icon><Delete /></el-icon></el-button> <el-button v-if="getNestedValue(field.key)[sub.key]" type="danger" link size="small" class="file-clear-btn" @click="getNestedValue(field.key)[sub.key] = ''; delete structFileUrls[field.key + '.' + sub.key]"><el-icon><Delete /></el-icon></el-button>
</div> </div>
@@ -606,7 +612,7 @@
> >
<div class="file-thumb" @click="previewFile(fid)"> <div class="file-thumb" @click="previewFile(fid)">
<img v-if="structFileUrls[field.key + '.' + sub.key + '_' + fi]" :src="structFileUrls[field.key + '.' + sub.key + '_' + fi]" @error="(e) => e.target.style.display='none'" /> <img v-if="structFileUrls[field.key + '.' + sub.key + '_' + fi]" :src="structFileUrls[field.key + '.' + sub.key + '_' + fi]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div> <div v-else class="file-thumb-empty"><el-icon><Document /></el-icon></div>
</div> </div>
<el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct-nested', field.key, sub.key, -1, fi)"><el-icon><Delete /></el-icon></el-button> <el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct-nested', field.key, sub.key, -1, fi)"><el-icon><Delete /></el-icon></el-button>
</div> </div>
@@ -800,7 +806,7 @@
<div v-else-if="field.type === 'file'" class="struct-file-picker"> <div v-else-if="field.type === 'file'" class="struct-file-picker">
<div class="file-thumb small" @click="openStructFileSelector('struct-list', field.key, null, itemIndex)"> <div class="file-thumb small" @click="openStructFileSelector('struct-list', field.key, null, itemIndex)">
<img v-if="structFileUrls['list_' + itemIndex + '_' + field.key]" :src="structFileUrls['list_' + itemIndex + '_' + field.key]" @error="(e) => e.target.style.display='none'" /> <img v-if="structFileUrls['list_' + itemIndex + '_' + field.key]" :src="structFileUrls['list_' + itemIndex + '_' + field.key]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div> <div v-else class="file-thumb-empty"><el-icon><Document /></el-icon></div>
</div> </div>
<el-button v-if="item[field.key]" type="danger" link size="small" class="file-clear-btn" @click="item[field.key] = ''; delete structFileUrls['list_' + itemIndex + '_' + field.key]"><el-icon><Delete /></el-icon></el-button> <el-button v-if="item[field.key]" type="danger" link size="small" class="file-clear-btn" @click="item[field.key] = ''; delete structFileUrls['list_' + itemIndex + '_' + field.key]"><el-icon><Delete /></el-icon></el-button>
</div> </div>
@@ -815,7 +821,7 @@
> >
<div class="file-thumb small" @click="previewFile(fid)"> <div class="file-thumb small" @click="previewFile(fid)">
<img v-if="structFileUrls['list_' + itemIndex + '_' + field.key + '_' + fi]" :src="structFileUrls['list_' + itemIndex + '_' + field.key + '_' + fi]" @error="(e) => e.target.style.display='none'" /> <img v-if="structFileUrls['list_' + itemIndex + '_' + field.key + '_' + fi]" :src="structFileUrls['list_' + itemIndex + '_' + field.key + '_' + fi]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div> <div v-else class="file-thumb-empty"><el-icon><Document /></el-icon></div>
</div> </div>
<el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct-list', field.key, null, itemIndex, fi)"><el-icon><Delete /></el-icon></el-button> <el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct-list', field.key, null, itemIndex, fi)"><el-icon><Delete /></el-icon></el-button>
</div> </div>
@@ -1056,11 +1062,12 @@
<img :src="currentViewImage" style="max-width: 100%; max-height: 80vh;" /> <img :src="currentViewImage" style="max-width: 100%; max-height: 80vh;" />
</el-dialog> </el-dialog>
<!-- 图片选择器 --> <!-- 文件选择器 -->
<ImageSelector <ImageSelector
v-model="imageSelectorVisible" v-model="imageSelectorVisible"
:current-file-id="currentImageSelectorFileId" :current-file-id="currentImageSelectorFileId"
:multiple="imageSelectorMode === 'list' || imageSelectorMode === 'struct-file-list'" :multiple="imageSelectorMode === 'list' || imageSelectorMode === 'struct-file-list'"
:accept-all="true"
@confirm="handleImageSelectorConfirm" @confirm="handleImageSelectorConfirm"
/> />
</div> </div>
@@ -1070,7 +1077,7 @@
import { ref, reactive, onMounted, watch, nextTick, computed } from 'vue' import { ref, reactive, onMounted, watch, nextTick, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Delete, UploadFilled, Folder, Document, Loading, Picture, Edit, Rank, FolderAdd, CopyDocument, CircleCheck, CircleClose, InfoFilled, Clock, Setting, EditPen, Connection, ArrowRight, View } from '@element-plus/icons-vue' import { Search, Plus, Delete, UploadFilled, Folder, FolderOpened, Document, Loading, Picture, Edit, Rank, FolderAdd, CopyDocument, CircleCheck, CircleClose, InfoFilled, Clock, Setting, EditPen, Connection, ArrowRight, View } from '@element-plus/icons-vue'
import { import {
getSettingGroupList, getSettingGroupList,
getSettingGroupInfo, getSettingGroupInfo,
@@ -2213,6 +2220,18 @@ const formatFileSize = (bytes) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
} }
const isImageFileName = (name) => {
if (!name) return false
return /\.(jpe?g|png|gif|webp|bmp|svg|ico|avif)$/i.test(name)
}
const getFileExtLabel = (name) => {
if (!name) return ''
const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1) return ''
return name.slice(dotIndex + 1).toUpperCase()
}
const truncateFileName = (fileName, maxLength = 25) => { const truncateFileName = (fileName, maxLength = 25) => {
if (!fileName) return '' if (!fileName) return ''
if (fileName.length <= maxLength) return fileName if (fileName.length <= maxLength) return fileName
@@ -3485,13 +3504,22 @@ onMounted(() => {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 2px;
background: #f5f7fa; background: #f5f7fa;
color: #909399; color: #909399;
font-size: 24px; font-size: 24px;
} }
.file-ext-label {
font-size: 10px;
font-weight: 600;
color: #606266;
line-height: 1;
}
.file-details { .file-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;