让凭证数组与套餐作废任务进入可联调状态
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:
2026-06-23 09:50:12 +08:00
parent ebf9739f06
commit 0c3065f970
71 changed files with 4264 additions and 240 deletions

View File

@@ -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' }
]
}

View File

@@ -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
}

View File

@@ -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',

View File

@@ -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>

View File

@@ -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;

View File

@@ -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