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',
|
welcomeTitle: 'IDE, your cross-border logistics advisor',
|
||||||
welcomeTitleShort: 'IDE · Your logistics advisor',
|
welcomeTitleShort: 'IDE · Your logistics advisor',
|
||||||
welcomeSubLead: 'Preferential rates · proactive service · faster transit · ',
|
welcomeSubLead: 'Preferential rates · proactive service · faster transit · ',
|
||||||
welcomeSubAccent: 'AI-powered logistics',
|
welcomeSubAccent: 'AI logistics',
|
||||||
welcomeSubTail: ' · dedicated lines, packets, air & express covered.',
|
welcomeSubTail: ' · dedicated lines, packets, air & DHL/UPS/FedEX/3PE express covered.',
|
||||||
placeholder: 'Ask about lanes, tracking, or 9610…',
|
placeholder: 'Ask about lanes, tracking, or 9610…',
|
||||||
sendLabel: 'Ask',
|
sendLabel: 'Ask',
|
||||||
human: 'Talk to a human advisor',
|
human: 'Talk to a human advisor',
|
||||||
@ -89,8 +89,8 @@
|
|||||||
welcomeTitle: 'IDE,您身边的跨境物流顾问',
|
welcomeTitle: 'IDE,您身边的跨境物流顾问',
|
||||||
welcomeTitleShort: 'IDE · 跨境物流顾问',
|
welcomeTitleShort: 'IDE · 跨境物流顾问',
|
||||||
welcomeSubLead: '价格更优惠 · 服务更主动 · 时效更快 · ',
|
welcomeSubLead: '价格更优惠 · 服务更主动 · 时效更快 · ',
|
||||||
welcomeSubAccent: 'AI 助力物流',
|
welcomeSubAccent: 'AI 物流',
|
||||||
welcomeSubTail: ' · 专线/小包/空派/快递全覆盖。',
|
welcomeSubTail: ' · 专线/小包/空派/DHL/UPS/FedEX/3PE快递全覆盖。',
|
||||||
placeholder: '问线路、查轨迹、9610 政策…',
|
placeholder: '问线路、查轨迹、9610 政策…',
|
||||||
sendLabel: '提问',
|
sendLabel: '提问',
|
||||||
human: '联系在线顾问',
|
human: '联系在线顾问',
|
||||||
@ -637,7 +637,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerUrl(dest) {
|
function registerUrl(dest) {
|
||||||
const url = new URL(omsUrl(preferOmsH5() ? '/wechat/register' : '/register'));
|
// 官网「注册询价」走客户登录页微信扫码(注册/登录同一入口)
|
||||||
|
const url = new URL(loginPortalUrl());
|
||||||
url.searchParams.set('from', 'site');
|
url.searchParams.set('from', 'site');
|
||||||
if (dest) url.searchParams.set('dest', String(dest));
|
if (dest) url.searchParams.set('dest', String(dest));
|
||||||
return url.toString();
|
return url.toString();
|
||||||
|
|||||||
@ -20,10 +20,16 @@ export async function getServiceChannelGroupList(params?: {
|
|||||||
apiServiceProviderId?: number
|
apiServiceProviderId?: number
|
||||||
}) {
|
}) {
|
||||||
const res = await axiosInstance.get(omsUrl('/channel/service-channel/group/list'), { params })
|
const res = await axiosInstance.get(omsUrl('/channel/service-channel/group/list'), { params })
|
||||||
assertBusinessOk(res.data)
|
const body = res.data
|
||||||
const raw = res.data?.data ?? res.data?.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 (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 []
|
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 { axiosInstance } from '@/api/rbac'
|
||||||
import { assertBusinessOk, omsUrl, parseListPayload, unwrapData } from '@/api/oms/common'
|
import { assertBusinessOk, omsUrl, parseListPayload, unwrapData } from '@/api/oms/common'
|
||||||
|
import { uploadFileDirect } from '@/utils/directUpload'
|
||||||
|
|
||||||
function postList(path: string, body: Record<string, unknown>) {
|
function postList(path: string, body: Record<string, unknown>) {
|
||||||
return axiosInstance.post(omsUrl(path), body).then((res) => parseListPayload(res.data))
|
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>) =>
|
export const bindWarehouseSkuMapping = (body: Record<string, unknown>) =>
|
||||||
postOk('/warehouse/sku/bind-mapping', body)
|
postOk('/warehouse/sku/bind-mapping', body)
|
||||||
|
|
||||||
/** 上传 SKU 图片,返回相对路径 url */
|
/** 上传 SKU 图片:优先 COS 直传,落库存 key;返回 key + 预览 URL */
|
||||||
export async function uploadWarehouseSkuImage(file: File): Promise<string> {
|
export async function uploadWarehouseSkuImage(
|
||||||
const form = new FormData()
|
file: File,
|
||||||
form.append('file', file)
|
options?: { customerId?: number },
|
||||||
const res = await axiosInstance.post(omsUrl('/warehouse/sku/upload-image'), form)
|
): Promise<{ key: string; previewUrl: string }> {
|
||||||
assertBusinessOk(res.data)
|
return uploadFileDirect({
|
||||||
const data = unwrapData<any>(res.data)
|
folder: 'sku',
|
||||||
const url = data?.url ?? data?.fileUrl ?? data?.Url ?? data?.FileUrl
|
file,
|
||||||
if (!url) throw new Error('上传成功但未返回图片地址')
|
fallback: async () => {
|
||||||
return String(url)
|
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<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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 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 deleteWarehouseParcel = (id: number) => postOk('/warehouse/parcel/delete', { id })
|
||||||
export const putawayWarehouseParcel = (body: Record<string, unknown>) => postOk('/warehouse/parcel/putaway', body)
|
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(快递到仓)
|
// Parcel arrival(快递到仓)
|
||||||
export const getWarehouseParcelArrivalList = (body: Record<string, unknown>) =>
|
export const getWarehouseParcelArrivalList = (body: Record<string, unknown>) =>
|
||||||
postList('/warehouse/parcel-arrival/list', body)
|
postList('/warehouse/parcel-arrival/list', body)
|
||||||
|
|||||||
@ -37,14 +37,7 @@
|
|||||||
:style="{ strokeDashoffset: ringOffset }"
|
:style="{ strokeDashoffset: ringOffset }"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<NanaAvatar size="lg" :pulse="hasActiveJobs || messageTotal > 0" />
|
<NanaAvatar size="lg" :pulse="hasActiveJobs" />
|
||||||
<span
|
|
||||||
v-if="fabBadgeText"
|
|
||||||
class="nana-fab-badge"
|
|
||||||
:class="{ 'is-message': !hasActiveJobs && messageTotal > 0 }"
|
|
||||||
>
|
|
||||||
{{ fabBadgeText }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Transition name="nana-fab-menu">
|
<Transition name="nana-fab-menu">
|
||||||
<div
|
<div
|
||||||
@ -76,30 +69,6 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="nana-fab-menu__hint">{{ t('nanaAssistant.menu.batchHint') }}</span>
|
<span class="nana-fab-menu__hint">{{ t('nanaAssistant.menu.batchHint') }}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@ -113,7 +82,6 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { getOrderBatchJobList } from '@/api/order';
|
import { getOrderBatchJobList } from '@/api/order';
|
||||||
import { useInboxUnread } from '@/composables/useInboxUnread';
|
|
||||||
import { useNanaBatchPanel } from '@/composables/useNanaBatchPanel';
|
import { useNanaBatchPanel } from '@/composables/useNanaBatchPanel';
|
||||||
import { BATCH_JOB_TERMINAL_STATUSES } from '@/constants/orderBatchAction';
|
import { BATCH_JOB_TERMINAL_STATUSES } from '@/constants/orderBatchAction';
|
||||||
import NanaAvatar from './NanaAvatar.vue';
|
import NanaAvatar from './NanaAvatar.vue';
|
||||||
@ -133,27 +101,13 @@ const {
|
|||||||
hasActiveJobs,
|
hasActiveJobs,
|
||||||
ringPercent,
|
ringPercent,
|
||||||
attentionToken,
|
attentionToken,
|
||||||
|
triggerAttention,
|
||||||
setActiveJobCount,
|
setActiveJobCount,
|
||||||
setRingPercent,
|
setRingPercent,
|
||||||
setFabAnchor,
|
setFabAnchor,
|
||||||
visible: batchPanelVisible,
|
visible: batchPanelVisible,
|
||||||
} = useNanaBatchPanel();
|
} = 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 fabRef = ref<HTMLElement>();
|
||||||
const nanaX = ref(0);
|
const nanaX = ref(0);
|
||||||
const nanaY = ref(0);
|
const nanaY = ref(0);
|
||||||
@ -163,6 +117,7 @@ const attentionActive = ref(false);
|
|||||||
|
|
||||||
const RING_R = 32;
|
const RING_R = 32;
|
||||||
const RING_C = 2 * Math.PI * RING_R;
|
const RING_C = 2 * Math.PI * RING_R;
|
||||||
|
const JOB_POLL_MS = 3000;
|
||||||
|
|
||||||
const ringOffset = computed(() => {
|
const ringOffset = computed(() => {
|
||||||
const p = Math.min(100, Math.max(0, ringPercent.value)) / 100;
|
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 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({
|
const dragState = ref({
|
||||||
active: false,
|
active: false,
|
||||||
@ -254,9 +213,7 @@ function onFabActivate() {
|
|||||||
closeBatchPanel();
|
closeBatchPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = !menuOpen.value;
|
menuOpen.value = !menuOpen.value;
|
||||||
menuOpen.value = next;
|
|
||||||
if (next) refreshUnread();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFabPointerUp() {
|
function onFabPointerUp() {
|
||||||
@ -281,16 +238,6 @@ function openBatchPanel() {
|
|||||||
void openBatchJobsPanel(null, { attention: true, delayMs: 160 });
|
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() {
|
function playAttention() {
|
||||||
attentionActive.value = false;
|
attentionActive.value = false;
|
||||||
// restart CSS animation
|
// restart CSS animation
|
||||||
@ -320,6 +267,10 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
if (menuOpen.value) menuOpen.value = false;
|
if (menuOpen.value) menuOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isJobTerminal(status: string) {
|
||||||
|
return BATCH_JOB_TERMINAL_STATUSES.has(String(status || '').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
function jobProgressPercent(job: {
|
function jobProgressPercent(job: {
|
||||||
total: number;
|
total: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
@ -331,18 +282,56 @@ function jobProgressPercent(job: {
|
|||||||
return Math.min(100, Math.round((finished / job.total) * 100));
|
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 (isAssistantPage.value) return;
|
||||||
|
if (batchPanelVisible.value && !options?.quiet) return;
|
||||||
try {
|
try {
|
||||||
const { items } = await getOrderBatchJobList({ page: 1, pageSize: 10 });
|
const { items } = await getOrderBatchJobList({ page: 1, pageSize: 10 });
|
||||||
const list = items || [];
|
const list = items || [];
|
||||||
const active = list.filter(
|
const active = list.filter((j) => !isJobTerminal(j.status));
|
||||||
(j) => !BATCH_JOB_TERMINAL_STATUSES.has(String(j.status || '').toLowerCase()),
|
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);
|
setActiveJobCount(active.length);
|
||||||
setRingPercent(active.length ? jobProgressPercent(active[0]) : 0);
|
setRingPercent(active.length ? jobProgressPercent(active[0]) : 0);
|
||||||
|
syncJobPoll();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore badge errors
|
// ignore job status errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,11 +339,24 @@ watch(isAssistantPage, (v) => {
|
|||||||
if (v) {
|
if (v) {
|
||||||
menuOpen.value = false;
|
menuOpen.value = false;
|
||||||
closeBatchPanel();
|
closeBatchPanel();
|
||||||
|
stopJobPoll();
|
||||||
|
} else {
|
||||||
|
void refreshActiveJobs({ quiet: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(batchPanelVisible, (open) => {
|
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, () => {
|
watch(attentionToken, () => {
|
||||||
@ -366,13 +368,14 @@ onMounted(() => {
|
|||||||
window.addEventListener('resize', onViewportResize);
|
window.addEventListener('resize', onViewportResize);
|
||||||
document.addEventListener('pointerdown', onDocPointerDown, true);
|
document.addEventListener('pointerdown', onDocPointerDown, true);
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
void refreshActiveBadge();
|
void refreshActiveJobs({ quiet: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', onViewportResize);
|
window.removeEventListener('resize', onViewportResize);
|
||||||
document.removeEventListener('pointerdown', onDocPointerDown, true);
|
document.removeEventListener('pointerdown', onDocPointerDown, true);
|
||||||
document.removeEventListener('keydown', onKeydown);
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
stopJobPoll();
|
||||||
if (attentionTimer) clearTimeout(attentionTimer);
|
if (attentionTimer) clearTimeout(attentionTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -433,41 +436,6 @@ onUnmounted(() => {
|
|||||||
transition: stroke-dashoffset 0.45s ease;
|
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 {
|
.nana-fab-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: calc(100% + 10px);
|
right: calc(100% + 10px);
|
||||||
@ -557,7 +525,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.nana-fab-host.is-attention,
|
.nana-fab-host.is-attention,
|
||||||
.nana-fab-badge,
|
|
||||||
.nana-fab-menu__item,
|
.nana-fab-menu__item,
|
||||||
.nana-fab-menu-enter-active,
|
.nana-fab-menu-enter-active,
|
||||||
.nana-fab-menu-leave-active,
|
.nana-fab-menu-leave-active,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<a-dropdown trigger="hover" placement="bottomRight" @visible-change="onMessageDropdownVisibleChange">
|
||||||
<div class="message-dropdown-trigger" :title="t('navbar.message')">
|
<div class="message-dropdown-trigger" :title="t('navbar.message')">
|
||||||
<IconNotification class="message-icon" :class="totalCount > 0 ? 'theMessage' : ''" />
|
<IconNotification class="message-icon" :class="totalCount > 0 ? 'theMessage' : ''" />
|
||||||
|
|||||||
@ -204,6 +204,11 @@
|
|||||||
<ContactService variant="navbar" />
|
<ContactService variant="navbar" />
|
||||||
</div>
|
</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">
|
<div class="top-nav-right-item top-nav-right-item--icon">
|
||||||
<SelectLang />
|
<SelectLang />
|
||||||
@ -232,6 +237,7 @@ import { useTabBarStore } from '@/store';
|
|||||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||||
import TabBar from '@/components/tab-bar/index.vue';
|
import TabBar from '@/components/tab-bar/index.vue';
|
||||||
import RightTopUser from './RightTopUser.vue';
|
import RightTopUser from './RightTopUser.vue';
|
||||||
|
import RightTopMessage from './RightTopMessage.vue';
|
||||||
import SelectLang from './SelectLang.vue';
|
import SelectLang from './SelectLang.vue';
|
||||||
import ContactService from '@/components/ContactService/index.vue';
|
import ContactService from '@/components/ContactService/index.vue';
|
||||||
import { IconPrinter } from '@arco-design/web-vue/es/icon';
|
import { IconPrinter } from '@arco-design/web-vue/es/icon';
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
* | ideship(生产) | https://ideship.com | https://api.ideship.com/{admin,itfx,track} |
|
* | ideship(生产) | https://ideship.com | https://api.ideship.com/{admin,itfx,track} |
|
||||||
* | nanafa(测试) | https://oms.nanafa.com | http://api.nanafa.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(测试)
|
* 构建:pnpm build(生产) / pnpm build:test(测试)
|
||||||
*/
|
*/
|
||||||
export type DeployTarget = 'ideship' | 'nanafa'
|
export type DeployTarget = 'ideship' | 'nanafa'
|
||||||
|
|||||||
@ -1297,8 +1297,6 @@ export default {
|
|||||||
'nanaAssistant.menu.advisorHint': 'Rates, tracking, human help',
|
'nanaAssistant.menu.advisorHint': 'Rates, tracking, human help',
|
||||||
'nanaAssistant.menu.batchJobs': 'Batch jobs',
|
'nanaAssistant.menu.batchJobs': 'Batch jobs',
|
||||||
'nanaAssistant.menu.batchHint': 'Progress and results',
|
'nanaAssistant.menu.batchHint': 'Progress and results',
|
||||||
'nanaAssistant.menu.inboxHint': 'Direct inbox messages',
|
|
||||||
'nanaAssistant.menu.noticeHint': 'System announcements',
|
|
||||||
'nanaAssistant.batchPanel.title': 'Batch jobs',
|
'nanaAssistant.batchPanel.title': 'Batch jobs',
|
||||||
'nanaAssistant.batchPanel.empty': 'No recent batch jobs',
|
'nanaAssistant.batchPanel.empty': 'No recent batch jobs',
|
||||||
'nanaAssistant.batchPanel.viewAll': 'View all',
|
'nanaAssistant.batchPanel.viewAll': 'View all',
|
||||||
|
|||||||
@ -1112,8 +1112,6 @@ export default {
|
|||||||
'nanaAssistant.menu.advisorHint': '查价、轨迹、人工咨询',
|
'nanaAssistant.menu.advisorHint': '查价、轨迹、人工咨询',
|
||||||
'nanaAssistant.menu.batchJobs': '批量任务',
|
'nanaAssistant.menu.batchJobs': '批量任务',
|
||||||
'nanaAssistant.menu.batchHint': '查看进度与结果',
|
'nanaAssistant.menu.batchHint': '查看进度与结果',
|
||||||
'nanaAssistant.menu.inboxHint': '点对点站内信',
|
|
||||||
'nanaAssistant.menu.noticeHint': '系统公告通知',
|
|
||||||
'nanaAssistant.batchPanel.title': '批量任务',
|
'nanaAssistant.batchPanel.title': '批量任务',
|
||||||
'nanaAssistant.batchPanel.empty': '暂无最近批量任务',
|
'nanaAssistant.batchPanel.empty': '暂无最近批量任务',
|
||||||
'nanaAssistant.batchPanel.viewAll': '查看全部',
|
'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 () => {
|
channelGroupsLoadingPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
let groupsRaw: any[] = []
|
let groupsRaw: any[] = []
|
||||||
|
let groupListFailed = false
|
||||||
try {
|
try {
|
||||||
groupsRaw = await getServiceChannelGroupList({})
|
groupsRaw = await getServiceChannelGroupList({})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载服务商分组列表失败:', e)
|
console.error('加载服务商分组列表失败:', e)
|
||||||
|
groupListFailed = true
|
||||||
groupsRaw = []
|
groupsRaw = []
|
||||||
}
|
}
|
||||||
if (!Array.isArray(groupsRaw) || groupsRaw.length === 0) {
|
// 仅在接口失败时用兜底,避免「保存成功但列表解析为空」被假数据盖回
|
||||||
|
if (groupListFailed && (!Array.isArray(groupsRaw) || groupsRaw.length === 0)) {
|
||||||
groupsRaw = [...FALLBACK_SERVICE_CHANNEL_GROUP_LIST]
|
groupsRaw = [...FALLBACK_SERVICE_CHANNEL_GROUP_LIST]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="accountError" class="nova-login__field-error">{{ accountError }}</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>
|
||||||
|
|
||||||
<div class="nova-login__input-group">
|
<div class="nova-login__input-group">
|
||||||
@ -547,13 +546,6 @@ async function handleSubmit() {
|
|||||||
color: #b91c1c;
|
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 {
|
.nova-login__error-box {
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@ -16,9 +16,9 @@
|
|||||||
'login.form.login': 'Login',
|
'login.form.login': 'Login',
|
||||||
'login.form.brandHint.customer': 'Welcome to your IDE portal',
|
'login.form.brandHint.customer': 'Welcome to your IDE portal',
|
||||||
'login.form.brandHint.admin': 'Welcome to IDE Operations Console',
|
'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.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.scanned': 'Scanned. Signing you in…',
|
||||||
'login.wechat.qrValid': 'QR code valid for 10 minutes',
|
'login.wechat.qrValid': 'QR code valid for 10 minutes',
|
||||||
'login.wechat.scanPending': 'Waiting for scan…',
|
'login.wechat.scanPending': 'Waiting for scan…',
|
||||||
|
|||||||
@ -16,9 +16,9 @@
|
|||||||
'login.form.login': '登录',
|
'login.form.login': '登录',
|
||||||
'login.form.brandHint.customer': '欢迎登录 IDE 客户门户',
|
'login.form.brandHint.customer': '欢迎登录 IDE 客户门户',
|
||||||
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
|
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
|
||||||
'login.switchToWechat': '微信扫码登录',
|
'login.switchToWechat': '微信扫码登录/注册',
|
||||||
'login.switchToAccount': '账号密码登录',
|
'login.switchToAccount': '账号密码登录',
|
||||||
'login.wechat.tips': '请使用微信扫一扫登录,二维码 10 分钟内有效',
|
'login.wechat.tips': '请使用微信扫一扫登录/注册,二维码 10 分钟内有效',
|
||||||
'login.wechat.scanned': '扫码成功,正在登录…',
|
'login.wechat.scanned': '扫码成功,正在登录…',
|
||||||
'login.wechat.qrValid': '二维码十分钟有效',
|
'login.wechat.qrValid': '二维码十分钟有效',
|
||||||
'login.wechat.scanPending': '等待扫码中…',
|
'login.wechat.scanPending': '等待扫码中…',
|
||||||
|
|||||||
@ -152,15 +152,12 @@ function statusLabel(code: string) {
|
|||||||
return code || '-'
|
return code || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionRenderer(instance: any, td: HTMLElement, row: number) {
|
function actionRenderer(rowData: any, rowIndex?: number) {
|
||||||
const record = tableRows.value[row]
|
const record = rowData || tableRows.value[rowIndex ?? -1]
|
||||||
if (!record) {
|
if (!record) return ''
|
||||||
td.innerHTML = ''
|
|
||||||
return td
|
|
||||||
}
|
|
||||||
const status = String(record.status || '')
|
const status = String(record.status || '')
|
||||||
const buttons = [
|
const buttons = [
|
||||||
renderWarehouseActionBtn('detail', t('warehouse.common.detail'), 'detail'),
|
renderWarehouseActionBtn('detail', t('warehouse.common.detail'), 'view'),
|
||||||
]
|
]
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
if (hasPermission('Order.Customs9610.Update')) {
|
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'))
|
buttons.push(renderWarehouseActionBtn('declare', t('order.customs9610.actions.declare'), 'primary'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return wrapWarehouseActions(td, buttons)
|
return wrapWarehouseActions(buttons, rowIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectionChange(rows: any[]) {
|
function handleSelectionChange(rows: any[]) {
|
||||||
@ -306,14 +303,14 @@ async function openDetail(id: number, editable: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRowAction(payload: { action: string; row: number }) {
|
function handleRowAction(row: any, rowIndex: number, action?: string) {
|
||||||
const record = tableRows.value[payload.row]
|
const record = row || tableRows.value[rowIndex]
|
||||||
if (!record?.id) return
|
if (!record?.id) return
|
||||||
if (payload.action === 'detail') {
|
if (action === 'detail') {
|
||||||
openDetail(record.id, false)
|
openDetail(record.id, false)
|
||||||
} else if (payload.action === 'edit') {
|
} else if (action === 'edit') {
|
||||||
openDetail(record.id, true)
|
openDetail(record.id, true)
|
||||||
} else if (payload.action === 'declare') {
|
} else if (action === 'declare') {
|
||||||
handleDeclare(record.id)
|
handleDeclare(record.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,10 @@ defineOptions({ name: 'WarehouseParcel' })
|
|||||||
interface ParcelItemRow {
|
interface ParcelItemRow {
|
||||||
productName: string
|
productName: string
|
||||||
attributeText: string
|
attributeText: string
|
||||||
|
/** 落库 key(或详情返回的临时 URL,保存时后端会 ToStorageKey) */
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
|
/** 直传后的本地预览 blob / 详情临时 URL */
|
||||||
|
imagePreviewUrl?: string
|
||||||
quantity: number
|
quantity: number
|
||||||
remark: string
|
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>`
|
return `<span class="parcel-detail-cell"${titleAttr} style="cursor:pointer;color:var(--app-primary-color);">${escapeHtml(text)}</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveItemImageSrc(url?: string | null) {
|
function resolveItemImageSrc(record: ParcelItemRow | string | null | undefined) {
|
||||||
return resolveOmsAssetUrl(url) || String(url || '').trim()
|
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):用中性灰,日夜间都可 */
|
/** 列表无图 / 加载失败时的缺省图片 icon(data URI):用中性灰,日夜间都可 */
|
||||||
@ -308,12 +315,25 @@ async function handleItemImageUpload(rowIndex: number, options: any) {
|
|||||||
options?.onError?.(new Error('too large'))
|
options?.onError?.(new Error('too large'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const mutationCustomerId = isAdmin.value ? ensureMutationCustomerId() : undefined
|
||||||
|
if (isAdmin.value && mutationCustomerId == null) {
|
||||||
|
options?.onError?.(new Error('customer required'))
|
||||||
|
return
|
||||||
|
}
|
||||||
imageUploadingIndex.value = rowIndex
|
imageUploadingIndex.value = rowIndex
|
||||||
try {
|
try {
|
||||||
const url = await uploadWarehouseParcelImage(file as File)
|
const { key, previewUrl } = await uploadWarehouseParcelImage(file as File, {
|
||||||
|
customerId: mutationCustomerId,
|
||||||
|
})
|
||||||
const row = itemRows.value[rowIndex]
|
const row = itemRows.value[rowIndex]
|
||||||
if (row) row.imageUrl = url
|
if (row) {
|
||||||
options?.onSuccess?.(url)
|
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')
|
$baseMessage(t('warehouse.parcel.message.uploadSuccess'), 'success')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
options?.onError?.(e)
|
options?.onError?.(e)
|
||||||
@ -325,7 +345,13 @@ async function handleItemImageUpload(rowIndex: number, options: any) {
|
|||||||
|
|
||||||
function clearItemImage(rowIndex: number) {
|
function clearItemImage(rowIndex: number) {
|
||||||
const row = itemRows.value[rowIndex]
|
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() {
|
function handleEditFromDetail() {
|
||||||
@ -509,6 +535,10 @@ function handleEdit(row: any) {
|
|||||||
productName: String(pickField(item, 'productName', 'ProductName') ?? ''),
|
productName: String(pickField(item, 'productName', 'ProductName') ?? ''),
|
||||||
attributeText: String(pickField(item, 'attributeText', 'AttributeText') ?? ''),
|
attributeText: String(pickField(item, 'attributeText', 'AttributeText') ?? ''),
|
||||||
imageUrl: String(pickField(item, 'imageUrl', 'ImageUrl') ?? ''),
|
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,
|
quantity: Number(pickField(item, 'quantity', 'Quantity') ?? 1) || 1,
|
||||||
remark: String(pickField(item, 'remark', 'Remark') ?? ''),
|
remark: String(pickField(item, 'remark', 'Remark') ?? ''),
|
||||||
}))
|
}))
|
||||||
@ -697,7 +727,7 @@ onMounted(async () => {
|
|||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
<a-image
|
<a-image
|
||||||
v-if="record.imageUrl"
|
v-if="record.imageUrl"
|
||||||
:src="resolveItemImageSrc(record.imageUrl)"
|
:src="resolveItemImageSrc(record)"
|
||||||
width="36"
|
width="36"
|
||||||
height="36"
|
height="36"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
@ -831,7 +861,7 @@ onMounted(async () => {
|
|||||||
<div class="parcel-item-image-cell">
|
<div class="parcel-item-image-cell">
|
||||||
<a-image
|
<a-image
|
||||||
v-if="record.imageUrl"
|
v-if="record.imageUrl"
|
||||||
:src="resolveItemImageSrc(record.imageUrl)"
|
:src="resolveItemImageSrc(record)"
|
||||||
width="36"
|
width="36"
|
||||||
height="36"
|
height="36"
|
||||||
fit="cover"
|
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 SKU_SERVER_FILTER_KEYS = ['skuCode', 'skuName', 'skuNameEn', 'customerName', 'customerCode', 'remark'] as const
|
||||||
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
|
const warehouseOptions = ref<Array<{ label: string; value: number }>>([])
|
||||||
const selectedWarehouseId = ref<number>()
|
const selectedWarehouseId = ref<number>()
|
||||||
const imageUploading = ref(false)
|
|
||||||
const skuImageFileList = ref<any[]>([])
|
|
||||||
|
|
||||||
const [registerFormModal, { openModal, closeModal }] = useModal() as any
|
const [registerFormModal, { openModal, closeModal }] = useModal() as any
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@ -88,7 +86,14 @@ const skuVolumeText = computed(() => {
|
|||||||
return `${(l * w * h).toFixed(2)} cm³`
|
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 currentWarehouseLabel = computed(() => {
|
||||||
const id = currentWarehouseStore.resolveDefaultWarehouseId(selectedWarehouseId.value)
|
const id = currentWarehouseStore.resolveDefaultWarehouseId(selectedWarehouseId.value)
|
||||||
@ -135,6 +140,10 @@ function resetForm() {
|
|||||||
formModel.widthCm = undefined
|
formModel.widthCm = undefined
|
||||||
formModel.heightCm = undefined
|
formModel.heightCm = undefined
|
||||||
formModel.remark = ''
|
formModel.remark = ''
|
||||||
|
if (skuImagePreviewOverride.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(skuImagePreviewOverride.value)
|
||||||
|
}
|
||||||
|
skuImagePreviewOverride.value = ''
|
||||||
skuImageFileList.value = []
|
skuImageFileList.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,12 +158,16 @@ function clearSkuSize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSkuImage() {
|
function clearSkuImage() {
|
||||||
|
if (skuImagePreviewOverride.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(skuImagePreviewOverride.value)
|
||||||
|
}
|
||||||
|
skuImagePreviewOverride.value = ''
|
||||||
formModel.skuImageUrl = ''
|
formModel.skuImageUrl = ''
|
||||||
skuImageFileList.value = []
|
skuImageFileList.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncImageFileList() {
|
function syncImageFileList() {
|
||||||
if (!formModel.skuImageUrl) {
|
if (!formModel.skuImageUrl && !skuImagePreviewOverride.value) {
|
||||||
skuImageFileList.value = []
|
skuImageFileList.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -178,12 +191,23 @@ async function handleSkuImageUpload(options: any) {
|
|||||||
options?.onError?.(new Error('too large'))
|
options?.onError?.(new Error('too large'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const mutationCustomerId = isAdmin.value ? ensureMutationCustomerId() : undefined
|
||||||
|
if (isAdmin.value && mutationCustomerId == null) {
|
||||||
|
options?.onError?.(new Error('customer required'))
|
||||||
|
return
|
||||||
|
}
|
||||||
imageUploading.value = true
|
imageUploading.value = true
|
||||||
try {
|
try {
|
||||||
const url = await uploadWarehouseSkuImage(file as File)
|
const { key, previewUrl } = await uploadWarehouseSkuImage(file as File, {
|
||||||
formModel.skuImageUrl = url
|
customerId: mutationCustomerId,
|
||||||
|
})
|
||||||
|
if (skuImagePreviewOverride.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(skuImagePreviewOverride.value)
|
||||||
|
}
|
||||||
|
formModel.skuImageUrl = key
|
||||||
|
skuImagePreviewOverride.value = previewUrl
|
||||||
syncImageFileList()
|
syncImageFileList()
|
||||||
options?.onSuccess?.(url)
|
options?.onSuccess?.(key)
|
||||||
$baseMessage(t('warehouse.sku.message.uploadSuccess'), 'success')
|
$baseMessage(t('warehouse.sku.message.uploadSuccess'), 'success')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
options?.onError?.(e)
|
options?.onError?.(e)
|
||||||
@ -295,6 +319,13 @@ function handleEdit(row: any) {
|
|||||||
formModel.widthCm = pickField<number>(raw, 'widthCm', 'WidthCm')
|
formModel.widthCm = pickField<number>(raw, 'widthCm', 'WidthCm')
|
||||||
formModel.heightCm = pickField<number>(raw, 'heightCm', 'HeightCm')
|
formModel.heightCm = pickField<number>(raw, 'heightCm', 'HeightCm')
|
||||||
formModel.remark = String(pickField(raw, 'remark', 'Remark') ?? '')
|
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()
|
syncImageFileList()
|
||||||
openModal(true)
|
openModal(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -209,6 +209,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/file-storage': {
|
||||||
|
target: omsAdminTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
'/marketing': {
|
'/marketing': {
|
||||||
target: omsAdminTarget,
|
target: omsAdminTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user