feat:更换企业授权
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 3m41s

This commit is contained in:
luo
2026-06-22 10:36:33 +08:00
parent 866a2587b3
commit d3b5f55c64
34 changed files with 2478 additions and 3335 deletions

View File

@@ -47,23 +47,13 @@
/>
</ElFormItem>
<ElFormItem label="退款凭证" prop="refund_voucher_key">
<ElUpload
<VoucherUpload
ref="uploadRef"
class="refund-voucher-upload"
drag
:auto-upload="false"
:on-change="handleVoucherFileChange"
:on-remove="handleRemoveVoucher"
accept="image/*"
list-type="picture"
multiple
>
<ElIcon class="refund-voucher-upload__icon"><UploadFilled /></ElIcon>
<div class="refund-voucher-upload__text">将退款凭证拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">支持所有图片格式可上传多张</div>
</template>
</ElUpload>
v-model="formData.refund_voucher_key"
voucher-name="退款凭证"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('refund_voucher_key')"
/>
</ElFormItem>
</ElForm>
<template #footer>
@@ -75,7 +65,7 @@
:loading="submitLoading || voucherUploading"
:disabled="voucherUploading"
>
{{ voucherUploading ? '图片上传中...' : '确认创建' }}
{{ voucherUploading ? '凭证上传中...' : '确认创建' }}
</ElButton>
</div>
</template>
@@ -83,13 +73,13 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { RefundService, OrderService, StorageService } from '@/api/modules'
import type { FormInstance, FormRules } from 'element-plus'
import { RefundService, OrderService } from '@/api/modules'
import type { CreateRefundRequest, Order } from '@/types/api'
import { formatMoney, yuanToFen } from '@/utils/business/format'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
interface Props {
modelValue: boolean
@@ -109,13 +99,11 @@
})
const formRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const submitLoading = ref(false)
const voucherUploading = ref(false)
const voucherUploadingCount = ref(0)
const orderSearchLoading = ref(false)
const orderSearchOptions = ref<Order[]>([])
let activeVoucherUploadBatch = 0
const formData = reactive<{
order_id: number | null
@@ -207,96 +195,14 @@
const handleDialogClosed = () => {
formRef.value?.resetFields()
activeVoucherUploadBatch += 1
voucherUploadingCount.value = 0
voucherUploading.value = false
formData.order_id = null
formData.requested_refund_amount = undefined
formData.actual_received_amount = 0
formData.refund_reason = ''
formData.refund_voucher_key = undefined
voucherFileKeyMap.clear()
removedVoucherUploadUids.clear()
orderSearchOptions.value = []
uploadRef.value?.clearFiles()
}
const voucherFileKeyMap = new Map<number, string>()
const removedVoucherUploadUids = new Set<number>()
const refreshPaymentVoucherKey = () => {
const keys = Array.from(voucherFileKeyMap.values())
formData.refund_voucher_key = keys.length ? keys.join(',') : undefined
}
const removeUploadFile = (uploadFile: UploadFile) => {
uploadRef.value?.handleRemove(uploadFile)
}
const handleVoucherFileChange = async (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!file.type.startsWith('image/')) {
ElMessage.error('只能上传图片文件!')
removeUploadFile(uploadFile)
return
}
const uploadBatch = activeVoucherUploadBatch
removedVoucherUploadUids.delete(uploadFile.uid)
voucherUploadingCount.value += 1
voucherUploading.value = voucherUploadingCount.value > 0
try {
ElMessage.info('正在上传退款凭证...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: file.type,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
removeUploadFile(uploadFile)
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, file.type)
if (
uploadBatch !== activeVoucherUploadBatch ||
removedVoucherUploadUids.has(uploadFile.uid)
) {
return
}
voucherFileKeyMap.set(uploadFile.uid, file_key)
refreshPaymentVoucherKey()
ElMessage.success('上传成功')
await nextTick(() => {
formRef.value?.validateField('refund_voucher_key')
})
} catch (error: any) {
if (uploadBatch !== activeVoucherUploadBatch) return
console.error('上传退款凭证失败:', error)
ElMessage.error(error.message || '上传失败,请重试')
voucherFileKeyMap.delete(uploadFile.uid)
refreshPaymentVoucherKey()
removeUploadFile(uploadFile)
} finally {
if (uploadBatch === activeVoucherUploadBatch) {
voucherUploadingCount.value = Math.max(0, voucherUploadingCount.value - 1)
voucherUploading.value = voucherUploadingCount.value > 0
}
}
}
const handleRemoveVoucher = (uploadFile: UploadFile) => {
removedVoucherUploadUids.add(uploadFile.uid)
voucherFileKeyMap.delete(uploadFile.uid)
refreshPaymentVoucherKey()
uploadRef.value?.clearFiles(false)
}
const handleSubmit = async () => {
@@ -343,35 +249,3 @@
}
})
</script>
<style scoped lang="scss">
.refund-voucher-upload {
width: 100%;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 24px 16px;
border-radius: 8px;
}
&__icon {
margin-bottom: 12px;
font-size: 28px;
color: var(--el-color-primary);
}
&__text {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -1,22 +1,47 @@
<template>
<ElImageViewer
v-if="visible"
:url-list="urls"
:initial-index="0"
hide-on-click-modal
@close="handleClose"
/>
<ElDialog v-model="visible" title="凭证查看" width="560px" @closed="handleClose">
<div v-if="loading" class="voucher-preview__loading">加载中...</div>
<div v-else-if="files.length" class="voucher-preview__list">
<div v-for="file in files" :key="file.key" class="voucher-preview__item">
<ElImage
v-if="file.isImage"
class="voucher-preview__image"
:src="file.url"
:preview-src-list="imageUrls"
:initial-index="getImageIndex(file.url)"
fit="cover"
preview-teleported
/>
<div v-else class="voucher-preview__file">
<ElIcon><Document /></ElIcon>
</div>
<div class="voucher-preview__meta">
<div class="voucher-preview__name" :title="file.name">{{ file.name }}</div>
<ElButton link type="primary" @click="openFile(file)">查看/下载</ElButton>
</div>
</div>
</div>
<ElEmpty v-else description="暂无凭证" />
</ElDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElImageViewer } from 'element-plus'
import { computed, ref, watch } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { ElButton, ElDialog, ElEmpty, ElIcon, ElImage } from 'element-plus'
import { StorageService } from '@/api/modules'
interface Props {
fileKey?: string
}
interface VoucherFile {
key: string
name: string
url: string
isImage: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
@@ -24,32 +49,74 @@
}>()
const visible = ref(false)
const urls = ref<string[]>([])
const loading = ref(false)
const files = ref<VoucherFile[]>([])
const imageUrls = computed(() =>
files.value.filter((file) => file.isImage).map((file) => file.url)
)
const getVoucherKeys = (value: string) =>
value
.split(',')
.map((key) => key.trim())
.filter(Boolean)
const getFileName = (fileKey: string) => fileKey.split('/').pop() || fileKey
const isImageFile = (fileKey: string, url: string) => {
const target = `${fileKey} ${url}`.toLowerCase()
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/.test(target)
}
const getImageIndex = (url: string) => Math.max(0, imageUrls.value.indexOf(url))
const openFile = (file: VoucherFile) => {
window.open(file.url, '_blank', 'noopener,noreferrer')
}
watch(
() => props.fileKey,
async (val) => {
if (val) {
urls.value = []
if (!val) {
visible.value = false
const fileKeys = val
.split(',')
.map((key) => key.trim())
.filter(Boolean)
try {
const res = await StorageService.batchDownloadUrls({
file_keys: fileKeys
})
if (res.code === 0) {
urls.value = fileKeys.map((key) => res.data.urls[key]).filter(Boolean)
visible.value = urls.value.length > 0
}
} catch (error) {
console.error('获取支付凭证失败:', error)
files.value = []
return
}
const fileKeys = getVoucherKeys(val)
if (!fileKeys.length) {
visible.value = false
files.value = []
return
}
visible.value = true
loading.value = true
files.value = []
try {
const res = await StorageService.batchDownloadUrls({
file_keys: fileKeys
})
if (res.code === 0) {
files.value = fileKeys
.map((key) => {
const url = res.data.urls[key]
if (!url) return null
return {
key,
name: getFileName(key),
url,
isImage: isImageFile(key, url)
}
})
.filter((file): file is VoucherFile => !!file)
}
} else {
visible.value = false
urls.value = []
} catch (error) {
console.error('获取凭证失败:', error)
} finally {
loading.value = false
}
},
{ immediate: true }
@@ -57,7 +124,62 @@
const handleClose = () => {
visible.value = false
urls.value = []
loading.value = false
files.value = []
emit('close')
}
</script>
<style scoped lang="scss">
.voucher-preview {
&__loading {
padding: 32px 0;
color: var(--el-text-color-secondary);
text-align: center;
}
&__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
&__item {
min-width: 0;
}
&__image,
&__file {
width: 120px;
height: 120px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
&__image {
cursor: zoom-in;
}
&__file {
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
&__meta {
width: 120px;
margin-top: 8px;
}
&__name {
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-regular);
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,404 @@
<template>
<div
ref="rootRef"
class="voucher-upload"
tabindex="0"
@click="focusUploadArea"
@paste="handlePaste"
>
<ElUpload
ref="uploadRef"
class="voucher-upload__control"
drag
multiple
:auto-upload="false"
:on-change="handleFileChange"
:on-remove="handleRemoveFile"
list-type="picture"
>
<ElIcon class="voucher-upload__icon"><UploadFilled /></ElIcon>
<div class="voucher-upload__text">{{ voucherName }}拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">
支持多张图片或附件最多上传 {{ maxCount }} 个文件点击上传区域后可粘贴文件
</div>
</template>
<template #file="{ file }">
<div class="voucher-upload__file-item">
<img
v-if="isUploadImage(file)"
class="voucher-upload__file-cover"
:src="getUploadFileUrl(file)"
alt=""
/>
<div v-else class="voucher-upload__file-cover voucher-upload__file-cover--default">
<ElIcon><Document /></ElIcon>
</div>
<span class="voucher-upload__file-name" :title="file.name">{{ file.name }}</span>
<button
class="voucher-upload__file-remove"
type="button"
aria-label="移除文件"
@click.stop="removeUploadFile(file)"
>
<ElIcon><Close /></ElIcon>
</button>
</div>
</template>
</ElUpload>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { genFileId } from 'element-plus'
import { Close, Document, UploadFilled } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus'
import { StorageService } from '@/api/modules'
interface Props {
modelValue?: string
voucherName?: string
maxCount?: number
}
const props = withDefaults(defineProps<Props>(), {
voucherName: '凭证',
maxCount: 5
})
const emit = defineEmits<{
'update:modelValue': [value: string | undefined]
'uploading-change': [value: boolean]
change: [value: string | undefined]
}>()
const rootRef = ref<HTMLElement>()
const uploadRef = ref<UploadInstance>()
const uploadingCount = ref(0)
const voucherFileKeyMap = new Map<number, string>()
const removedUploadUids = new Set<number>()
const fileObjectUrlMap = new Map<number, string>()
const selectedUploadUids = new Set<number>()
let activeUploadBatch = 0
const getMaxCount = () => Math.max(1, props.maxCount)
const showMaxCountWarning = () => {
ElMessage.warning(`最多只能上传 ${getMaxCount()} 个文件`)
}
const emitVoucherKeys = () => {
const keys = Array.from(voucherFileKeyMap.values())
const value = keys.length ? keys.join(',') : undefined
emit('update:modelValue', value)
emit('change', value)
}
const setUploadingCount = (count: number) => {
uploadingCount.value = Math.max(0, count)
emit('uploading-change', uploadingCount.value > 0)
}
const clearFiles = (emitValue = true) => {
activeUploadBatch += 1
setUploadingCount(0)
voucherFileKeyMap.clear()
removedUploadUids.clear()
selectedUploadUids.clear()
revokeAllFileObjectUrls()
uploadRef.value?.clearFiles()
if (emitValue) {
emitVoucherKeys()
}
}
const revokeFileObjectUrl = (uid: number) => {
const url = fileObjectUrlMap.get(uid)
if (!url) return
URL.revokeObjectURL(url)
fileObjectUrlMap.delete(uid)
}
const revokeAllFileObjectUrls = () => {
fileObjectUrlMap.forEach((url) => URL.revokeObjectURL(url))
fileObjectUrlMap.clear()
}
const isUploadImage = (file: UploadFile) => {
if (file.raw?.type?.startsWith('image/')) return true
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name)
}
const getUploadFileUrl = (file: UploadFile) => {
if (file.url) return file.url
if (!file.raw) return ''
const cachedUrl = fileObjectUrlMap.get(file.uid)
if (cachedUrl) return cachedUrl
const url = URL.createObjectURL(file.raw)
fileObjectUrlMap.set(file.uid, url)
return url
}
const removeUploadFile = (uploadFile: UploadFile) => {
uploadRef.value?.handleRemove(uploadFile)
}
const handleFileChange = async (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!selectedUploadUids.has(uploadFile.uid)) {
if (selectedUploadUids.size >= getMaxCount()) {
showMaxCountWarning()
removeUploadFile(uploadFile)
return
}
selectedUploadUids.add(uploadFile.uid)
}
const uploadBatch = activeUploadBatch
removedUploadUids.delete(uploadFile.uid)
setUploadingCount(uploadingCount.value + 1)
try {
ElMessage.info(`正在上传${props.voucherName}...`)
const contentType = file.type || 'application/octet-stream'
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
removeUploadFile(uploadFile)
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, contentType)
if (uploadBatch !== activeUploadBatch || removedUploadUids.has(uploadFile.uid)) {
return
}
voucherFileKeyMap.set(uploadFile.uid, file_key)
emitVoucherKeys()
ElMessage.success('上传成功')
} catch (error: any) {
if (uploadBatch !== activeUploadBatch) return
console.error(`上传${props.voucherName}失败:`, error)
ElMessage.error(error?.message || '上传失败,请重试')
voucherFileKeyMap.delete(uploadFile.uid)
emitVoucherKeys()
removeUploadFile(uploadFile)
} finally {
if (uploadBatch === activeUploadBatch) {
setUploadingCount(uploadingCount.value - 1)
}
}
}
const handleRemoveFile = (uploadFile: UploadFile) => {
removedUploadUids.add(uploadFile.uid)
selectedUploadUids.delete(uploadFile.uid)
revokeFileObjectUrl(uploadFile.uid)
voucherFileKeyMap.delete(uploadFile.uid)
emitVoucherKeys()
}
const focusUploadArea = () => {
nextTick(() => rootRef.value?.focus())
}
const getExtensionByMimeType = (mimeType: string) => {
const extensionMap: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'application/pdf': 'pdf',
'text/plain': 'txt'
}
return extensionMap[mimeType] || 'bin'
}
const normalizeClipboardFile = (file: File, index: number) => {
if (file.name) return file
const extension = getExtensionByMimeType(file.type)
return new File([file], `clipboard-${Date.now()}-${index + 1}.${extension}`, {
type: file.type || 'application/octet-stream'
})
}
const startUploadFile = (file: File) => {
const uploadRawFile = file as UploadRawFile
uploadRawFile.uid = genFileId()
uploadRef.value?.handleStart(uploadRawFile)
}
const getClipboardFiles = (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) return []
const files = Array.from(clipboardData.files || [])
if (files.length) return files
return Array.from(clipboardData.items || [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => !!file)
}
const handlePaste = (event: ClipboardEvent) => {
if (event.defaultPrevented) return
if (!isUploadVisible()) return
const files = getClipboardFiles(event)
if (!files.length) return
event.preventDefault()
const remainingCount = getMaxCount() - selectedUploadUids.size
if (remainingCount <= 0) {
showMaxCountWarning()
return
}
if (files.length > remainingCount) {
showMaxCountWarning()
}
files.slice(0, remainingCount).map(normalizeClipboardFile).forEach(startUploadFile)
}
const isUploadVisible = () => {
const element = rootRef.value
return (
!!element &&
!!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
)
}
onMounted(() => {
document.addEventListener('paste', handlePaste)
})
onBeforeUnmount(() => {
document.removeEventListener('paste', handlePaste)
revokeAllFileObjectUrls()
})
defineExpose({
clearFiles
})
</script>
<style scoped lang="scss">
.voucher-upload {
width: 100%;
outline: none;
&:focus-within,
&:focus {
:deep(.el-upload-dragger) {
border-color: var(--el-color-primary);
}
}
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 24px 16px;
border-radius: 8px;
}
:deep(.el-upload-list__item) {
height: auto;
padding: 8px;
line-height: normal;
}
&__icon {
margin-bottom: 12px;
font-size: 28px;
color: var(--el-color-primary);
}
&__text {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
&__file-item {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
min-width: 0;
}
&__file-cover {
flex: 0 0 auto;
width: 42px;
height: 42px;
object-fit: cover;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
}
&__file-cover--default {
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
&__file-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--el-text-color-regular);
text-overflow: ellipsis;
white-space: nowrap;
}
&__file-remove {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 50%;
&:hover {
color: var(--el-color-danger);
background: var(--el-fill-color-light);
}
}
}
</style>