front/src/views/order/components/OrderAttachmentsTab.vue
zzh a2e71a3a6b feat: 自动触发支持 AI 写公式,并完善下单失败与订单附件体验
触发条件接入 AI 助手(选模型、发送 skill、一键填入);预报时机并入下单并支持公式验证;下单失败可继续修改或存草稿;补充订单附件、H5 下单与渠道买价相关优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 20:54:28 +08:00

343 lines
8.4 KiB
Vue

<template>
<div class="order-attachments-tab">
<a-spin :loading="loading" class="attachments-spin">
<div
v-for="section in visibleSections"
:key="section.type"
class="attach-section"
>
<div class="attach-section-header">
<div class="attach-section-title-wrap">
<span class="attach-section-title">{{ $t(section.labelKey) }}</span>
<span class="attach-section-count">
{{ section.files.length }}/{{ section.maxCount }}
</span>
</div>
<a-upload
v-if="canUpload"
:accept="section.accept"
:multiple="section.maxCount > 1"
:show-file-list="false"
:disabled="uploadingType === section.type || section.files.length >= section.maxCount"
:custom-request="(opt) => handleUpload(section.type, opt)"
>
<a-button
type="outline"
size="small"
:loading="uploadingType === section.type"
:disabled="section.files.length >= section.maxCount"
>
{{ $t('order.myOrders.drawer.attachments.upload') }}
</a-button>
</a-upload>
</div>
<div v-if="section.files.length" class="attach-file-list">
<div
v-for="file in section.files"
:key="file.id"
class="attach-file-item"
>
<a
class="attach-file-name"
:href="resolveUrl(file.fileUrl)"
target="_blank"
rel="noopener noreferrer"
>
{{ file.fileName || file.fileUrl }}
</a>
<div class="attach-file-meta">
<span>{{ formatTime(file.createTime) }}</span>
<span v-if="file.source">{{ $t(`order.myOrders.drawer.attachments.source.${file.source}`, file.source) }}</span>
</div>
<div class="attach-file-actions">
<a-button
type="text"
size="mini"
@click="openFile(file.fileUrl)"
>
{{ $t('order.myOrders.drawer.attachments.preview') }}
</a-button>
<a-button
v-if="canUpload"
type="text"
size="mini"
status="danger"
:loading="deletingId === file.id"
@click="handleDelete(file.id)"
>
{{ $t('order.myOrders.drawer.attachments.delete') }}
</a-button>
</div>
</div>
</div>
<a-empty
v-else
:description="$t('order.myOrders.drawer.attachments.empty')"
:image-style="{ height: '48px' }"
class="attach-empty"
/>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue';
import {
deleteOrderFile,
getOrderFiles,
uploadOrderFiles,
type OrderFileItem,
} from '@/api/order';
import { resolveOmsAssetUrl } from '@/api/oms/common';
const FILE_TYPE_DEFS = [
{
type: 'tradeProof',
labelKey: 'order.myOrders.drawer.attachments.types.tradeProof',
maxCount: 20,
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf',
},
{
type: 'systemLabel',
labelKey: 'order.myOrders.drawer.attachments.types.systemLabel',
maxCount: 1,
accept: '.pdf,.jpg,.jpeg,.png',
},
{
type: 'downstreamLabel',
labelKey: 'order.myOrders.drawer.attachments.types.downstreamLabel',
maxCount: 20,
accept: '.pdf,.jpg,.jpeg,.png',
},
{
type: 'lastMileLabel',
labelKey: 'order.myOrders.drawer.attachments.types.lastMileLabel',
maxCount: 20,
accept: '.pdf,.jpg,.jpeg,.png',
},
{
type: 'infraredImage',
labelKey: 'order.myOrders.drawer.attachments.types.infraredImage',
maxCount: 20,
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf',
},
] as const;
const props = withDefaults(
defineProps<{
orderId?: number | null;
active?: boolean;
/** 客户门户:只读系统面单 */
readOnly?: boolean;
customerView?: boolean;
}>(),
{
orderId: null,
active: false,
readOnly: false,
customerView: false,
}
);
const { t } = useI18n();
const loading = ref(false);
const uploadingType = ref<string | null>(null);
const deletingId = ref<number | null>(null);
const groups = ref<Record<string, OrderFileItem[]>>({});
const canUpload = computed(() => !props.readOnly && !props.customerView);
const visibleSections = computed(() => {
const defs = props.customerView
? FILE_TYPE_DEFS.filter((d) => d.type === 'systemLabel')
: FILE_TYPE_DEFS;
return defs.map((def) => ({
...def,
files: groups.value[def.type] ?? [],
}));
});
function resolveUrl(path?: string) {
return resolveOmsAssetUrl(path);
}
function openFile(path?: string) {
const url = resolveUrl(path);
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
}
function formatTime(value?: string) {
if (!value) return '';
return String(value).replace('T', ' ').slice(0, 19);
}
async function loadFiles() {
if (!props.orderId) {
groups.value = {};
return;
}
loading.value = true;
try {
const data = await getOrderFiles(props.orderId);
groups.value = data.groups ?? {};
} catch (e: any) {
groups.value = {};
Message.error(e?.message || t('order.myOrders.drawer.attachments.loadFailed'));
} finally {
loading.value = false;
}
}
async function handleUpload(fileType: string, options: any) {
const file = options?.fileItem?.file ?? options?.file;
if (!file || !props.orderId) {
options?.onError?.(new Error('no file'));
return;
}
uploadingType.value = fileType;
try {
await uploadOrderFiles(props.orderId, fileType, [file as File]);
options?.onSuccess?.();
Message.success(t('order.myOrders.drawer.attachments.uploadSuccess'));
await loadFiles();
} catch (e: any) {
options?.onError?.(e);
Message.error(e?.message || t('order.myOrders.drawer.attachments.uploadFailed'));
} finally {
uploadingType.value = null;
}
}
function handleDelete(id: number) {
Modal.confirm({
title: t('order.myOrders.drawer.attachments.deleteConfirmTitle'),
content: t('order.myOrders.drawer.attachments.deleteConfirm'),
onOk: async () => {
deletingId.value = id;
try {
await deleteOrderFile(id);
Message.success(t('order.myOrders.drawer.attachments.deleteSuccess'));
await loadFiles();
} catch (e: any) {
Message.error(e?.message || t('order.myOrders.drawer.attachments.deleteFailed'));
} finally {
deletingId.value = null;
}
},
});
}
watch(
() => [props.active, props.orderId] as const,
([active, orderId]) => {
if (active && orderId) {
void loadFiles();
}
},
{ immediate: true }
);
</script>
<style scoped lang="less">
.order-attachments-tab {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 200px;
}
.attachments-spin {
width: 100%;
}
.attach-section {
background: #fff;
border: 1px solid var(--color-border-2);
border-radius: 4px;
padding: 12px 16px;
}
.attach-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.attach-section-title-wrap {
display: flex;
align-items: baseline;
gap: 8px;
}
.attach-section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.attach-section-count {
font-size: 12px;
color: var(--color-text-3);
}
.attach-file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attach-file-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px 12px;
align-items: center;
padding: 8px 10px;
background: #f7f8fa;
border-radius: 4px;
}
.attach-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-primary-color);
font-size: 13px;
}
.attach-file-meta {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
color: var(--color-text-3);
text-align: right;
}
.attach-file-actions {
display: flex;
align-items: center;
gap: 4px;
}
.attach-empty {
margin: 8px 0;
}
@media (max-width: 720px) {
.attach-file-item {
grid-template-columns: 1fr;
}
.attach-file-meta {
text-align: left;
}
}
</style>