diff --git a/IDE官网/assets/js/ai-advisor.js b/IDE官网/assets/js/ai-advisor.js index 5a2be8b..40c784a 100644 --- a/IDE官网/assets/js/ai-advisor.js +++ b/IDE官网/assets/js/ai-advisor.js @@ -38,8 +38,8 @@ welcomeTitle: 'IDE, your cross-border logistics advisor', welcomeTitleShort: 'IDE · Your logistics advisor', welcomeSubLead: 'Preferential rates · proactive service · faster transit · ', - welcomeSubAccent: 'AI-powered logistics', - welcomeSubTail: ' · dedicated lines, packets, air & express covered.', + welcomeSubAccent: 'AI logistics', + welcomeSubTail: ' · dedicated lines, packets, air & DHL/UPS/FedEX/3PE express covered.', placeholder: 'Ask about lanes, tracking, or 9610…', sendLabel: 'Ask', human: 'Talk to a human advisor', @@ -89,8 +89,8 @@ welcomeTitle: 'IDE,您身边的跨境物流顾问', welcomeTitleShort: 'IDE · 跨境物流顾问', welcomeSubLead: '价格更优惠 · 服务更主动 · 时效更快 · ', - welcomeSubAccent: 'AI 助力物流', - welcomeSubTail: ' · 专线/小包/空派/快递全覆盖。', + welcomeSubAccent: 'AI 物流', + welcomeSubTail: ' · 专线/小包/空派/DHL/UPS/FedEX/3PE快递全覆盖。', placeholder: '问线路、查轨迹、9610 政策…', sendLabel: '提问', human: '联系在线顾问', @@ -637,7 +637,8 @@ } function registerUrl(dest) { - const url = new URL(omsUrl(preferOmsH5() ? '/wechat/register' : '/register')); + // 官网「注册询价」走客户登录页微信扫码(注册/登录同一入口) + const url = new URL(loginPortalUrl()); url.searchParams.set('from', 'site'); if (dest) url.searchParams.set('dest', String(dest)); return url.toString(); diff --git a/src/api/channel/serviceChannel.ts b/src/api/channel/serviceChannel.ts index 792742b..9e4d9c5 100644 --- a/src/api/channel/serviceChannel.ts +++ b/src/api/channel/serviceChannel.ts @@ -20,10 +20,16 @@ export async function getServiceChannelGroupList(params?: { apiServiceProviderId?: number }) { const res = await axiosInstance.get(omsUrl('/channel/service-channel/group/list'), { params }) - assertBusinessOk(res.data) - const raw = res.data?.data ?? res.data?.Data + const body = res.data + // 网关偶发直接返回数组;也兼容 { code, data } / { items } + if (Array.isArray(body)) return body + assertBusinessOk(body) + const raw = body?.data ?? body?.Data ?? body?.result ?? body?.Result ?? body if (Array.isArray(raw)) return raw - if (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) return (raw as any).items + if (raw && typeof raw === 'object') { + const items = (raw as any).items ?? (raw as any).Items ?? (raw as any).list ?? (raw as any).List + if (Array.isArray(items)) return items + } return [] } diff --git a/src/api/fileStorage.ts b/src/api/fileStorage.ts new file mode 100644 index 0000000..3675181 --- /dev/null +++ b/src/api/fileStorage.ts @@ -0,0 +1,109 @@ +import { axiosInstance } from '@/api/rbac' +import { assertBusinessOk, omsUrl, unwrapData } from '@/api/oms/common' + +export type FileStorageFolder = + | 'avatars' + | 'sku' + | 'parcel' + | 'recharge' + | 'order-files' + | 'order-templates' + | 'xhs' + | 'prints' + | 'exports' + | 'misc' + +export type FileStorageCapabilities = { + supportsClientDirectUpload: boolean + provider?: string +} + +export type PresignUploadResult = { + key: string + uploadUrl: string + method: string + contentType?: string | null + headers?: Record + expireAt?: string +} + +export type AccessUrlResult = { + key: string + url: string +} + +function pickCaps(raw: any): FileStorageCapabilities { + return { + supportsClientDirectUpload: Boolean( + raw?.supportsClientDirectUpload ?? raw?.SupportsClientDirectUpload, + ), + provider: raw?.provider ?? raw?.Provider, + } +} + +function pickPresign(raw: any): PresignUploadResult { + const headers = raw?.headers ?? raw?.Headers + return { + key: String(raw?.key ?? raw?.Key ?? ''), + uploadUrl: String(raw?.uploadUrl ?? raw?.UploadUrl ?? ''), + method: String(raw?.method ?? raw?.Method ?? 'PUT'), + contentType: raw?.contentType ?? raw?.ContentType ?? null, + headers: + headers && typeof headers === 'object' && !Array.isArray(headers) + ? (headers as Record) + : undefined, + expireAt: raw?.expireAt ?? raw?.ExpireAt, + } +} + +/** GET /file-storage/capabilities */ +export async function getFileStorageCapabilities(): Promise { + const res = await axiosInstance.get(omsUrl('/file-storage/capabilities')) + const body = res.data + if (body && typeof body === 'object' && 'supportsClientDirectUpload' in body) { + return pickCaps(body) + } + return pickCaps(unwrapData(body)) +} + +/** POST /file-storage/presign-upload */ +export async function presignFileUpload(input: { + folder: FileStorageFolder | string + fileName: string + contentType?: string +}): Promise { + const res = await axiosInstance.post(omsUrl('/file-storage/presign-upload'), { + folder: input.folder, + fileName: input.fileName, + contentType: input.contentType, + }) + const raw = unwrapData(res.data) + const ticket = pickPresign(raw) + if (!ticket.key || !ticket.uploadUrl) { + throw new Error('预签名上传凭证不完整') + } + return ticket +} + +/** GET /file-storage/access-url?key= */ +export async function getFileAccessUrl(key: string): Promise { + const res = await axiosInstance.get(omsUrl('/file-storage/access-url'), { + params: { key }, + }) + const raw = unwrapData(res.data) + const url = String(raw?.url ?? raw?.Url ?? '') + const storageKey = String(raw?.key ?? raw?.Key ?? key) + if (!url) throw new Error('未返回访问地址') + return { key: storageKey, url } +} + +/** POST /file-storage/normalize-key */ +export async function normalizeFileStorageKey(pathOrUrl: string): Promise { + const res = await axiosInstance.post(omsUrl('/file-storage/normalize-key'), { + pathOrUrl, + }) + assertBusinessOk(res.data) + const raw = unwrapData(res.data) + const key = raw?.key ?? raw?.Key + return key != null && String(key).trim() ? String(key).trim() : null +} diff --git a/src/api/warehouse/index.ts b/src/api/warehouse/index.ts index ab8e470..e7990ac 100644 --- a/src/api/warehouse/index.ts +++ b/src/api/warehouse/index.ts @@ -1,5 +1,6 @@ import { axiosInstance } from '@/api/rbac' import { assertBusinessOk, omsUrl, parseListPayload, unwrapData } from '@/api/oms/common' +import { uploadFileDirect } from '@/utils/directUpload' function postList(path: string, body: Record) { return axiosInstance.post(omsUrl(path), body).then((res) => parseListPayload(res.data)) @@ -25,16 +26,80 @@ export const deleteWarehouseSku = (id: number) => postOk('/warehouse/sku/delete' export const bindWarehouseSkuMapping = (body: Record) => postOk('/warehouse/sku/bind-mapping', body) -/** 上传 SKU 图片,返回相对路径 url */ -export async function uploadWarehouseSkuImage(file: File): Promise { - const form = new FormData() - form.append('file', file) - const res = await axiosInstance.post(omsUrl('/warehouse/sku/upload-image'), form) - assertBusinessOk(res.data) - const data = unwrapData(res.data) - const url = data?.url ?? data?.fileUrl ?? data?.Url ?? data?.FileUrl - if (!url) throw new Error('上传成功但未返回图片地址') - return String(url) +/** 上传 SKU 图片:优先 COS 直传,落库存 key;返回 key + 预览 URL */ +export async function uploadWarehouseSkuImage( + file: File, + options?: { customerId?: number }, +): Promise<{ key: string; previewUrl: string }> { + return uploadFileDirect({ + folder: 'sku', + file, + fallback: async () => { + const form = new FormData() + form.append('file', file) + if (options?.customerId != null && options.customerId > 0) { + form.append('customerId', String(options.customerId)) + } + const res = await axiosInstance.post(omsUrl('/warehouse/sku/upload-image'), form, { + params: + options?.customerId != null && options.customerId > 0 + ? { customerId: options.customerId } + : undefined, + }) + assertBusinessOk(res.data) + const data = unwrapData(res.data) + const key = String(data?.key ?? data?.Key ?? data?.fileUrl ?? data?.FileUrl ?? '') + const previewUrl = String( + data?.url ?? data?.Url ?? (key ? resolvePreviewFromUpload(key, data) : ''), + ) + if (!key && !previewUrl) throw new Error('上传成功但未返回图片地址') + return { + key: key || previewUrl, + previewUrl: previewUrl || key, + } + }, + }) +} + +function resolvePreviewFromUpload(key: string, data: any): string { + const url = data?.url ?? data?.Url + if (url) return String(url) + return key +} + +/** 上传包裹商品图片:优先 COS 直传,落库存 key */ +export async function uploadWarehouseParcelImage( + file: File, + options?: { customerId?: number }, +): Promise<{ key: string; previewUrl: string }> { + return uploadFileDirect({ + folder: 'parcel', + file, + fallback: async () => { + const form = new FormData() + form.append('file', file) + if (options?.customerId != null && options.customerId > 0) { + form.append('customerId', String(options.customerId)) + } + const res = await axiosInstance.post(omsUrl('/warehouse/parcel/upload-image'), form, { + params: + options?.customerId != null && options.customerId > 0 + ? { customerId: options.customerId } + : undefined, + }) + assertBusinessOk(res.data) + const data = unwrapData(res.data) + const key = String(data?.key ?? data?.Key ?? data?.fileUrl ?? data?.FileUrl ?? '') + const previewUrl = String( + data?.url ?? data?.Url ?? (key ? resolvePreviewFromUpload(key, data) : ''), + ) + if (!key && !previewUrl) throw new Error('上传成功但未返回图片地址') + return { + key: key || previewUrl, + previewUrl: previewUrl || key, + } + }, + }) } // Bin location @@ -60,18 +125,6 @@ export const saveWarehouseParcel = (body: Record) => postOk('/w export const deleteWarehouseParcel = (id: number) => postOk('/warehouse/parcel/delete', { id }) export const putawayWarehouseParcel = (body: Record) => postOk('/warehouse/parcel/putaway', body) -/** 上传包裹商品图片,返回相对路径 url */ -export async function uploadWarehouseParcelImage(file: File): Promise { - const form = new FormData() - form.append('file', file) - const res = await axiosInstance.post(omsUrl('/warehouse/parcel/upload-image'), form) - assertBusinessOk(res.data) - const data = unwrapData(res.data) - const url = data?.url ?? data?.fileUrl ?? data?.Url ?? data?.FileUrl - if (!url) throw new Error('上传成功但未返回图片地址') - return String(url) -} - // Parcel arrival(快递到仓) export const getWarehouseParcelArrivalList = (body: Record) => postList('/warehouse/parcel-arrival/list', body) diff --git a/src/components/NanaAssistant/index.vue b/src/components/NanaAssistant/index.vue index 5114cb1..feb4690 100644 --- a/src/components/NanaAssistant/index.vue +++ b/src/components/NanaAssistant/index.vue @@ -37,14 +37,7 @@ :style="{ strokeDashoffset: ringOffset }" /> - - - {{ fabBadgeText }} - +
{{ t('nanaAssistant.menu.batchHint') }} - -
@@ -113,7 +82,6 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { getOrderBatchJobList } from '@/api/order'; -import { useInboxUnread } from '@/composables/useInboxUnread'; import { useNanaBatchPanel } from '@/composables/useNanaBatchPanel'; import { BATCH_JOB_TERMINAL_STATUSES } from '@/constants/orderBatchAction'; import NanaAvatar from './NanaAvatar.vue'; @@ -133,27 +101,13 @@ const { hasActiveJobs, ringPercent, attentionToken, + triggerAttention, setActiveJobCount, setRingPercent, setFabAnchor, visible: batchPanelVisible, } = useNanaBatchPanel(); -const { - notificationCount: inboxCount, - noticeCount, - totalCount: messageTotal, - displayCount: messageDisplayCount, - refreshUnread, -} = useInboxUnread(); - -const fabBadgeText = computed(() => { - if (hasActiveJobs.value) { - return activeJobCount.value > 9 ? '9+' : String(activeJobCount.value); - } - if (messageTotal.value > 0) return messageDisplayCount.value; - return ''; -}); const fabRef = ref(); const nanaX = ref(0); const nanaY = ref(0); @@ -163,6 +117,7 @@ const attentionActive = ref(false); const RING_R = 32; const RING_C = 2 * Math.PI * RING_R; +const JOB_POLL_MS = 3000; const ringOffset = computed(() => { const p = Math.min(100, Math.max(0, ringPercent.value)) / 100; @@ -170,6 +125,10 @@ const ringOffset = computed(() => { }); let attentionTimer: ReturnType | null = null; +let jobPollTimer: ReturnType | null = null; +/** Active job ids we are watching; used to detect finish without nagging on first load. */ +let trackedActiveIds = new Set(); +let jobTrackSeeded = false; const dragState = ref({ active: false, @@ -254,9 +213,7 @@ function onFabActivate() { closeBatchPanel(); return; } - const next = !menuOpen.value; - menuOpen.value = next; - if (next) refreshUnread(); + menuOpen.value = !menuOpen.value; } function onFabPointerUp() { @@ -281,16 +238,6 @@ function openBatchPanel() { void openBatchJobsPanel(null, { attention: true, delayMs: 160 }); } -function openInbox() { - menuOpen.value = false; - router.push({ name: 'SystemAnnouncement', query: { noticeType: 'message' } }); -} - -function openNotice() { - menuOpen.value = false; - router.push({ name: 'SystemAnnouncement', query: { noticeType: 'notice' } }); -} - function playAttention() { attentionActive.value = false; // restart CSS animation @@ -320,6 +267,10 @@ function onKeydown(e: KeyboardEvent) { if (menuOpen.value) menuOpen.value = false; } +function isJobTerminal(status: string) { + return BATCH_JOB_TERMINAL_STATUSES.has(String(status || '').toLowerCase()); +} + function jobProgressPercent(job: { total: number; successCount: number; @@ -331,18 +282,56 @@ function jobProgressPercent(job: { return Math.min(100, Math.round((finished / job.total) * 100)); } -async function refreshActiveBadge() { +function stopJobPoll() { + if (!jobPollTimer) return; + clearInterval(jobPollTimer); + jobPollTimer = null; +} + +function syncJobPoll() { + if (isAssistantPage.value || batchPanelVisible.value || !hasActiveJobs.value) { + stopJobPoll(); + return; + } + if (jobPollTimer) return; + jobPollTimer = setInterval(() => { + void refreshActiveJobs(); + }, JOB_POLL_MS); +} + +/** @param quiet Skip finish attention / auto-open (re-seed after panel close / first paint). */ +async function refreshActiveJobs(options?: { quiet?: boolean }) { if (isAssistantPage.value) return; + if (batchPanelVisible.value && !options?.quiet) return; try { const { items } = await getOrderBatchJobList({ page: 1, pageSize: 10 }); const list = items || []; - const active = list.filter( - (j) => !BATCH_JOB_TERMINAL_STATUSES.has(String(j.status || '').toLowerCase()), - ); + const active = list.filter((j) => !isJobTerminal(j.status)); + const quiet = options?.quiet || !jobTrackSeeded; + + if (!quiet) { + const finished = list.filter( + (j) => trackedActiveIds.has(j.jobId) && isJobTerminal(j.status), + ); + const withFail = finished.filter( + (j) => + j.failCount > 0 || String(j.status || '').toLowerCase() === 'failed', + ); + if (withFail.length > 0) { + syncFabAnchor(); + void openBatchJobsPanel(withFail[0].jobId, { attention: true }); + } else if (finished.length > 0) { + triggerAttention(); + } + } + + trackedActiveIds = new Set(active.map((j) => j.jobId)); + jobTrackSeeded = true; setActiveJobCount(active.length); setRingPercent(active.length ? jobProgressPercent(active[0]) : 0); + syncJobPoll(); } catch { - // ignore badge errors + // ignore job status errors } } @@ -350,11 +339,24 @@ watch(isAssistantPage, (v) => { if (v) { menuOpen.value = false; closeBatchPanel(); + stopJobPoll(); + } else { + void refreshActiveJobs({ quiet: true }); } }); watch(batchPanelVisible, (open) => { - if (open) menuOpen.value = false; + if (open) { + menuOpen.value = false; + stopJobPoll(); + return; + } + // Panel owned polling while open; re-seed then resume FAB ring poll if needed. + void refreshActiveJobs({ quiet: true }); +}); + +watch(hasActiveJobs, () => { + syncJobPoll(); }); watch(attentionToken, () => { @@ -366,13 +368,14 @@ onMounted(() => { window.addEventListener('resize', onViewportResize); document.addEventListener('pointerdown', onDocPointerDown, true); document.addEventListener('keydown', onKeydown); - void refreshActiveBadge(); + void refreshActiveJobs({ quiet: true }); }); onUnmounted(() => { window.removeEventListener('resize', onViewportResize); document.removeEventListener('pointerdown', onDocPointerDown, true); document.removeEventListener('keydown', onKeydown); + stopJobPoll(); if (attentionTimer) clearTimeout(attentionTimer); }); @@ -433,41 +436,6 @@ onUnmounted(() => { transition: stroke-dashoffset 0.45s ease; } -.nana-fab-badge { - position: absolute; - top: 2px; - right: 2px; - z-index: 2; - min-width: 18px; - height: 18px; - padding: 0 5px; - border-radius: 9px; - background: #f53f3f; - color: #fff; - font-size: 11px; - font-weight: 600; - line-height: 18px; - text-align: center; - box-shadow: 0 0 0 2px var(--app-card-bg, #fff); - pointer-events: none; - animation: nana-badge-in 0.28s cubic-bezier(0.34, 1.4, 0.64, 1); -} - -.nana-fab-badge.is-message { - background: var(--app-primary-color, #1d7281); -} - -@keyframes nana-badge-in { - from { - transform: scale(0.4); - opacity: 0; - } - to { - transform: scale(1); - opacity: 1; - } -} - .nana-fab-menu { position: absolute; right: calc(100% + 10px); @@ -557,7 +525,6 @@ onUnmounted(() => { @media (prefers-reduced-motion: reduce) { .nana-fab-host.is-attention, - .nana-fab-badge, .nana-fab-menu__item, .nana-fab-menu-enter-active, .nana-fab-menu-leave-active, diff --git a/src/components/top-nav/RightTopMessage.vue b/src/components/top-nav/RightTopMessage.vue index d018752..72442ac 100644 --- a/src/components/top-nav/RightTopMessage.vue +++ b/src/components/top-nav/RightTopMessage.vue @@ -1,5 +1,5 @@