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
+256 -14
View File
@@ -108,6 +108,7 @@
</div>
<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 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>
@@ -159,18 +160,52 @@
<el-form-item label="签名标题" prop="title">
<el-input v-model="form.title" placeholder="如:某某公司" maxlength="30" show-word-limit />
</el-form-item>
<el-divider content-position="left">申请人资料</el-divider>
<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 label="身份证号">
<el-input v-model="form.applicant_id_card" placeholder="申请人身份证号" />
<el-form-item label="申请人身份证号" prop="applicant_id_card">
<el-input v-model="form.applicant_id_card" placeholder="申请人身份证号(可选)" />
</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 label="营业执照URL">
<el-input v-model="form.license_url" placeholder="营业执照图片地址(可选)" />
</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>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -191,6 +226,77 @@
</template>
</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
v-model="userSelectorVisible"
@@ -229,17 +335,64 @@ const queryParams = reactive({ service_id: null, page: 1, count: 10, user_id: ''
const dialogVisible = ref(false)
const dialogType = ref('add')
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 = {
service_id: [{ required: true, message: '请选择服务', trigger: 'change' }],
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 rejectReason = ref('')
const currentRow = ref(null)
const detailVisible = ref(false)
const detailData = ref(null)
const userSelectorVisible = ref(false)
const selectedUserInfo = ref(null)
const filterUserSelectorVisible = ref(false)
@@ -308,26 +461,42 @@ const clearUser = () => {
selectedUserInfo.value = null
}
const handleDetail = (row) => {
detailData.value = row
detailVisible.value = true
}
const handleAdd = () => {
dialogType.value = 'add'
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
}
const handleEdit = (row) => {
dialogType.value = 'edit'
selectedUserInfo.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
Object.assign(form, {
Object.assign(form, createEmptyForm(), {
service_id: queryParams.service_id,
signature_id: row.ID,
user_id: row.user_id,
title: row.title,
applicant_name: row.applicant_name || '',
applicant_id_card: row.applicant_id_card || '',
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
}
@@ -336,11 +505,34 @@ const handleFormSubmit = () => {
if (!valid) return
submitting.value = true
try {
const data = { ...form }
let res
if (dialogType.value === 'add') {
res = await createSmsSignature(data)
res = await createSmsSignature({ ...form })
} 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)
}
if (res.data.code === 200) {
@@ -546,4 +738,54 @@ onMounted(async () => {
border-top: 1px solid #f0f2f5;
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>
+43 -15
View File
@@ -303,7 +303,7 @@
<div v-else-if="settingForm.type === 'file'" class="file-upload-section">
<div class="file-info-display" v-if="settingForm.value && fileInfo">
<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" />
<div v-if="fileUploading && fileInfo.isLocal" class="upload-overlay">
<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-placeholder">
<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 class="file-details">
@@ -321,8 +326,8 @@
<span class="file-size">{{ formatFileSize(fileInfo.size) }}</span>
</div>
<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 type="danger" size="small" @click="clearFile">删除</el-button>
</div>
@@ -333,10 +338,10 @@
<el-button
type="primary"
@click="openImageSelector"
:icon="Picture"
:icon="FolderOpened"
class="image-selector-btn"
>
从文件库选择图片
从文件库选择文件
</el-button>
</div>
</div>
@@ -346,7 +351,7 @@
<div class="file-list-header">
<span class="list-title">文件列表 ({{ getEditableFormFileList().length }} )</span>
<div class="header-actions">
<el-button size="small" @click="openImageSelectorForList" :icon="Picture">
<el-button size="small" @click="openImageSelectorForList" :icon="FolderOpened">
从文件库选择
</el-button>
</div>
@@ -366,7 +371,7 @@
</div>
<div class="file-preview">
<img
v-if="fileItem.url || fileItem.localUrl"
v-if="isImageFileName(fileItem.realName || fileItem.saveName) && (fileItem.url || fileItem.localUrl)"
:src="fileItem.localUrl || processImageUrl(fileItem.url)"
:alt="fileItem.realName"
class="preview-image"
@@ -375,6 +380,7 @@
/>
<div v-else class="file-placeholder">
<el-icon><Document /></el-icon>
<span v-if="fileItem.realName || fileItem.saveName" class="file-ext-label">{{ getFileExtLabel(fileItem.realName || fileItem.saveName) }}</span>
</div>
<div v-if="fileItem.uploading" class="upload-overlay">
<el-icon class="is-loading"><Loading /></el-icon>
@@ -509,7 +515,7 @@
<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'" />
<div v-else class="file-thumb-empty">
<el-icon><Picture /></el-icon>
<el-icon><Document /></el-icon>
</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>
@@ -525,7 +531,7 @@
>
<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'" />
<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>
<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>
@@ -591,7 +597,7 @@
<div v-else-if="sub.type === 'file'" class="struct-file-picker">
<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'" />
<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>
<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>
@@ -606,7 +612,7 @@
>
<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'" />
<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>
<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>
@@ -800,7 +806,7 @@
<div v-else-if="field.type === 'file'" class="struct-file-picker">
<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'" />
<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>
<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>
@@ -815,7 +821,7 @@
>
<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'" />
<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>
<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>
@@ -1056,11 +1062,12 @@
<img :src="currentViewImage" style="max-width: 100%; max-height: 80vh;" />
</el-dialog>
<!-- 图片选择器 -->
<!-- 文件选择器 -->
<ImageSelector
v-model="imageSelectorVisible"
:current-file-id="currentImageSelectorFileId"
:multiple="imageSelectorMode === 'list' || imageSelectorMode === 'struct-file-list'"
:accept-all="true"
@confirm="handleImageSelectorConfirm"
/>
</div>
@@ -1070,7 +1077,7 @@
import { ref, reactive, onMounted, watch, nextTick, computed } from 'vue'
import { useRoute } from 'vue-router'
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 {
getSettingGroupList,
getSettingGroupInfo,
@@ -2213,6 +2220,18 @@ const formatFileSize = (bytes) => {
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) => {
if (!fileName) return ''
if (fileName.length <= maxLength) return fileName
@@ -3485,13 +3504,22 @@ onMounted(() => {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
background: #f5f7fa;
color: #909399;
font-size: 24px;
}
.file-ext-label {
font-size: 10px;
font-weight: 600;
color: #606266;
line-height: 1;
}
.file-details {
display: flex;
flex-direction: column;