feat: COS 直传、仓储上传带客户、9610 表格与 Nana 体验修复
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m28s
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:
parent
8f12a6cf65
commit
a24dcb41ff
@ -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();
|
||||
|
||||
@ -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
109
src/api/fileStorage.ts
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' : ''" />
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
124
src/utils/directUpload.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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…',
|
||||
|
||||
@ -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': '等待扫码中…',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user