让凭证数组与套餐作废任务进入可联调状态
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 7m52s
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 7m52s
Constraint: 后端订单套餐作废接口使用 /api/admin/order-package-invalidate-tasks,上传 purpose 只能使用 attachment。 Rejected: 继续提交逗号拼接凭证字符串 | 新提交路径必须使用 string[],历史字符串只做读取归一化。 Confidence: high Scope-risk: broad Directive: 新增订单套餐作废权限时需同时配置菜单 URL 与 order_package_invalidate_task:create/detail 按钮权限。 Tested: bun run build;development 真实后端联调上传、创建、列表、详情通过;复机接口未触发。 Not-tested: 未逐一手工覆盖所有历史凭证数据、全部角色权限组合和所有文件扩展名预览
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
<!-- 选择下拉项表单项 -->
|
||||
<ElFormItem v-if="showSelect" :label="selectLabel" :prop="selectProp">
|
||||
<ElSelect
|
||||
v-model="formData[selectProp]"
|
||||
v-model="formData[selectField]"
|
||||
:placeholder="selectPlaceholder"
|
||||
filterable
|
||||
remote
|
||||
@@ -80,8 +80,7 @@
|
||||
ElOption,
|
||||
ElInput,
|
||||
ElButton,
|
||||
ElTag,
|
||||
ElMessage
|
||||
ElTag
|
||||
} from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
@@ -145,12 +144,20 @@
|
||||
const showCardList = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
type FormData = Record<string, string> & {
|
||||
selectedValue: string
|
||||
amount: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
const formData = reactive<FormData>({
|
||||
selectedValue: '',
|
||||
amount: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const selectField = computed(() => props.selectProp || 'selectedValue')
|
||||
|
||||
// 下拉选项
|
||||
const selectOptions = ref<SelectOption[]>([])
|
||||
|
||||
@@ -159,7 +166,7 @@
|
||||
const rules: FormRules = {}
|
||||
|
||||
if (props.showSelect) {
|
||||
rules[props.selectProp!] = [
|
||||
rules[selectField.value] = [
|
||||
{ required: true, message: `请选择${props.selectLabel}`, trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
import { RefundService, OrderService } from '@/api/modules'
|
||||
import type { CreateRefundRequest, Order } from '@/types/api'
|
||||
import { formatMoney, yuanToFen } from '@/utils/business/format'
|
||||
import { getErrorMessage } from '@/utils/business'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
|
||||
interface Props {
|
||||
@@ -110,13 +111,13 @@
|
||||
requested_refund_amount?: number
|
||||
actual_received_amount: number
|
||||
refund_reason: string
|
||||
refund_voucher_key?: string
|
||||
refund_voucher_key: string[]
|
||||
}>({
|
||||
order_id: null,
|
||||
requested_refund_amount: undefined,
|
||||
actual_received_amount: 0,
|
||||
refund_reason: '',
|
||||
refund_voucher_key: undefined
|
||||
refund_voucher_key: []
|
||||
})
|
||||
|
||||
const validateRefundAmount = (rule: any, value: number, callback: any) => {
|
||||
@@ -200,7 +201,7 @@
|
||||
formData.requested_refund_amount = undefined
|
||||
formData.actual_received_amount = 0
|
||||
formData.refund_reason = ''
|
||||
formData.refund_voucher_key = undefined
|
||||
formData.refund_voucher_key = []
|
||||
orderSearchOptions.value = []
|
||||
uploadRef.value?.clearFiles(false)
|
||||
}
|
||||
@@ -220,7 +221,7 @@
|
||||
order_id: formData.order_id!,
|
||||
requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0,
|
||||
actual_received_amount: formData.actual_received_amount,
|
||||
refund_voucher_key: formData.refund_voucher_key!,
|
||||
refund_voucher_key: formData.refund_voucher_key,
|
||||
refund_reason: formData.refund_reason || undefined
|
||||
}
|
||||
await RefundService.createRefund(data)
|
||||
@@ -229,6 +230,7 @@
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error(getErrorMessage(error, '退款申请创建失败'))
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { ElTag } from 'element-plus'
|
||||
import { AccountService } from '@/api/modules'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { PlatformAccount } from '@/types/api'
|
||||
@@ -175,13 +175,13 @@
|
||||
prop: 'shop_id',
|
||||
label: '店铺ID',
|
||||
width: 100,
|
||||
formatter: (row: PlatformAccount) => row.shop_id || '-'
|
||||
formatter: (row: PlatformAccount) => String(row.shop_id || '-')
|
||||
},
|
||||
{
|
||||
prop: 'enterprise_id',
|
||||
label: '企业ID',
|
||||
width: 100,
|
||||
formatter: (row: PlatformAccount) => row.enterprise_id || '-'
|
||||
formatter: (row: PlatformAccount) => String(row.enterprise_id || '-')
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
|
||||
@@ -13,11 +13,16 @@
|
||||
@change="handleChange"
|
||||
@visible-change="handleVisibleChange"
|
||||
>
|
||||
<el-option v-for="item in packageList" :key="item.id" :label="item.name" :value="item.id">
|
||||
<el-option
|
||||
v-for="item in packageList"
|
||||
:key="item.id"
|
||||
:label="item.packageName"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="package-option">
|
||||
<span class="package-name">{{ item.name }}</span>
|
||||
<span class="package-name">{{ item.packageName }}</span>
|
||||
<span class="package-info">
|
||||
{{ formatFlow(item.flow) }} / {{ formatMoney(item.price) }}
|
||||
{{ formatFlow(item.totalFlow) }} / {{ formatMoney(item.price) }}
|
||||
</span>
|
||||
</div>
|
||||
</el-option>
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
preview-teleported
|
||||
/>
|
||||
<div v-else class="voucher-preview__file">
|
||||
<ElIcon><Document /></ElIcon>
|
||||
<ElIcon><component :is="getFileIcon(file)" /></ElIcon>
|
||||
<span class="voucher-preview__file-type">{{ file.fileType }}</span>
|
||||
</div>
|
||||
<div class="voucher-preview__meta">
|
||||
<div class="voucher-preview__name" :title="file.name">{{ file.name }}</div>
|
||||
<div class="voucher-preview__name" :title="file.name">{{
|
||||
getDisplayName(file.name)
|
||||
}}</div>
|
||||
<ElButton link type="primary" @click="openFile(file)">查看/下载</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,12 +30,24 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Box,
|
||||
DataAnalysis,
|
||||
Document,
|
||||
Film,
|
||||
Headset,
|
||||
Memo,
|
||||
Picture,
|
||||
Tickets
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElButton, ElDialog, ElEmpty, ElIcon, ElImage } from 'element-plus'
|
||||
import type { Component } from 'vue'
|
||||
import { StorageService } from '@/api/modules'
|
||||
import { toVoucherKeyList } from '@/utils/business'
|
||||
|
||||
interface Props {
|
||||
fileKey?: string
|
||||
fileKey?: string | string[]
|
||||
fileKeys?: string[]
|
||||
}
|
||||
|
||||
interface VoucherFile {
|
||||
@@ -40,6 +55,8 @@
|
||||
name: string
|
||||
url: string
|
||||
isImage: boolean
|
||||
extension: string
|
||||
fileType: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -56,14 +73,47 @@
|
||||
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 getFileExtension = (fileName: string) => {
|
||||
const match = fileName.toLowerCase().match(/\.([a-z0-9]+)(?:[?#].*)?$/)
|
||||
return match?.[1] || ''
|
||||
}
|
||||
|
||||
const getFileType = (extension: string) => {
|
||||
if (!extension) return '文件'
|
||||
const typeMap: Record<string, string> = {
|
||||
csv: 'CSV',
|
||||
xls: 'Excel',
|
||||
xlsx: 'Excel',
|
||||
doc: 'Word',
|
||||
docx: 'Word',
|
||||
pdf: 'PDF',
|
||||
txt: 'TXT',
|
||||
json: 'JSON',
|
||||
zip: 'ZIP',
|
||||
rar: 'RAR',
|
||||
'7z': '7Z',
|
||||
mp4: '视频',
|
||||
mov: '视频',
|
||||
avi: '视频',
|
||||
mp3: '音频',
|
||||
wav: '音频'
|
||||
}
|
||||
return typeMap[extension] || extension.toUpperCase()
|
||||
}
|
||||
|
||||
const getDisplayName = (fileName: string) => {
|
||||
if (fileName.length <= 28) return fileName
|
||||
|
||||
const extension = getFileExtension(fileName)
|
||||
const suffix = extension ? `.${extension}` : ''
|
||||
const baseName = suffix ? fileName.slice(0, -suffix.length) : fileName
|
||||
if (baseName.length <= 22) return fileName
|
||||
|
||||
return `${baseName.slice(0, 12)}...${baseName.slice(-8)}${suffix}`
|
||||
}
|
||||
|
||||
const isImageFile = (fileKey: string, url: string) => {
|
||||
const target = `${fileKey} ${url}`.toLowerCase()
|
||||
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/.test(target)
|
||||
@@ -71,21 +121,45 @@
|
||||
|
||||
const getImageIndex = (url: string) => Math.max(0, imageUrls.value.indexOf(url))
|
||||
|
||||
const getFileIcon = (file: VoucherFile): Component => {
|
||||
const iconMap: Record<string, Component> = {
|
||||
csv: DataAnalysis,
|
||||
xls: DataAnalysis,
|
||||
xlsx: DataAnalysis,
|
||||
doc: Tickets,
|
||||
docx: Tickets,
|
||||
pdf: Document,
|
||||
txt: Memo,
|
||||
json: Memo,
|
||||
zip: Box,
|
||||
rar: Box,
|
||||
'7z': Box,
|
||||
mp4: Film,
|
||||
mov: Film,
|
||||
avi: Film,
|
||||
mp3: Headset,
|
||||
wav: Headset,
|
||||
png: Picture,
|
||||
jpg: Picture,
|
||||
jpeg: Picture,
|
||||
gif: Picture,
|
||||
webp: Picture,
|
||||
bmp: Picture,
|
||||
svg: Picture
|
||||
}
|
||||
return iconMap[file.extension] || Document
|
||||
}
|
||||
|
||||
const openFile = (file: VoucherFile) => {
|
||||
window.open(file.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.fileKey,
|
||||
async (val) => {
|
||||
if (!val) {
|
||||
visible.value = false
|
||||
files.value = []
|
||||
return
|
||||
}
|
||||
() => [props.fileKeys, props.fileKey] as const,
|
||||
async ([fileKeys, fileKey]) => {
|
||||
const keys = fileKeys?.length ? toVoucherKeyList(fileKeys) : toVoucherKeyList(fileKey)
|
||||
|
||||
const fileKeys = getVoucherKeys(val)
|
||||
if (!fileKeys.length) {
|
||||
if (!keys.length) {
|
||||
visible.value = false
|
||||
files.value = []
|
||||
return
|
||||
@@ -97,10 +171,10 @@
|
||||
|
||||
try {
|
||||
const res = await StorageService.batchDownloadUrls({
|
||||
file_keys: fileKeys
|
||||
file_keys: keys
|
||||
})
|
||||
if (res.code === 0) {
|
||||
files.value = fileKeys
|
||||
files.value = keys
|
||||
.map((key) => {
|
||||
const url = res.data.urls[key]
|
||||
if (!url) return null
|
||||
@@ -108,7 +182,9 @@
|
||||
key,
|
||||
name: getFileName(key),
|
||||
url,
|
||||
isImage: isImageFile(key, url)
|
||||
isImage: isImageFile(key, url),
|
||||
extension: getFileExtension(getFileName(key)),
|
||||
fileType: getFileType(getFileExtension(getFileName(key)))
|
||||
}
|
||||
})
|
||||
.filter((file): file is VoucherFile => !!file)
|
||||
@@ -162,6 +238,8 @@
|
||||
|
||||
&__file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
@@ -169,6 +247,17 @@
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&__file-type {
|
||||
max-width: 84px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
color: var(--el-text-color-regular);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
width: 120px;
|
||||
margin-top: 8px;
|
||||
@@ -177,6 +266,7 @@
|
||||
&__name {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--el-text-color-regular);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { genFileId } from 'element-plus'
|
||||
import { Close, Document, UploadFilled } from '@element-plus/icons-vue'
|
||||
@@ -58,7 +58,7 @@
|
||||
import { StorageService } from '@/api/modules'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
modelValue?: string[] | string
|
||||
voucherName?: string
|
||||
maxCount?: number
|
||||
}
|
||||
@@ -69,9 +69,9 @@
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | undefined]
|
||||
'update:modelValue': [value: string[]]
|
||||
'uploading-change': [value: boolean]
|
||||
change: [value: string | undefined]
|
||||
change: [value: string[]]
|
||||
}>()
|
||||
|
||||
const rootRef = ref<HTMLElement>()
|
||||
@@ -91,9 +91,8 @@
|
||||
|
||||
const emitVoucherKeys = () => {
|
||||
const keys = Array.from(voucherFileKeyMap.values())
|
||||
const value = keys.length ? keys.join(',') : undefined
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
emit('update:modelValue', keys)
|
||||
emit('change', keys)
|
||||
}
|
||||
|
||||
const setUploadingCount = (count: number) => {
|
||||
@@ -114,6 +113,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const keys = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? value
|
||||
.split(',')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
voucherFileKeyMap.clear()
|
||||
keys.slice(0, getMaxCount()).forEach((key, index) => {
|
||||
voucherFileKeyMap.set(index + 1, key)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const revokeFileObjectUrl = (uid: number) => {
|
||||
const url = fileObjectUrlMap.get(uid)
|
||||
if (!url) return
|
||||
|
||||
Reference in New Issue
Block a user