feat: COS 直传、仓储上传带客户、9610 表格与 Nana 体验修复
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m28s

管理端包裹/SKU 上传携带 customerId;服务渠道分组列表解析;9610 操作列渲染;Nana FAB 去掉数字角标并恢复顶栏消息。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-11 12:09:36 +08:00
parent 8f12a6cf65
commit a24dcb41ff
19 changed files with 501 additions and 181 deletions

View File

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

View File

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

109
src/api/fileStorage.ts Normal file
View File

@ -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<string, string>
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<string, string>)
: undefined,
expireAt: raw?.expireAt ?? raw?.ExpireAt,
}
}
/** GET /file-storage/capabilities */
export async function getFileStorageCapabilities(): Promise<FileStorageCapabilities> {
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<PresignUploadResult> {
const res = await axiosInstance.post(omsUrl('/file-storage/presign-upload'), {
folder: input.folder,
fileName: input.fileName,
contentType: input.contentType,
})
const raw = unwrapData<any>(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<AccessUrlResult> {
const res = await axiosInstance.get(omsUrl('/file-storage/access-url'), {
params: { key },
})
const raw = unwrapData<any>(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<string | null> {
const res = await axiosInstance.post(omsUrl('/file-storage/normalize-key'), {
pathOrUrl,
})
assertBusinessOk(res.data)
const raw = unwrapData<any>(res.data)
const key = raw?.key ?? raw?.Key
return key != null && String(key).trim() ? String(key).trim() : null
}

View File

@ -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<string, unknown>) {
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<string, unknown>) =>
postOk('/warehouse/sku/bind-mapping', body)
/** 上传 SKU 图片,返回相对路径 url */
export async function uploadWarehouseSkuImage(file: File): Promise<string> {
/** 上传 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)
const res = await axiosInstance.post(omsUrl('/warehouse/sku/upload-image'), form)
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<any>(res.data)
const url = data?.url ?? data?.fileUrl ?? data?.Url ?? data?.FileUrl
if (!url) throw new Error('上传成功但未返回图片地址')
return String(url)
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<any>(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<string, unknown>) => postOk('/w
export const deleteWarehouseParcel = (id: number) => postOk('/warehouse/parcel/delete', { id })
export const putawayWarehouseParcel = (body: Record<string, unknown>) => postOk('/warehouse/parcel/putaway', body)
/** 上传包裹商品图片,返回相对路径 url */
export async function uploadWarehouseParcelImage(file: File): Promise<string> {
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<any>(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<string, unknown>) =>
postList('/warehouse/parcel-arrival/list', body)

View File

@ -37,14 +37,7 @@
:style="{ strokeDashoffset: ringOffset }"
/>
</svg>
<NanaAvatar size="lg" :pulse="hasActiveJobs || messageTotal > 0" />
<span
v-if="fabBadgeText"
class="nana-fab-badge"
:class="{ 'is-message': !hasActiveJobs && messageTotal > 0 }"
>
{{ fabBadgeText }}
</span>
<NanaAvatar size="lg" :pulse="hasActiveJobs" />
<Transition name="nana-fab-menu">
<div
@ -76,30 +69,6 @@
</span>
<span class="nana-fab-menu__hint">{{ t('nanaAssistant.menu.batchHint') }}</span>
</button>
<button
type="button"
class="nana-fab-menu__item"
role="menuitem"
@click.stop="openInbox"
>
<span class="nana-fab-menu__label">
{{ t('navbar.messageInbox') }}
<span v-if="inboxCount > 0" class="nana-fab-menu__count">{{ inboxCount }}</span>
</span>
<span class="nana-fab-menu__hint">{{ t('nanaAssistant.menu.inboxHint') }}</span>
</button>
<button
type="button"
class="nana-fab-menu__item"
role="menuitem"
@click.stop="openNotice"
>
<span class="nana-fab-menu__label">
{{ t('navbar.messageNotice') }}
<span v-if="noticeCount > 0" class="nana-fab-menu__count">{{ noticeCount }}</span>
</span>
<span class="nana-fab-menu__hint">{{ t('nanaAssistant.menu.noticeHint') }}</span>
</button>
</div>
</Transition>
</div>
@ -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<HTMLElement>();
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<typeof setTimeout> | null = null;
let jobPollTimer: ReturnType<typeof setInterval> | null = null;
/** Active job ids we are watching; used to detect finish without nagging on first load. */
let trackedActiveIds = new Set<number>();
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);
});
</script>
@ -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,

View File

@ -1,5 +1,5 @@
<template>
<!-- Legacy entry kept for reuse; top-nav uses Nana FAB instead. -->
<!-- Top-nav inbox / announcement entry (unread counts stay here, not on Nana FAB). -->
<a-dropdown trigger="hover" placement="bottomRight" @visible-change="onMessageDropdownVisibleChange">
<div class="message-dropdown-trigger" :title="t('navbar.message')">
<IconNotification class="message-icon" :class="totalCount > 0 ? 'theMessage' : ''" />

View File

@ -204,6 +204,11 @@
<ContactService variant="navbar" />
</div>
<!-- 消息:站内信 / 公告 -->
<div class="top-nav-right-item top-nav-right-item--icon">
<RightTopMessage />
</div>
<!-- 语言 -->
<div class="top-nav-right-item top-nav-right-item--icon">
<SelectLang />
@ -232,6 +237,7 @@ import { useTabBarStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import TabBar from '@/components/tab-bar/index.vue';
import RightTopUser from './RightTopUser.vue';
import RightTopMessage from './RightTopMessage.vue';
import SelectLang from './SelectLang.vue';
import ContactService from '@/components/ContactService/index.vue';
import { IconPrinter } from '@arco-design/web-vue/es/icon';

View File

@ -6,7 +6,7 @@
* | ideship(生产) | https://ideship.com | https://api.ideship.com/{admin,itfx,track} |
* | nanafa(测试) | https://oms.nanafa.com | http://api.nanafa.com/{admin,itfx,track} |
*
* 本地:pnpm dev(测试) / pnpm dev:ideship(生产 API)
* 本地:pnpm dev(生产 API) / pnpm dev:test(测试 API)
* 构建:pnpm build(生产) / pnpm build:test(测试)
*/
export type DeployTarget = 'ideship' | 'nanafa'

View File

@ -1297,8 +1297,6 @@ export default {
'nanaAssistant.menu.advisorHint': 'Rates, tracking, human help',
'nanaAssistant.menu.batchJobs': 'Batch jobs',
'nanaAssistant.menu.batchHint': 'Progress and results',
'nanaAssistant.menu.inboxHint': 'Direct inbox messages',
'nanaAssistant.menu.noticeHint': 'System announcements',
'nanaAssistant.batchPanel.title': 'Batch jobs',
'nanaAssistant.batchPanel.empty': 'No recent batch jobs',
'nanaAssistant.batchPanel.viewAll': 'View all',

View File

@ -1112,8 +1112,6 @@ export default {
'nanaAssistant.menu.advisorHint': '查价、轨迹、人工咨询',
'nanaAssistant.menu.batchJobs': '批量任务',
'nanaAssistant.menu.batchHint': '查看进度与结果',
'nanaAssistant.menu.inboxHint': '点对点站内信',
'nanaAssistant.menu.noticeHint': '系统公告通知',
'nanaAssistant.batchPanel.title': '批量任务',
'nanaAssistant.batchPanel.empty': '暂无最近批量任务',
'nanaAssistant.batchPanel.viewAll': '查看全部',

124
src/utils/directUpload.ts Normal file
View File

@ -0,0 +1,124 @@
import {
getFileAccessUrl,
getFileStorageCapabilities,
presignFileUpload,
type FileStorageFolder,
} from '@/api/fileStorage'
export type DirectUploadResult = {
/** 业务落库用 key,如 uploads/sku/202609/xxx.png */
key: string
/** 当前预览用 URL(blob 或临时签名) */
previewUrl: string
}
type FallbackUpload = () => Promise<DirectUploadResult>
let cachedSupportsDirect: boolean | null = null
let capsInflight: Promise<boolean> | null = null
async function supportsClientDirectUpload(): Promise<boolean> {
if (cachedSupportsDirect != null) return cachedSupportsDirect
if (capsInflight) return capsInflight
capsInflight = (async () => {
try {
const caps = await getFileStorageCapabilities()
cachedSupportsDirect = Boolean(caps.supportsClientDirectUpload)
} catch {
cachedSupportsDirect = false
} finally {
capsInflight = null
}
return cachedSupportsDirect!
})()
return capsInflight
}
/** 测试/切换环境时可清缓存 */
export function resetDirectUploadCapabilityCache() {
cachedSupportsDirect = null
}
/**
* 对预签名 URL 执行 PUT(不要带业务 Authorization,Content-Type 须与签发一致)。
*/
export async function putFileToPresignedUrl(
uploadUrl: string,
file: Blob,
options?: {
contentType?: string | null
headers?: Record<string, string>
},
): Promise<void> {
const headers: Record<string, string> = {
...(options?.headers || {}),
}
const contentType = options?.contentType || (file instanceof File ? file.type : '') || ''
if (contentType && !headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = contentType
}
const res = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(
text?.trim()
? `直传失败(${res.status}): ${text.trim().slice(0, 200)}`
: `直传失败: HTTP ${res.status}`,
)
}
}
/**
* 推荐流程:capabilities → presign-upload → PUT → 落库只存 key。
* Cos 不可用时走 fallback(服务端 multipart)。
*/
export async function uploadFileDirect(options: {
folder: FileStorageFolder | string
file: File
fallback: FallbackUpload
/** 直传成功后是否再换临时读地址;默认用本地 blob 预览 */
resolveAccessUrl?: boolean
}): Promise<DirectUploadResult> {
const { folder, file, fallback } = options
const canDirect = await supportsClientDirectUpload()
if (!canDirect) {
return fallback()
}
try {
const contentType = file.type || 'application/octet-stream'
const ticket = await presignFileUpload({
folder,
fileName: file.name || 'file.bin',
contentType,
})
await putFileToPresignedUrl(ticket.uploadUrl, file, {
contentType: ticket.contentType || contentType,
headers: ticket.headers,
})
let previewUrl = URL.createObjectURL(file)
if (options.resolveAccessUrl) {
try {
const access = await getFileAccessUrl(ticket.key)
if (access.url) {
URL.revokeObjectURL(previewUrl)
previewUrl = access.url
}
} catch {
// 预览仍用 blob
}
}
return { key: ticket.key, previewUrl }
} catch (err) {
// 直传失败时回落 multipart,避免阻断业务
console.warn('[directUpload] presign/PUT failed, fallback to server upload', err)
return fallback()
}
}

View File

@ -2143,13 +2143,16 @@ const loadChannelGroups = async () => {
channelGroupsLoadingPromise = (async () => {
try {
let groupsRaw: any[] = []
let groupListFailed = false
try {
groupsRaw = await getServiceChannelGroupList({})
} catch (e) {
console.error('加载服务商分组列表失败:', e)
groupListFailed = true
groupsRaw = []
}
if (!Array.isArray(groupsRaw) || groupsRaw.length === 0) {
// 仅在接口失败时用兜底,避免「保存成功但列表解析为空」被假数据盖回
if (groupListFailed && (!Array.isArray(groupsRaw) || groupsRaw.length === 0)) {
groupsRaw = [...FALLBACK_SERVICE_CHANNEL_GROUP_LIST]
}

View File

@ -26,7 +26,6 @@
</div>
</div>
<div v-if="accountError" class="nova-login__field-error">{{ accountError }}</div>
<div v-else-if="isCustomerLogin" class="nova-login__field-hint">{{ t('login.form.account.hint.customer') }}</div>
</div>
<div class="nova-login__input-group">
@ -547,13 +546,6 @@ async function handleSubmit() {
color: #b91c1c;
}
.nova-login__field-hint {
margin-top: 4px;
font-size: 12px;
line-height: 1.4;
color: rgba(148, 163, 184, 0.92);
}
.nova-login__error-box {
padding: 11px 14px;
font-size: 13px;

View File

@ -16,9 +16,9 @@
'login.form.login': 'Login',
'login.form.brandHint.customer': 'Welcome to your IDE portal',
'login.form.brandHint.admin': 'Welcome to IDE Operations Console',
'login.switchToWechat': 'Sign in with WeChat',
'login.switchToWechat': 'Sign in / register with WeChat',
'login.switchToAccount': 'Account and password',
'login.wechat.tips': 'Scan with WeChat to sign in. QR code valid for 10 minutes.',
'login.wechat.tips': 'Scan with WeChat to sign in / register. QR code valid for 10 minutes.',
'login.wechat.scanned': 'Scanned. Signing you in…',
'login.wechat.qrValid': 'QR code valid for 10 minutes',
'login.wechat.scanPending': 'Waiting for scan…',

View File

@ -16,9 +16,9 @@
'login.form.login': '登录',
'login.form.brandHint.customer': '欢迎登录 IDE 客户门户',
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
'login.switchToWechat': '微信扫码登录',
'login.switchToWechat': '微信扫码登录/注册',
'login.switchToAccount': '账号密码登录',
'login.wechat.tips': '请使用微信扫一扫登录,二维码 10 分钟内有效',
'login.wechat.tips': '请使用微信扫一扫登录/注册,二维码 10 分钟内有效',
'login.wechat.scanned': '扫码成功,正在登录…',
'login.wechat.qrValid': '二维码十分钟有效',
'login.wechat.scanPending': '等待扫码中…',

View File

@ -152,15 +152,12 @@ function statusLabel(code: string) {
return code || '-'
}
function actionRenderer(instance: any, td: HTMLElement, row: number) {
const record = tableRows.value[row]
if (!record) {
td.innerHTML = ''
return td
}
function actionRenderer(rowData: any, rowIndex?: number) {
const record = rowData || tableRows.value[rowIndex ?? -1]
if (!record) return ''
const status = String(record.status || '')
const buttons = [
renderWarehouseActionBtn('detail', t('warehouse.common.detail'), 'detail'),
renderWarehouseActionBtn('detail', t('warehouse.common.detail'), 'view'),
]
if (status === 'pending') {
if (hasPermission('Order.Customs9610.Update')) {
@ -170,7 +167,7 @@ function actionRenderer(instance: any, td: HTMLElement, row: number) {
buttons.push(renderWarehouseActionBtn('declare', t('order.customs9610.actions.declare'), 'primary'))
}
}
return wrapWarehouseActions(td, buttons)
return wrapWarehouseActions(buttons, rowIndex)
}
function handleSelectionChange(rows: any[]) {
@ -306,14 +303,14 @@ async function openDetail(id: number, editable: boolean) {
}
}
function handleRowAction(payload: { action: string; row: number }) {
const record = tableRows.value[payload.row]
function handleRowAction(row: any, rowIndex: number, action?: string) {
const record = row || tableRows.value[rowIndex]
if (!record?.id) return
if (payload.action === 'detail') {
if (action === 'detail') {
openDetail(record.id, false)
} else if (payload.action === 'edit') {
} else if (action === 'edit') {
openDetail(record.id, true)
} else if (payload.action === 'declare') {
} else if (action === 'declare') {
handleDeclare(record.id)
}
}

View File

@ -47,7 +47,10 @@ defineOptions({ name: 'WarehouseParcel' })
interface ParcelItemRow {
productName: string
attributeText: string
/** 落库 key(或详情返回的临时 URL,保存时后端会 ToStorageKey) */
imageUrl: string
/** 直传后的本地预览 blob / 详情临时 URL */
imagePreviewUrl?: string
quantity: number
remark: string
}
@ -187,8 +190,12 @@ function renderDetailCell(row: any, value: any) {
return `<span class="parcel-detail-cell"${titleAttr} style="cursor:pointer;color:var(--app-primary-color);">${escapeHtml(text)}</span>`
}
function resolveItemImageSrc(url?: string | null) {
return resolveOmsAssetUrl(url) || String(url || '').trim()
function resolveItemImageSrc(record: ParcelItemRow | string | null | undefined) {
if (record && typeof record === 'object') {
if (record.imagePreviewUrl) return record.imagePreviewUrl
return resolveOmsAssetUrl(record.imageUrl) || String(record.imageUrl || '').trim()
}
return resolveOmsAssetUrl(record) || String(record || '').trim()
}
/** 列表无图 / 加载失败时的缺省图片 icon(data URI):用中性灰,日夜间都可 */
@ -308,12 +315,25 @@ async function handleItemImageUpload(rowIndex: number, options: any) {
options?.onError?.(new Error('too large'))
return
}
const mutationCustomerId = isAdmin.value ? ensureMutationCustomerId() : undefined
if (isAdmin.value && mutationCustomerId == null) {
options?.onError?.(new Error('customer required'))
return
}
imageUploadingIndex.value = rowIndex
try {
const url = await uploadWarehouseParcelImage(file as File)
const { key, previewUrl } = await uploadWarehouseParcelImage(file as File, {
customerId: mutationCustomerId,
})
const row = itemRows.value[rowIndex]
if (row) row.imageUrl = url
options?.onSuccess?.(url)
if (row) {
if (row.imagePreviewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(row.imagePreviewUrl)
}
row.imageUrl = key
row.imagePreviewUrl = previewUrl
}
options?.onSuccess?.(key)
$baseMessage(t('warehouse.parcel.message.uploadSuccess'), 'success')
} catch (e: any) {
options?.onError?.(e)
@ -325,7 +345,13 @@ async function handleItemImageUpload(rowIndex: number, options: any) {
function clearItemImage(rowIndex: number) {
const row = itemRows.value[rowIndex]
if (row) row.imageUrl = ''
if (row) {
if (row.imagePreviewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(row.imagePreviewUrl)
}
row.imageUrl = ''
row.imagePreviewUrl = undefined
}
}
function handleEditFromDetail() {
@ -509,6 +535,10 @@ function handleEdit(row: any) {
productName: String(pickField(item, 'productName', 'ProductName') ?? ''),
attributeText: String(pickField(item, 'attributeText', 'AttributeText') ?? ''),
imageUrl: String(pickField(item, 'imageUrl', 'ImageUrl') ?? ''),
imagePreviewUrl: (() => {
const u = String(pickField(item, 'imageUrl', 'ImageUrl') ?? '')
return /^https?:\/\//i.test(u) ? u : undefined
})(),
quantity: Number(pickField(item, 'quantity', 'Quantity') ?? 1) || 1,
remark: String(pickField(item, 'remark', 'Remark') ?? ''),
}))
@ -697,7 +727,7 @@ onMounted(async () => {
<template #cell="{ record }">
<a-image
v-if="record.imageUrl"
:src="resolveItemImageSrc(record.imageUrl)"
:src="resolveItemImageSrc(record)"
width="36"
height="36"
fit="cover"
@ -831,7 +861,7 @@ onMounted(async () => {
<div class="parcel-item-image-cell">
<a-image
v-if="record.imageUrl"
:src="resolveItemImageSrc(record.imageUrl)"
:src="resolveItemImageSrc(record)"
width="36"
height="36"
fit="cover"

View File

@ -55,8 +55,6 @@ const headerSearchParams = reactive<Record<string, string>>({})
const SKU_SERVER_FILTER_KEYS = ['skuCode', 'skuName', 'skuNameEn', 'customerName', 'customerCode', 'remark'] as const
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
const selectedWarehouseId = ref<number>()
const imageUploading = ref(false)
const skuImageFileList = ref<any[]>([])
const [registerFormModal, { openModal, closeModal }] = useModal() as any
const saving = ref(false)
@ -88,7 +86,14 @@ const skuVolumeText = computed(() => {
return `${(l * w * h).toFixed(2)} cm³`
})
const skuImagePreview = computed(() => resolveOmsAssetUrl(formModel.skuImageUrl))
const skuImageFileList = ref<any[]>([])
const imageUploading = ref(false)
/** 直传后的本地预览(blob);详情回填时用接口已签好的 URL */
const skuImagePreviewOverride = ref('')
const skuImagePreview = computed(
() => skuImagePreviewOverride.value || resolveOmsAssetUrl(formModel.skuImageUrl),
)
const currentWarehouseLabel = computed(() => {
const id = currentWarehouseStore.resolveDefaultWarehouseId(selectedWarehouseId.value)
@ -135,6 +140,10 @@ function resetForm() {
formModel.widthCm = undefined
formModel.heightCm = undefined
formModel.remark = ''
if (skuImagePreviewOverride.value.startsWith('blob:')) {
URL.revokeObjectURL(skuImagePreviewOverride.value)
}
skuImagePreviewOverride.value = ''
skuImageFileList.value = []
}
@ -149,12 +158,16 @@ function clearSkuSize() {
}
function clearSkuImage() {
if (skuImagePreviewOverride.value.startsWith('blob:')) {
URL.revokeObjectURL(skuImagePreviewOverride.value)
}
skuImagePreviewOverride.value = ''
formModel.skuImageUrl = ''
skuImageFileList.value = []
}
function syncImageFileList() {
if (!formModel.skuImageUrl) {
if (!formModel.skuImageUrl && !skuImagePreviewOverride.value) {
skuImageFileList.value = []
return
}
@ -178,12 +191,23 @@ async function handleSkuImageUpload(options: any) {
options?.onError?.(new Error('too large'))
return
}
const mutationCustomerId = isAdmin.value ? ensureMutationCustomerId() : undefined
if (isAdmin.value && mutationCustomerId == null) {
options?.onError?.(new Error('customer required'))
return
}
imageUploading.value = true
try {
const url = await uploadWarehouseSkuImage(file as File)
formModel.skuImageUrl = url
const { key, previewUrl } = await uploadWarehouseSkuImage(file as File, {
customerId: mutationCustomerId,
})
if (skuImagePreviewOverride.value.startsWith('blob:')) {
URL.revokeObjectURL(skuImagePreviewOverride.value)
}
formModel.skuImageUrl = key
skuImagePreviewOverride.value = previewUrl
syncImageFileList()
options?.onSuccess?.(url)
options?.onSuccess?.(key)
$baseMessage(t('warehouse.sku.message.uploadSuccess'), 'success')
} catch (e: any) {
options?.onError?.(e)
@ -295,6 +319,13 @@ function handleEdit(row: any) {
formModel.widthCm = pickField<number>(raw, 'widthCm', 'WidthCm')
formModel.heightCm = pickField<number>(raw, 'heightCm', 'HeightCm')
formModel.remark = String(pickField(raw, 'remark', 'Remark') ?? '')
if (skuImagePreviewOverride.value.startsWith('blob:')) {
URL.revokeObjectURL(skuImagePreviewOverride.value)
}
// 详情接口已返回临时访问 URL,直接用作预览
skuImagePreviewOverride.value = /^https?:\/\//i.test(formModel.skuImageUrl)
? formModel.skuImageUrl
: ''
syncImageFileList()
openModal(true)
}

View File

@ -209,6 +209,11 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: false,
},
'/file-storage': {
target: omsAdminTarget,
changeOrigin: true,
secure: false,
},
'/marketing': {
target: omsAdminTarget,
changeOrigin: true,