feat: 用户扩展、Nana双Tab、仓库与客服入口优化

扩展用户管理字段与组织编码生成,顶部改为仓库下拉;Nana 分 AI 与专属顾问 Tab,仓库页移除客服字段,并完善扫描语音与订单相关页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-09 11:23:54 +08:00
parent e356ddb9cf
commit 6a7f8f1bcb
35 changed files with 3990 additions and 1624 deletions

View File

@ -116,6 +116,9 @@ server {
location ^~ /order/batchJobs {
try_files /index.html =200;
}
location ^~ /order/templateMapping {
try_files /index.html =200;
}
# ============ 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先)============
# 页面路径为 camelCase;API 为 kebab(如 /system/dict/list、/system/batch-email/…),勿用宽泛前缀匹配 dict

View File

@ -45,6 +45,7 @@ server {
location ^~ /order/placeOrder { try_files /index.html =200; }
location ^~ /order/myOrders { try_files /index.html =200; }
location ^~ /order/batchJobs { try_files /index.html =200; }
location ^~ /order/templateMapping { try_files /index.html =200; }
location ^~ /channel/salesProducts { try_files /index.html =200; }
location ^~ /channel/serviceChannels { try_files /index.html =200; }

View File

@ -78,6 +78,12 @@ function mapUpdateBody(id: string | number, data: Partial<CustomerDto>) {
}
}
export interface CustomerServiceAdvisorDto {
name?: string
wechatId?: string
wechatQrCode?: string
}
export const CustomerService = {
async getList(params: CustomerListParams): Promise<CustomerPagedResult> {
const res = await axiosInstance.get(omsUrl('/customer/list'), { params })
@ -89,6 +95,11 @@ export const CustomerService = {
return unwrapData<CustomerDto>(res.data)
},
async getServiceAdvisor(): Promise<CustomerServiceAdvisorDto> {
const res = await axiosInstance.get(omsUrl('/customer/service-advisor'))
return unwrapData<CustomerServiceAdvisorDto>(res.data)
},
async createSub(
data: Partial<CustomerDto> & { password?: string; permissions?: string[] }
): Promise<void> {

View File

@ -54,6 +54,16 @@ function assertBusinessOk(body: any) {
}
}
/** 兼容 data 包裹或顶层直出(camel/Pascal) */
function unwrapApiData<T = Record<string, any>>(body: any): T {
if (body == null) return {} as T
const wrapped = body.data ?? body.Data
if (wrapped !== undefined && wrapped !== null) {
return wrapped as T
}
return body as T
}
/** 取出分页载荷(兼容 data 包裹或顶层直出、items/list/records、camel/Pascal) */
function parseOrderListPayload(resBody: any): OrderListResult {
assertBusinessOk(resBody)
@ -321,7 +331,7 @@ function parseOrderBatchJob(raw: any): {
finishedAt?: string
createTime?: string
} {
const d = raw?.data ?? raw?.Data ?? raw ?? {}
const d = unwrapApiData(raw)
return {
jobId: Number(d.jobId ?? d.JobId ?? 0),
action: String(d.action ?? d.Action ?? ''),
@ -543,7 +553,8 @@ export async function getOrderBatchJobItems(
): Promise<Array<ReturnType<typeof parseOrderBatchJobItem>>> {
const res = await axiosInstance.get(orderUrl(`/order/batch/jobs/${jobId}/items`))
assertBusinessOk(res.data)
const list = res.data?.data ?? res.data?.Data ?? res.data ?? []
const payload = unwrapApiData(res.data)
const list = Array.isArray(payload) ? payload : payload.items ?? payload.Items ?? []
return (Array.isArray(list) ? list : []).map(parseOrderBatchJobItem)
}
@ -816,19 +827,57 @@ export async function getOrderTemplateList(params: {
return parseOrderListPayload(res.data)
}
/** GET /order/template/download?templateId= → Excel 文件流 */
export async function downloadOrderTemplate(templateId: number, fileName: string) {
const res = await axiosInstance.get(orderUrl('/order/template/download'), {
params: { templateId },
responseType: 'blob',
})
const blob = res.data as Blob
const ct = (res.headers['content-type'] || '').toString()
if (ct.includes('application/json')) {
const text = await blob.text()
const json = JSON.parse(text)
throw new Error(json.msg ?? json.message ?? json.Message ?? '下载失败')
/** 从 axios 响应中解析二进制下载内容 */
function resolveDownloadBlob(response: {
data?: unknown
headers?: Record<string, string | undefined>
}): { blob: Blob; contentType: string } {
const headers = response?.headers ?? {}
const contentType = String(headers['content-type'] ?? headers['Content-Type'] ?? '')
const raw = response?.data
if (raw instanceof Blob) {
return { blob: raw, contentType: contentType || raw.type }
}
if (raw instanceof ArrayBuffer) {
return {
blob: new Blob([raw], {
type: contentType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
contentType,
}
}
if (raw instanceof Uint8Array) {
return {
blob: new Blob([raw], {
type: contentType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
contentType,
}
}
if (raw && typeof raw === 'object') {
const body = raw as Record<string, unknown>
throw new Error(
String(body.msg ?? body.Msg ?? body.message ?? body.Message ?? '下载失败'),
)
}
throw new Error('下载响应无效')
}
async function parseDownloadErrorBlob(blob: Blob): Promise<never> {
const text = await blob.text()
try {
const json = JSON.parse(text)
throw new Error(String(json.msg ?? json.message ?? json.Message ?? '下载失败'))
} catch (error: unknown) {
if (error instanceof Error && error.message !== '下载失败') {
throw error
}
throw new Error(text || '下载失败')
}
}
function triggerBrowserFileDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@ -840,6 +889,146 @@ export async function downloadOrderTemplate(templateId: number, fileName: string
URL.revokeObjectURL(url)
}
/** GET /order/template/download?templateId= → Excel 文件流 */
export async function downloadOrderTemplate(templateId: number, fileName: string) {
const response = await axiosInstance.get(orderUrl('/order/template/download'), {
params: { templateId },
responseType: 'arraybuffer',
})
const { blob, contentType } = resolveDownloadBlob(response)
const ct = contentType || blob.type || ''
if (ct.includes('application/json') || ct.includes('text/json')) {
await parseDownloadErrorBlob(blob)
}
if (!blob.size) {
throw new Error('下载文件为空')
}
triggerBrowserFileDownload(blob, fileName)
}
export interface OrderTemplateFieldSchemaItem {
fieldKey: string
label: string
required: boolean
dataType: string
dictType?: string
group: string
example: string
}
export interface OrderTemplateCustomDetail {
id: number
name: string
type: string
standardTemplateKey?: string
mappingStatus?: string
sourceFileName?: string
sourceHeaders: string[]
mapping?: {
standardTemplateKey?: string
headerRowIndex?: number
dataStartRowIndex?: number
mappings?: Array<{ standardFieldKey: string; sourceColumn: string; required?: boolean }>
unmappedSourceColumns?: string[]
}
standardFields: OrderTemplateFieldSchemaItem[]
}
/** GET /order/template/field-schema */
export async function getOrderTemplateFieldSchema(templateKey?: string) {
const res = await axiosInstance.get(orderUrl('/order/template/field-schema'), {
params: { templateKey: templateKey ?? '' },
})
assertBusinessOk(res.data)
return (res.data?.data ?? res.data?.Data ?? res.data) as {
templateKey: string
templateName: string
fields: OrderTemplateFieldSchemaItem[]
}
}
/** POST /order/template/custom/upload */
export async function postOrderTemplateCustomUpload(name: string, file: File) {
const form = new FormData()
form.append('name', name)
form.append('file', file)
const res = await axiosInstance.post(orderUrl('/order/template/custom/upload'), form)
assertBusinessOk(res.data)
const d = unwrapApiData(res.data)
const templateId = Number(d.templateId ?? d.TemplateId ?? d.id ?? d.Id)
if (!Number.isFinite(templateId) || templateId <= 0) {
throw new Error('上传成功但未返回模板 ID')
}
return {
templateId,
name: String(d.name ?? d.Name ?? name),
mappingStatus: String(d.mappingStatus ?? d.MappingStatus ?? 'draft'),
sourceHeaders: (d.sourceHeaders ?? d.SourceHeaders ?? []) as string[],
}
}
/** GET /order/template/custom/detail */
export async function getOrderTemplateCustomDetail(templateId: number): Promise<OrderTemplateCustomDetail> {
const res = await axiosInstance.get(orderUrl('/order/template/custom/detail'), {
params: { templateId },
})
assertBusinessOk(res.data)
const d = unwrapApiData(res.data)
return {
id: Number(d.id ?? d.Id ?? templateId),
name: String(d.name ?? d.Name ?? ''),
type: String(d.type ?? d.Type ?? 'custom'),
standardTemplateKey: d.standardTemplateKey ?? d.StandardTemplateKey,
mappingStatus: d.mappingStatus ?? d.MappingStatus,
sourceFileName: d.sourceFileName ?? d.SourceFileName,
sourceHeaders: (d.sourceHeaders ?? d.SourceHeaders ?? []) as string[],
mapping: d.mapping ?? d.Mapping,
standardFields: (d.standardFields ?? d.StandardFields ?? []) as OrderTemplateFieldSchemaItem[],
}
}
/** POST /order/template/custom/save-mapping */
export async function postOrderTemplateSaveMapping(body: {
templateId: number
standardTemplateKey?: string
headerRowIndex?: number
dataStartRowIndex?: number
mappings: Array<{ standardFieldKey: string; sourceColumn: string }>
}) {
const templateId = Number(body.templateId)
if (!Number.isFinite(templateId) || templateId <= 0) {
throw new Error('模板 ID 无效')
}
const payload = {
TemplateId: templateId,
StandardTemplateKey: body.standardTemplateKey,
HeaderRowIndex: body.headerRowIndex ?? 1,
DataStartRowIndex: body.dataStartRowIndex ?? 2,
Mappings: body.mappings.map((item) => ({
StandardFieldKey: item.standardFieldKey,
SourceColumn: item.sourceColumn,
})),
}
const res = await axiosInstance.post(orderUrl('/order/template/custom/save-mapping'), payload)
assertBusinessOk(res.data)
const d = unwrapApiData(res.data)
return {
templateId: Number(d.templateId ?? d.TemplateId ?? templateId),
mappingStatus: String(d.mappingStatus ?? d.MappingStatus ?? 'draft'),
missingRequiredFields: (d.missingRequiredFields ?? d.MissingRequiredFields ?? []) as string[],
}
}
/** DELETE /order/template/custom/{id} */
export async function deleteOrderTemplateCustom(templateId: number) {
const res = await axiosInstance.delete(orderUrl(`/order/template/custom/${templateId}`))
const body = res?.data
if (body == null || body === '') {
return
}
assertBusinessOk(body)
}
/** POST /order/upload multipart: file + templateId */
export async function postOrderUpload(
file: File,
@ -850,7 +1039,7 @@ export async function postOrderUpload(
form.append('templateId', String(templateId))
const res = await axiosInstance.post(orderUrl('/order/upload'), form)
assertBusinessOk(res.data)
const d = res.data?.data ?? res.data?.Data ?? {}
const d = unwrapApiData(res.data)
const taskId = d.taskId ?? d.TaskId
const jobId = d.jobId ?? d.JobId
const asyncFlag = d.async ?? d.Async

View File

@ -9,9 +9,6 @@ export interface WarehouseDto {
recipient?: string
contactPhone?: string
shippingAddress?: string
servicePhone?: string
serviceWechat?: string
serviceWechatFiles?: string[]
createTime?: string
updateTime?: string
createBy?: string

2
src/components.d.ts vendored
View File

@ -19,6 +19,7 @@ declare module 'vue' {
CustomLeftMenu: typeof import('./components/CustomLeftMenu/index.vue')['default']
CustomList: typeof import('./components/CustomList/index.vue')['default']
DrawerPro: typeof import('./components/DrawerPro/index.vue')['default']
ExcelFileIcon: typeof import('./components/icons/ExcelFileIcon.vue')['default']
Footer: typeof import('./components/footer/index.vue')['default']
FormPro: typeof import('./components/FormPro/index.vue')['default']
FormWrapper: typeof import('./components/global-setting/form-wrapper.vue')['default']
@ -58,5 +59,6 @@ declare module 'vue' {
TagsAdd: typeof import('./components/TagsAdd/index.vue')['default']
Timezone: typeof import('./components/Timezone/index.vue')['default']
TopNav: typeof import('./components/top-nav/index.vue')['default']
UserProfileFields: typeof import('./components/UserProfileFields/index.vue')['default']
}
}

View File

@ -1,151 +1,22 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getWarehouseDetail, getWarehouseList, type WarehouseDto } from '@/api/system/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
const props = withDefaults(
defineProps<{
variant?: 'navbar' | 'login'
placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'
showLabel?: boolean
}>(),
{
variant: 'navbar',
placement: 'bottomLeft',
showLabel: undefined,
},
)
const { t } = useI18n()
const showLabelText = computed(() => {
if (props.showLabel != null) return props.showLabel
return props.variant === 'navbar'
})
const serviceWarehouseListLoading = ref(false)
const serviceWarehouseDetailLoading = ref(false)
const serviceWarehouseListItems = ref<WarehouseDto[]>([])
const serviceWarehouseSelectData = ref<Array<{ label: string; value: number | string }>>([])
const serviceWarehouseSelect = ref<number | string | undefined>(undefined)
const serviceWarehouseDetail = ref<WarehouseDto | null>(null)
function transitionAssetUrl(url: string) {
if (!url) return ''
if (url.indexOf('//') === -1) {
if (url.indexOf('QiNiuYunFile/') === -1) {
return `/api/upload/${url}`
}
return `/${url}`
}
return url
}
function pickServicePhone(row: WarehouseDto | Record<string, unknown> | null | undefined): string {
if (!row || typeof row !== 'object') return ''
const r = row as Record<string, unknown>
const candidates = [r.servicePhone, r.ServicePhone, r.contactPhone, r.ContactPhone]
for (const c of candidates) {
if (c != null && String(c).trim() !== '') return String(c).trim()
}
return ''
}
const displayServicePhone = computed(() => {
const id = serviceWarehouseSelect.value
const fromDetail = pickServicePhone(serviceWarehouseDetail.value)
if (fromDetail) return fromDetail
if (id == null || id === '') return ''
const listRow = serviceWarehouseListItems.value.find((w) => String(w.id) === String(id))
return pickServicePhone(listRow)
})
const displayServicePhoneForTel = computed(() =>
displayServicePhone.value.replace(/[\s\-()()]/g, ''),
)
const WECHAT_QR_PLACEHOLDER_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect fill="#f7f8fa" width="128" height="128" rx="10"/>
<g fill="#e5e6eb">
<rect x="28" y="28" width="22" height="22" rx="2"/>
<rect x="53" y="28" width="22" height="22" rx="2"/>
<rect x="78" y="28" width="22" height="22" rx="2"/>
<rect x="28" y="53" width="22" height="22" rx="2"/>
<rect x="78" y="53" width="22" height="22" rx="2"/>
<rect x="28" y="78" width="22" height="22" rx="2"/>
<rect x="53" y="78" width="22" height="22" rx="2"/>
<rect x="78" y="78" width="22" height="22" rx="2"/>
</g>
<rect x="51" y="51" width="26" height="26" rx="3" fill="#c9cdd4" opacity="0.45"/>
</svg>`,
)}`
function pickServiceWechatText(row: WarehouseDto | Record<string, unknown> | null | undefined): string {
if (!row || typeof row !== 'object') return ''
const r = row as Record<string, unknown>
const v = r.serviceWechat ?? r.ServiceWechat
return v != null && String(v).trim() !== '' ? String(v).trim() : ''
}
const displayServiceWechatText = computed(() => {
const fromDetail = pickServiceWechatText(serviceWarehouseDetail.value)
if (fromDetail) return fromDetail
const id = serviceWarehouseSelect.value
if (id == null || id === '') return ''
const listRow = serviceWarehouseListItems.value.find((w) => String(w.id) === String(id))
return pickServiceWechatText(listRow)
})
function pickFirstWechatFileUrl(detail: WarehouseDto | null, listRow: WarehouseDto | undefined): string {
const fromFiles = (files: unknown) => {
if (!Array.isArray(files) || files.length === 0) return ''
const raw = files[0]
return raw != null && String(raw).trim() !== '' ? transitionAssetUrl(String(raw)) : ''
}
const fromDetailFiles = fromFiles(detail?.serviceWechatFiles)
if (fromDetailFiles) return fromDetailFiles
return fromFiles(listRow?.serviceWechatFiles)
}
const serviceWechatQrcodeUrl = computed(() => {
const id = serviceWarehouseSelect.value
const listRow =
id == null || id === ''
? undefined
: serviceWarehouseListItems.value.find((w) => String(w.id) === String(id))
return pickFirstWechatFileUrl(serviceWarehouseDetail.value, listRow)
})
const wechatQrLoadFailed = ref(false)
watch(
() => [serviceWarehouseSelect.value, serviceWechatQrcodeUrl.value] as const,
() => {
wechatQrLoadFailed.value = false
},
)
const wechatQrDisplaySrc = computed(() => {
if (wechatQrLoadFailed.value) return WECHAT_QR_PLACEHOLDER_SVG
return serviceWechatQrcodeUrl.value || WECHAT_QR_PLACEHOLDER_SVG
})
function onWechatQrImgError() {
wechatQrLoadFailed.value = true
}
async function loadServiceWarehouseDetail(id: number | string) {
serviceWarehouseDetailLoading.value = true
try {
serviceWarehouseDetail.value = await getWarehouseDetail(id)
} catch (error) {
console.error('Failed to load warehouse detail', error)
serviceWarehouseDetail.value = null
} finally {
serviceWarehouseDetailLoading.value = false
}
}
async function loadServiceDataList() {
serviceWarehouseListLoading.value = true
@ -155,58 +26,36 @@ async function loadServiceDataList() {
pageSize: 100,
})
const list = items ?? []
serviceWarehouseListItems.value = list
serviceWarehouseSelectData.value = list
.filter((row) => row.id != null)
.map((row) => ({
label: row.warehouseName || String(row.id),
value: row.id as number | string,
}))
const firstId = serviceWarehouseSelectData.value[0]?.value
serviceWarehouseSelect.value = firstId
if (firstId == null) {
serviceWarehouseDetail.value = null
}
serviceWarehouseSelect.value = serviceWarehouseSelectData.value[0]?.value
} catch (error) {
console.error('Failed to load service warehouse list', error)
serviceWarehouseListItems.value = []
serviceWarehouseSelectData.value = []
serviceWarehouseSelect.value = undefined
serviceWarehouseDetail.value = null
} finally {
serviceWarehouseListLoading.value = false
}
}
watch(serviceWarehouseSelect, (id) => {
if (id === undefined || id === null || id === '') {
serviceWarehouseDetail.value = null
return
}
loadServiceWarehouseDetail(id)
})
onMounted(() => {
void loadServiceDataList()
})
</script>
<template>
<a-popover :placement="placement" :mouse-enter-delay="0.3">
<template #content>
<div class="contact-content p-3" style="width: 280px;">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-800">{{ t('nav.contactService.title') }}</h3>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('nav.contactService.selectWarehouse') }}
</label>
<div class="contact-service" :class="`contact-service--${variant}`">
<a-select
v-model="serviceWarehouseSelect"
style="width: 100%"
class="contact-service__select"
:loading="serviceWarehouseListLoading"
:placeholder="t('nav.contactService.selectWarehouse')"
popup-container="body"
allow-search
>
<a-option
v-for="opt in serviceWarehouseSelectData"
@ -217,166 +66,51 @@ onMounted(() => {
</a-option>
</a-select>
</div>
<div class="space-y-4">
<div v-if="serviceWarehouseDetailLoading" class="py-6 flex justify-center">
<a-spin />
</div>
<div v-else class="warehouse-detail">
<div class="mb-3">
<h4 class="text-sm font-medium text-gray-500 mb-2">
{{ t('nav.contactService.serviceWechat') }}
</h4>
<div
class="border-2 border-dashed rounded-xl mx-auto p-2 flex items-center justify-center contact-wechat-qr-wrap"
>
<img
class="contact-wechat-qr-img"
:src="wechatQrDisplaySrc"
width="128"
height="128"
alt=""
@error="onWechatQrImgError"
/>
</div>
<p
v-if="displayServiceWechatText"
class="text-gray-800 font-medium text-sm mt-2 text-center"
>
{{ displayServiceWechatText }}
</p>
</div>
<div class="mb-3">
<h4 class="text-sm font-medium text-gray-500 mb-1">
{{ t('nav.contactService.phone') }}
</h4>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-500 mr-2 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
<a
v-if="displayServicePhone"
class="text-gray-800 font-medium contact-service-phone-link"
:href="`tel:${displayServicePhoneForTel}`"
>
{{ displayServicePhone }}
</a>
<span v-else class="text-gray-800 font-medium">--</span>
</div>
</div>
</div>
</div>
<div class="text-xs text-gray-500 mt-4 pt-4 border-t border-gray-200">
<p>{{ t('nav.contactService.workingTime') }}</p>
</div>
</div>
</template>
<a-tooltip
v-if="variant === 'login' && !showLabelText"
:content="t('nav.contactService.contactUs')"
>
<a
class="contact-service-trigger"
:class="`contact-service-trigger--${variant}`"
@click.prevent
>
<i class="fa fa-weixin contact-service-trigger__icon" aria-hidden="true"></i>
<span v-if="showLabelText" class="contact-service-trigger__label">
{{ t('nav.contactService.contactUs') }}
</span>
</a>
</a-tooltip>
<a
v-else
class="contact-service-trigger"
:class="`contact-service-trigger--${variant}`"
@click.prevent
>
<i class="fa fa-weixin contact-service-trigger__icon" aria-hidden="true"></i>
<span v-if="showLabelText" class="contact-service-trigger__label">
{{ t('nav.contactService.contactUs') }}
</span>
</a>
</a-popover>
</template>
<style lang="less" scoped>
.contact-service-trigger {
.contact-service {
display: inline-flex;
align-items: center;
white-space: nowrap;
cursor: pointer;
flex-shrink: 0;
color: inherit;
text-decoration: none;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
&__icon {
font-size: 18px;
color: #22c55e;
}
&__label {
font-size: 14px;
margin-left: 4px;
&__select {
min-width: 140px;
}
&--navbar {
color: #fff;
:deep(.arco-select-view) {
border: none;
background: transparent;
box-shadow: none;
padding: 0 8px;
height: 32px;
line-height: 32px;
color: #fff;
}
:deep(.arco-select-view-value),
:deep(.arco-select-view-input),
:deep(.arco-select-view-input::placeholder) {
font-size: 14px;
color: #fff;
}
:deep(.arco-select-view-suffix),
:deep(.arco-select-view-icon),
:deep(.arco-icon) {
color: #fff;
}
}
&--login {
width: 32px;
:deep(.arco-select-view) {
min-width: 120px;
height: 32px;
justify-content: center;
border-radius: 50%;
padding: 0;
&:hover {
opacity: 1;
background: rgba(15, 23, 42, 0.06);
}
.contact-service-trigger__icon {
margin-right: 0;
border-radius: 16px;
}
}
}
.contact-service-phone-link {
color: inherit;
text-decoration: none;
cursor: pointer;
&:hover {
color: rgb(var(--arcoblue-6));
text-decoration: underline;
}
}
.contact-wechat-qr-wrap {
max-width: 168px;
background: var(--color-fill-1);
}
.contact-wechat-qr-img {
display: block;
object-fit: contain;
border-radius: 8px;
}
</style>

View File

@ -5,12 +5,8 @@
class="nana-panel"
:style="panelStyle"
>
<div class="nana-panel__header">
<span class="nana-panel__title">{{ t('nanaAssistant.title') }}</span>
<button type="button" class="nana-panel__close" :aria-label="t('nanaAssistant.close')" @click="closeChat">
<icon-close />
</button>
</div>
<a-tabs v-model:active-key="activeTab" class="nana-panel__tabs" @change="onTabChange">
<a-tab-pane key="ai" :title="t('nanaAssistant.tabAi')">
<div class="nana-chat">
<div ref="chatBoxRef" class="nana-chat__messages">
<div
@ -59,6 +55,34 @@
{{ t('nanaAssistant.noProvider') }}
</p>
</div>
</a-tab-pane>
<a-tab-pane key="advisor" :title="t('nanaAssistant.tabAdvisor')">
<div class="nana-advisor">
<div v-if="advisorLoading" class="nana-advisor__loading">
<a-spin />
</div>
<template v-else-if="advisor.name">
<div class="nana-advisor__name">{{ advisor.name }}</div>
<div class="nana-advisor__qr-wrap">
<img
v-if="advisorQrSrc"
:src="advisorQrSrc"
class="nana-advisor__qr"
alt=""
@error="onAdvisorQrError"
/>
<div v-else class="nana-advisor__qr-placeholder">{{ t('nanaAssistant.noWechatQr') }}</div>
</div>
<div v-if="advisor.wechatId" class="nana-advisor__wechat">
<span class="nana-advisor__label">{{ t('nanaAssistant.wechatId') }}</span>
<span class="nana-advisor__value">{{ advisor.wechatId }}</span>
</div>
</template>
<a-empty v-else :description="t('nanaAssistant.noAdvisor')" />
</div>
</a-tab-pane>
</a-tabs>
</div>
<div
@ -66,6 +90,10 @@
class="nana-fab-wrap"
:class="{ 'is-dragging': dragState.active && dragState.moved }"
:style="fabStyle"
:title="t('nanaAssistant.title')"
:aria-label="t('nanaAssistant.open')"
role="button"
tabindex="0"
@pointerdown="onFabPointerDown"
@pointermove="onFabPointerMove"
@pointerup="onFabPointerUp"
@ -81,22 +109,41 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconClose } from '@arco-design/web-vue/es/icon';
import { chatXhsAi, listXhsAiProviders } from '@/api/xhs';
import { CustomerService, type CustomerServiceAdvisorDto } from '@/api/customer';
import { isValidAvatarUrl } from '@/utils/avatar';
import nanaImg from '@/assets/images/nana.png';
type ChatMessage = { role: 'user' | 'assistant'; content: string };
const FAB_SIZE = 72;
const PANEL_WIDTH = 400;
const PANEL_HEIGHT = 520;
const PANEL_HEIGHT = 560;
const PANEL_GAP = 16;
const EDGE_PADDING = 8;
const DRAG_THRESHOLD = 5;
const WECHAT_QR_PLACEHOLDER_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect fill="#f7f8fa" width="128" height="128" rx="10"/>
<g fill="#e5e6eb">
<rect x="28" y="28" width="22" height="22" rx="2"/>
<rect x="53" y="28" width="22" height="22" rx="2"/>
<rect x="78" y="28" width="22" height="22" rx="2"/>
<rect x="28" y="53" width="22" height="22" rx="2"/>
<rect x="78" y="53" width="22" height="22" rx="2"/>
<rect x="28" y="78" width="22" height="22" rx="2"/>
<rect x="53" y="78" width="22" height="22" rx="2"/>
<rect x="78" y="78" width="22" height="22" rx="2"/>
</g>
<rect x="51" y="51" width="26" height="26" rx="3" fill="#c9cdd4" opacity="0.45"/>
</svg>`,
)}`;
const { t } = useI18n();
const visible = ref(false);
const activeTab = ref<'ai' | 'advisor'>('ai');
const inputText = ref('');
const chatLoading = ref(false);
const providerLoading = ref(false);
@ -109,6 +156,11 @@ const nanaX = ref(0);
const nanaY = ref(0);
const viewport = ref({ width: window.innerWidth, height: window.innerHeight });
const advisorLoading = ref(false);
const advisorLoaded = ref(false);
const advisor = ref<CustomerServiceAdvisorDto>({});
const advisorQrLoadFailed = ref(false);
const dragState = ref({
active: false,
moved: false,
@ -153,6 +205,31 @@ const panelStyle = computed(() => {
};
});
function resolveWechatQrUrl(url?: string | null): string {
if (!url?.trim()) return '';
const value = url.trim();
if (isValidAvatarUrl(value)) {
if (value.indexOf('//') === -1 && !value.startsWith('data:') && !value.startsWith('/')) {
if (value.indexOf('QiNiuYunFile/') === -1) {
return `/api/upload/${value}`;
}
return `/${value}`;
}
return value;
}
return '';
}
const advisorQrSrc = computed(() => {
if (advisorQrLoadFailed.value) return WECHAT_QR_PLACEHOLDER_SVG;
const resolved = resolveWechatQrUrl(advisor.value.wechatQrCode);
return resolved || '';
});
function onAdvisorQrError() {
advisorQrLoadFailed.value = true;
}
function clampNanaPosition() {
const maxX = viewport.value.width - FAB_SIZE - EDGE_PADDING;
const maxY = viewport.value.height - FAB_SIZE - EDGE_PADDING;
@ -190,10 +267,35 @@ function ensureGreeting() {
scrollToBottom();
}
async function loadAdvisor() {
if (advisorLoading.value) return;
advisorLoading.value = true;
advisorQrLoadFailed.value = false;
try {
advisor.value = await CustomerService.getServiceAdvisor();
advisorLoaded.value = true;
} catch {
advisor.value = {};
advisorLoaded.value = true;
} finally {
advisorLoading.value = false;
}
}
function onTabChange(key: string | number) {
if (key === 'advisor' && !advisorLoaded.value) {
void loadAdvisor();
}
}
function openChat() {
visible.value = true;
if (activeTab.value === 'ai') {
ensureGreeting();
scrollToBottom();
} else if (!advisorLoaded.value) {
void loadAdvisor();
}
}
function closeChat() {
@ -291,6 +393,13 @@ watch(
() => scrollToBottom(),
);
watch(
() => advisor.value.wechatQrCode,
() => {
advisorQrLoadFailed.value = false;
},
);
onMounted(() => {
initNanaPosition();
loadProvider();
@ -365,39 +474,25 @@ onUnmounted(() => {
overflow: hidden;
}
.nana-panel__header {
.nana-panel__tabs {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 12px 0 16px;
border-bottom: 1px solid #eef2f6;
background: #fff;
flex-shrink: 0;
}
flex-direction: column;
.nana-panel__title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
:deep(.arco-tabs-nav) {
padding: 8px 12px 0;
}
.nana-panel__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: #64748b;
cursor: pointer;
:deep(.arco-tabs-content) {
flex: 1;
min-height: 0;
padding-top: 0;
}
&:hover {
background: #f1f5f9;
color: #334155;
:deep(.arco-tabs-content-list),
:deep(.arco-tabs-pane) {
height: 100%;
}
}
@ -405,9 +500,10 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
height: 100%;
min-height: 0;
padding: 12px 16px 16px;
box-sizing: border-box;
}
.nana-chat__messages {
@ -516,4 +612,81 @@ onUnmounted(() => {
color: #94a3b8;
text-align: center;
}
.nana-advisor {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: 100%;
min-height: 0;
padding: 20px 16px 16px;
box-sizing: border-box;
overflow-y: auto;
}
.nana-advisor__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.nana-advisor__name {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 16px;
text-align: center;
}
.nana-advisor__qr-wrap {
width: 168px;
height: 168px;
border: 2px dashed #e5e6eb;
border-radius: 12px;
background: #f7f8fa;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
box-sizing: border-box;
margin-bottom: 16px;
}
.nana-advisor__qr {
width: 128px;
height: 128px;
object-fit: contain;
border-radius: 8px;
display: block;
}
.nana-advisor__qr-placeholder {
font-size: 12px;
color: #94a3b8;
text-align: center;
padding: 0 8px;
}
.nana-advisor__wechat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.nana-advisor__label {
font-size: 12px;
color: #64748b;
}
.nana-advisor__value {
font-size: 15px;
font-weight: 500;
color: #1f2937;
word-break: break-all;
text-align: center;
}
</style>

View File

@ -0,0 +1,99 @@
<template>
<div class="user-profile-fields">
<a-form-item :label="t('system.user.form.phone')" field="phone">
<a-input v-model="phone" :placeholder="t('system.user.form.phonePlaceholder')" />
</a-form-item>
<a-form-item :label="t('system.user.form.employeeNo')" field="employeeNo">
<a-input
v-model="employeeNo"
:placeholder="t('system.user.form.employeeNoPlaceholder')"
:readonly="employeeNoReadonly"
/>
</a-form-item>
<a-form-item :label="t('system.user.form.wechatId')" field="wechatId">
<a-input v-model="wechatId" :placeholder="t('system.user.form.wechatIdPlaceholder')" />
</a-form-item>
<a-form-item :label="t('system.user.form.wechatQrCode')" field="wechatQrFiles">
<a-upload
v-model:file-list="wechatQrFiles"
list-type="picture-card"
:limit="1"
accept="image/*"
:custom-request="handleWechatCustomRequest"
>
<template #upload-button>
<div class="upload-placeholder">
<icon-plus class="upload-icon" />
<span>{{ t('system.user.form.wechatQrUpload') }}</span>
</div>
</template>
</a-upload>
</a-form-item>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { $baseMessage } from '@/utils'
import { readFileAsDataUrl } from '@/utils/image-data-url'
const { t } = useI18n()
withDefaults(
defineProps<{
employeeNoReadonly?: boolean
}>(),
{
employeeNoReadonly: false,
},
)
const phone = defineModel<string>('phone', { default: '' })
const employeeNo = defineModel<string>('employeeNo', { default: '' })
const wechatId = defineModel<string>('wechatId', { default: '' })
const wechatQrFiles = defineModel<any[]>('wechatQrFiles', { default: () => [] })
async function handleWechatCustomRequest(options: any) {
const { fileItem, onSuccess, onError } = options
const file = fileItem?.file as File | undefined
if (!file) {
onError?.()
return
}
try {
const dataUrl = await readFileAsDataUrl(
file,
undefined,
t('system.user.message.wechatQrTooLarge'),
)
fileItem.url = dataUrl
fileItem.status = 'done'
fileItem.response = { url: dataUrl }
onSuccess?.({ url: dataUrl })
} catch (e: any) {
fileItem.status = 'error'
$baseMessage(e?.message || t('system.user.message.wechatQrTooLarge'), 'warning')
onError?.(e)
}
}
</script>
<style lang="less" scoped>
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8c8c8c;
font-size: 12px;
.upload-icon {
font-size: 20px;
margin-bottom: 4px;
}
}
</style>

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
}>(),
{ size: 40 },
)
</script>
<template>
<svg
class="excel-file-icon"
:width="size"
:height="size"
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M901.851653 926.484607a48.255937 48.255937 0 0 1-14.274541 34.442195 48.844737 48.844737 0 0 1-34.457555 14.279661H170.419644a48.220097 48.220097 0 0 1-34.457555-14.279661 48.844737 48.844737 0 0 1-14.279662-34.442195V48.846068a48.317377 48.317377 0 0 1 14.279662-34.467795A48.844737 48.844737 0 0 1 170.419644 0.001331h418.800096a48.742337 48.742337 0 0 1 34.662355 14.131182l263.858857 263.910057a48.844737 48.844737 0 0 1 14.110701 34.667475z"
fill="#EBECF0"
/>
<path
d="M901.851653 926.484607v48.767936a48.317377 48.317377 0 0 1-14.274541 34.467796 48.844737 48.844737 0 0 1-34.457555 14.279661H170.419644a48.844737 48.844737 0 0 1-48.788417-48.844737v-48.721856a48.204737 48.204737 0 0 0 14.279662 34.442195 48.844737 48.844737 0 0 0 34.457555 14.279661h682.648713a48.844737 48.844737 0 0 0 48.773056-48.783296z"
fill="#C1C7D0"
/>
<path
d="M24.167034 536.423034h975.153932v243.849923a48.921536 48.921536 0 0 1-48.721856 48.844736H73.011771a48.342977 48.342977 0 0 1-34.467796-14.274541 48.844737 48.844737 0 0 1-14.376941-34.570195z"
fill="#0AC905"
/>
<path
d="M121.733627 536.412794V438.856441L24.167034 536.412794z m780.118026 0l0.926719-97.556353 97.039234 97.556353z"
fill="#168E2D"
/>
<path
d="M901.851653 312.628125v6.860791h-263.833257a48.844737 48.844737 0 0 1-48.844736-48.844737V0.001331a48.732097 48.732097 0 0 1 34.662355 14.115822l264.268456 263.751337a49.459136 49.459136 0 0 1 13.701102 34.754515z"
fill="#C1C7D0"
/>
<path
d="M265.190721 764.037458H136.346088v-153.94284h126.335836v35.978193H187.382182v21.749732h63.590317v35.973073h-63.590317v24.268769h77.808539zM358.05716 720.532875l-25.098207 43.504583H276.065587l54.384569-80.322456-49.366976-73.620384h56.893366l21.754852 39.321549 22.589411-39.321549h56.888246L386.508963 683.725242l52.705212 80.317336H382.320809zM599.843726 668.662222h-47.682498q-3.353596-24.268768-27.612124-25.103327-27.612124 0.839679-28.446683 45.178821 0 41.845706 30.120921 41.830345 22.584291 0 25.937886-25.932766h48.522177q-8.376309 59.417523-73.625505 61.91096-78.648218-2.508797-81.151894-79.482777 4.177915-76.124061 76.96886-81.151894 69.43735 0.839679 76.96886 62.750638zM752.112328 764.037458h-128.844633v-153.94284h126.335836v35.978193H674.303789v21.749732h63.590317v35.973073H674.303789v24.268769h77.808539zM897.694219 764.037458H777.215655v-153.94284h51.036094v117.964647h69.44247z"
fill="#FFFFFF"
/>
</svg>
</template>
<style scoped lang="less">
.excel-file-icon {
display: block;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,51 @@
import { ref } from 'vue';
import { useOperationVoiceBroadcast } from './useOperationVoiceBroadcast';
export type ScanFeedbackType = 'idle' | 'success' | 'error' | 'warning';
/**
* 扫描页左侧反馈区 + 语音播报。
*/
export function useOperationScanFeedback(pageKey: string) {
const { voiceEnabled, speak, stopSpeak } = useOperationVoiceBroadcast(pageKey);
const feedbackType = ref<ScanFeedbackType>('idle');
const feedbackMessage = ref('');
function clearFeedback() {
feedbackType.value = 'idle';
feedbackMessage.value = '';
}
function showFeedback(type: ScanFeedbackType, message: string, withVoice = true) {
feedbackType.value = type;
feedbackMessage.value = message;
if (withVoice && (type === 'error' || type === 'success' || type === 'warning')) {
speak(message);
}
}
function showError(message: string, withVoice = true) {
showFeedback('error', message, withVoice);
}
function showSuccess(message: string, withVoice = true) {
showFeedback('success', message, withVoice);
}
function showWarning(message: string, withVoice = true) {
showFeedback('warning', message, withVoice);
}
return {
voiceEnabled,
feedbackType,
feedbackMessage,
speak,
stopSpeak,
clearFeedback,
showFeedback,
showError,
showSuccess,
showWarning,
};
}

View File

@ -0,0 +1,66 @@
import { ref, watch } from 'vue';
function storageKey(pageKey: string) {
return `oms.operation.voiceBroadcast.${pageKey}`;
}
function pickChineseVoice(): SpeechSynthesisVoice | undefined {
const voices = window.speechSynthesis?.getVoices?.() ?? [];
return (
voices.find((v) => v.lang === 'zh-CN') ||
voices.find((v) => v.lang.startsWith('zh')) ||
voices[0]
);
}
/**
* 操作扫描页语音播报(Web Speech API)。
*/
export function useOperationVoiceBroadcast(pageKey: string) {
const voiceEnabled = ref(true);
try {
const stored = localStorage.getItem(storageKey(pageKey));
if (stored != null) voiceEnabled.value = stored === '1';
} catch {
/* ignore */
}
watch(voiceEnabled, (v) => {
try {
localStorage.setItem(storageKey(pageKey), v ? '1' : '0');
} catch {
/* ignore */
}
if (!v) {
window.speechSynthesis?.cancel?.();
}
});
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.onvoiceschanged = () => {
pickChineseVoice();
};
}
function speak(text: string) {
const content = text?.trim();
if (!voiceEnabled.value || !content || typeof window === 'undefined') return;
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(content);
utterance.lang = 'zh-CN';
utterance.rate = 1.05;
utterance.pitch = 1;
const voice = pickChineseVoice();
if (voice) utterance.voice = voice;
window.speechSynthesis.speak(utterance);
}
function stopSpeak() {
window.speechSynthesis?.cancel?.();
}
return { voiceEnabled, speak, stopSpeak };
}

View File

@ -79,6 +79,7 @@ export default {
'menu.order': 'Order',
'menu.order.priceCheck': 'Price Check',
'menu.order.placeOrder': 'Place Order',
'menu.order.templateMapping': 'Template Mapping',
'menu.order.myOrders': 'My Orders',
'menu.order.batchJobs': 'Batch Jobs',
'menu.channel': 'Channel',
@ -345,9 +346,14 @@ export default {
'nav.contactService.phone': 'Contact phone',
'nav.contactService.workingTime': 'Service hours: Mon-Sun 9:00-22:00',
'nav.contactService.contactUs': 'Contact support',
'nanaAssistant.title': 'Nana · Logistics Assistant',
'nanaAssistant.open': 'Open Nana logistics assistant',
'nanaAssistant.greeting': "Hi, I'm Nana, your logistics assistant.\n\nAsk me about tracking, shipping times, declared values, customs clearance, and more. How can I help you today?",
'nanaAssistant.title': 'Nana · Exclusive Advisor',
'nanaAssistant.open': 'Open Nana exclusive advisor',
'nanaAssistant.tabAi': 'AI Nana',
'nanaAssistant.tabAdvisor': 'Exclusive Advisor',
'nanaAssistant.wechatId': 'WeChat ID',
'nanaAssistant.noAdvisor': 'No exclusive advisor assigned yet',
'nanaAssistant.noWechatQr': 'WeChat QR code not uploaded',
'nanaAssistant.greeting': "Hi, I'm Nana, your AI assistant.\n\nAsk me about tracking, shipping times, declared values, customs clearance, and more. How can I help you today?",
'nanaAssistant.inputPlaceholder': 'Type your logistics question, press Enter to send',
'nanaAssistant.send': 'Send',
'nanaAssistant.close': 'Close',
@ -379,10 +385,10 @@ export default {
'order.myOrders.handleStatus.context.problematic': 'Problematic handling',
'order.myOrders.handleStatus.context.claimed': 'Claim item handling',
'order.myOrders.actions.pay': 'Pay',
'order.myOrders.actions.predeclare': 'Pre-declare',
'order.myOrders.actions.pay': 'Pay Now',
'order.myOrders.actions.predeclare': 'Re-predeclare',
'order.myOrders.actions.createTrackOrder': 'Create track order',
'order.myOrders.actions.forwardDownstreamCreate': 'Forward downstream order',
'order.myOrders.actions.forwardDownstreamCreate': 'Get Tracking No.',
'order.myOrders.modal.downstreamForwardTitle': 'Downstream forward result',
'order.myOrders.actions.intercept': 'Intercept',
'order.myOrders.actions.cancelIntercept': 'Cancel intercept',
@ -449,6 +455,7 @@ export default {
'order.myOrders.table.ioss': 'IOSS',
'order.myOrders.table.eori': 'EORI',
'order.myOrders.table.orderStatus': 'Order Status',
'order.myOrders.table.predeclareFailReason': 'Predeclare Failure Reason',
'order.myOrders.table.createTime': 'Create Time',
'order.myOrders.table.receiveTime': 'Receive Time',
'order.myOrders.table.shipTime': 'Ship Time',
@ -506,6 +513,7 @@ export default {
'order.myOrders.drawer.cargoAttribute.sensitive': 'Sensitive',
'order.myOrders.table.cargoAttribute': 'Cargo Attribute',
'order.myOrders.drawer.orderStatus': 'Order Status',
'order.myOrders.drawer.predeclareFailReason': 'Predeclare Failure Reason',
'order.myOrders.drawer.warehouse': 'Warehouse',
'order.myOrders.drawer.receiveTime': 'Receive Time',
'order.myOrders.drawer.shipTime': 'Ship Time',
@ -727,6 +735,12 @@ export default {
// Order - Place Order
'order.placeOrder.template.label': 'Standard template',
'order.placeOrder.template.download': 'Download',
'order.placeOrder.template.edit': 'Edit',
'order.placeOrder.template.delete': 'Delete',
'order.placeOrder.template.cancel': 'Cancel',
'order.placeOrder.template.deleteSuccess': 'Custom template deleted',
'order.placeOrder.template.deleteConfirmTitle': 'Delete custom template',
'order.placeOrder.template.deleteConfirmContent': 'Delete template "{name}"? This cannot be undone.',
'order.placeOrder.template.standard1': 'Standard template 1',
'order.placeOrder.template.standard2': 'Standard template 2',
'order.placeOrder.template.custom1': 'Custom template 1',
@ -736,6 +750,34 @@ export default {
'order.placeOrder.upload.tip1': 'Please upload an Excel file and fill in required data according to the template.',
'order.placeOrder.upload.tip2': 'Maximum 2000 orders per upload.',
'order.placeOrder.batchOrder': 'Batch order',
'order.placeOrder.batch.submitted': 'Batch job #{jobId} submitted ({total} items). Opening Batch Jobs…',
'order.placeOrder.batch.viewJobs': 'View batch jobs',
'order.placeOrder.batch.uploadTitle': 'Upload & submit',
'order.placeOrder.batch.stepTemplate': 'Select template',
'order.placeOrder.batch.stepUpload': 'Upload Excel',
'order.placeOrder.batch.stepSubmit': 'Submit',
'order.placeOrder.upload.downloadTemplate': 'Download template',
'order.placeOrder.upload.fileReady': 'File ready to submit',
'order.placeOrder.upload.reselect': 'Reselect',
'order.placeOrder.upload.clearFile': 'Clear',
'order.placeOrder.upload.invalidType': 'Only .xlsx / .xls files are supported',
'order.placeOrder.upload.selectTemplateFirst': 'Please select a template first',
'order.placeOrder.templatePanel.title': 'Templates',
'order.placeOrder.templatePanel.hint': 'Click a card to select template',
'order.placeOrder.templatePanel.emptyCustom': 'No custom templates',
'order.placeOrder.templatePanel.emptyStandard': 'No standard templates. Contact admin or refresh the page.',
'order.placeOrder.agreement.label': 'I have read and agree to all terms',
'order.placeOrder.singleOrder': 'Single order',
'order.placeOrder.single.formTitle': 'Order details',
'order.placeOrder.single.sampleOrder': 'Generate sample',
'order.placeOrder.single.stepBasic': 'Basic info',
'order.placeOrder.single.stepSender': 'Sender',
'order.placeOrder.single.stepRecipient': 'Recipient',
'order.placeOrder.single.stepDeclaration': 'Declaration & submit',
'order.placeOrder.single.tip1': 'Fields marked * are required. Fill in each section in order.',
'order.placeOrder.single.tip2': 'Use “Generate sample” to try the full flow quickly.',
'order.placeOrder.single.submitHint': 'Complete required fields and accept the agreement to submit',
'order.placeOrder.single.submitReady': 'Ready to submit',
'order.placeOrder.standardTemplates': 'Standard templates',
'order.placeOrder.customTemplates': 'Custom templates',
@ -1132,7 +1174,7 @@ export default {
'system.organization.form.name': 'Organization Name',
'system.organization.form.namePlaceholder': 'Enter organization name',
'system.organization.form.code': 'Organization Code',
'system.organization.form.codePlaceholder': 'Enter organization code',
'system.organization.form.codePlaceholder': 'Auto-generated after save',
'system.organization.form.type': 'Organization Type',
'system.organization.form.typePlaceholder': 'Select organization type',
'system.organization.form.type.company': 'Company',
@ -1178,6 +1220,17 @@ export default {
'system.user.form.avatarPlaceholder': 'Enter avatar URL',
'system.user.form.roles': 'Roles',
'system.user.form.rolesPlaceholder': 'Select roles',
'system.user.form.phone': 'Phone',
'system.user.form.phonePlaceholder': 'Enter phone number',
'system.user.form.employeeNo': 'Employee No.',
'system.user.form.employeeNoPlaceholder': 'Auto-generated',
'system.user.form.wechatId': 'WeChat ID',
'system.user.form.wechatIdPlaceholder': 'Enter WeChat ID',
'system.user.form.wechatQrCode': 'WeChat QR Code',
'system.user.form.wechatQrUpload': 'Upload QR code',
'system.user.table.employeeNo': 'Employee No.',
'system.user.table.phone': 'Phone',
'system.user.message.wechatQrTooLarge': 'WeChat QR image must be under 2MB',
'system.user.search.organizationPlaceholder': 'Filter by organization',
'system.user.search.accountOrNamePlaceholder': 'Search username or account',
'system.user.rules.name.required': 'Please enter user name',
@ -1268,7 +1321,7 @@ export default {
'system.org.form.containsPosition': 'Include positions',
'system.org.form.placeholder.selectParentOrg': 'Select parent organization',
'system.org.form.placeholder.orgName': 'Enter organization name',
'system.org.form.placeholder.orgCode': 'Enter organization code, leave blank for random value',
'system.org.form.placeholder.orgCode': 'Auto-generated after save',
'system.org.form.placeholder.selectCategory': 'Select category',
'system.org.form.placeholder.selectStatus': 'Select status',
'system.org.form.placeholder.selectTargetOrg': 'Select target organization',
@ -1479,6 +1532,10 @@ export default {
// Operation Module - Common
'operation.common.keepScanFocus': 'Keep focus on scan field',
'operation.common.voiceBroadcast': 'Voice broadcast',
'operation.common.feedback.idle': 'Scan a number — result will appear here',
'operation.common.message.orderNotFound': 'Order not found',
'operation.common.message.lookupSuccess': 'Order matched',
// Operation Module - Inbound Scan
'operation.inbound.pageTitle': 'Waybill Information',
@ -1512,7 +1569,10 @@ export default {
'operation.inbound.table.operateTime': 'Operate Time',
'operation.inbound.table.operator': 'Operator',
'operation.inbound.message.waybillNumberRequired': 'Please enter or scan waybill number',
'operation.inbound.message.metricsRequired': 'Please enter received weight and dimensions',
'operation.inbound.message.inboundSuccess': 'Inbound successful',
'operation.inbound.message.inboundFailed': 'Inbound failed',
'operation.inbound.message.loadRecentFailed': 'Failed to load recent inbound records',
// Operation Module - Outbound Scan
'operation.outbound.pageTitle': 'Waybill Information',
@ -1533,6 +1593,7 @@ export default {
'operation.outbound.form.hint.supportedTypes': 'Supported: Waybill number only',
'operation.outbound.actions.reset': 'Reset',
'operation.outbound.actions.outbound': 'Outbound',
'operation.outbound.recentOrders.title': 'Recent orders',
'operation.outbound.recentOrders.subtitle': 'Maximum 50 recent orders displayed here',
'operation.outbound.recentOrders.empty': 'No data',
'operation.outbound.table.index': 'Index',
@ -1547,6 +1608,8 @@ export default {
'operation.outbound.table.operator': 'Operator',
'operation.outbound.message.waybillNumberRequired': 'Please enter or scan waybill number',
'operation.outbound.message.outboundSuccess': 'Outbound successful',
'operation.outbound.message.outboundFailed': 'Outbound failed',
'operation.outbound.message.loadRecentFailed': 'Failed to load recent outbound records',
// Operation Module - Print Label
'operation.printLabel.pageTitle': 'Print Label',

View File

@ -79,6 +79,7 @@ export default {
'menu.order': '订单',
'menu.order.priceCheck': '我要查价',
'menu.order.placeOrder': '我要下单',
'menu.order.templateMapping': '模板字段映射',
'menu.order.myOrders': '我的订单',
'menu.order.batchJobs': '批量任务',
'menu.channel': '渠道',
@ -172,9 +173,14 @@ export default {
'nav.contactService.phone': '联系电话',
'nav.contactService.workingTime': '工作时间:周一至周日 9:00-22:00',
'nav.contactService.contactUs': '联系客服',
'nanaAssistant.title': 'Nana · 物流小助手',
'nanaAssistant.open': '打开 Nana 物流助手',
'nanaAssistant.greeting': '嗨,我是 Nana,你的物流小助手~\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?',
'nanaAssistant.title': 'Nana 专属顾问',
'nanaAssistant.open': '打开 Nana 专属顾问',
'nanaAssistant.tabAi': 'AI Nana',
'nanaAssistant.tabAdvisor': '专属顾问',
'nanaAssistant.wechatId': '微信号',
'nanaAssistant.noAdvisor': '暂未分配专属顾问',
'nanaAssistant.noWechatQr': '暂未上传微信二维码',
'nanaAssistant.greeting': '嗨,我是 Nana~,我是你的AI智能客服\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?',
'nanaAssistant.inputPlaceholder': '输入你的物流问题,Enter 发送',
'nanaAssistant.send': '发送',
'nanaAssistant.close': '关闭',
@ -264,10 +270,10 @@ export default {
'order.myOrders.handleStatus.context.problematic': '问题件处理进度',
'order.myOrders.handleStatus.context.claimed': '理赔件处理进度',
'order.myOrders.actions.pay': '支付',
'order.myOrders.actions.predeclare': '预报',
'order.myOrders.actions.pay': '立即支付',
'order.myOrders.actions.predeclare': '重新预报',
'order.myOrders.actions.createTrackOrder': '创建轨迹订单',
'order.myOrders.actions.forwardDownstreamCreate': '预报下游订单',
'order.myOrders.actions.forwardDownstreamCreate': '获取跟踪号',
'order.myOrders.modal.downstreamForwardTitle': '下游预报结果',
'order.myOrders.actions.intercept': '拦截',
'order.myOrders.actions.cancelIntercept': '取消拦截',
@ -334,6 +340,7 @@ export default {
'order.myOrders.table.ioss': 'IOSS',
'order.myOrders.table.eori': 'EORI',
'order.myOrders.table.orderStatus': '订单状态',
'order.myOrders.table.predeclareFailReason': '预报失败原因',
'order.myOrders.table.createTime': '创建时间',
'order.myOrders.table.receiveTime': '收货时间',
'order.myOrders.table.shipTime': '发货时间',
@ -391,6 +398,7 @@ export default {
'order.myOrders.drawer.cargoAttribute.sensitive': '敏感货',
'order.myOrders.table.cargoAttribute': '货物属性',
'order.myOrders.drawer.orderStatus': '订单状态',
'order.myOrders.drawer.predeclareFailReason': '预报失败原因',
'order.myOrders.drawer.warehouse': '仓库',
'order.myOrders.drawer.receiveTime': '收货时间',
'order.myOrders.drawer.shipTime': '发货时间',
@ -612,6 +620,12 @@ export default {
// 订单 - 我要下单
'order.placeOrder.template.label': '标准模板',
'order.placeOrder.template.download': '下载',
'order.placeOrder.template.edit': '编辑',
'order.placeOrder.template.delete': '删除',
'order.placeOrder.template.cancel': '取消',
'order.placeOrder.template.deleteSuccess': '自定义模板已删除',
'order.placeOrder.template.deleteConfirmTitle': '删除自定义模板',
'order.placeOrder.template.deleteConfirmContent': '确定删除模板「{name}」吗?删除后不可恢复。',
'order.placeOrder.template.standard1': '标准模板1',
'order.placeOrder.template.standard2': '标准模板2',
'order.placeOrder.template.custom1': '自定义模板1',
@ -621,6 +635,34 @@ export default {
'order.placeOrder.upload.tip1': '请上传excel文件,根据对应模板填写必填数据。',
'order.placeOrder.upload.tip2': '最大上传订单数量2000单。',
'order.placeOrder.batchOrder': '批量下单',
'order.placeOrder.batch.submitted': '已提交批量任务 #{jobId}(共 {total} 条),正在跳转批量任务页…',
'order.placeOrder.batch.viewJobs': '查看批量任务',
'order.placeOrder.batch.uploadTitle': '上传并提交',
'order.placeOrder.batch.stepTemplate': '选择模板',
'order.placeOrder.batch.stepUpload': '上传 Excel',
'order.placeOrder.batch.stepSubmit': '提交下单',
'order.placeOrder.upload.downloadTemplate': '下载模板',
'order.placeOrder.upload.fileReady': '文件已就绪,可提交下单',
'order.placeOrder.upload.reselect': '重新选择',
'order.placeOrder.upload.clearFile': '清除',
'order.placeOrder.upload.invalidType': '仅支持 .xlsx / .xls 文件',
'order.placeOrder.upload.selectTemplateFirst': '请先选择模板',
'order.placeOrder.templatePanel.title': '模板库',
'order.placeOrder.templatePanel.hint': '点击卡片可快速选中模板',
'order.placeOrder.templatePanel.emptyCustom': '暂无自定义模板',
'order.placeOrder.templatePanel.emptyStandard': '暂无标准模板,请联系管理员或刷新页面',
'order.placeOrder.agreement.label': '我已阅读并同意本协议全部条款',
'order.placeOrder.singleOrder': '单票下单',
'order.placeOrder.single.formTitle': '填写订单信息',
'order.placeOrder.single.sampleOrder': '生成示例订单',
'order.placeOrder.single.stepBasic': '基本信息',
'order.placeOrder.single.stepSender': '发件人',
'order.placeOrder.single.stepRecipient': '收件人',
'order.placeOrder.single.stepDeclaration': '申报并提交',
'order.placeOrder.single.tip1': '带 * 号为必填项,请按区块依次填写订单信息。',
'order.placeOrder.single.tip2': '可先点击「生成示例订单」快速体验完整流程。',
'order.placeOrder.single.submitHint': '请完善必填信息并勾选协议后提交',
'order.placeOrder.single.submitReady': '信息已完善,可提交下单',
'order.placeOrder.standardTemplates': '标准模板',
'order.placeOrder.customTemplates': '自定义模板',
@ -1017,7 +1059,7 @@ export default {
'system.organization.form.name': '组织名称',
'system.organization.form.namePlaceholder': '请输入组织名称',
'system.organization.form.code': '组织编码',
'system.organization.form.codePlaceholder': '请输入组织编码',
'system.organization.form.codePlaceholder': '保存后自动生成',
'system.organization.form.type': '组织类型',
'system.organization.form.typePlaceholder': '请选择组织类型',
'system.organization.form.type.company': '公司',
@ -1063,6 +1105,17 @@ export default {
'system.user.form.avatarPlaceholder': '请输入头像URL',
'system.user.form.roles': '角色',
'system.user.form.rolesPlaceholder': '请选择角色',
'system.user.form.phone': '电话',
'system.user.form.phonePlaceholder': '请输入电话',
'system.user.form.employeeNo': '工号',
'system.user.form.employeeNoPlaceholder': '自动生成',
'system.user.form.wechatId': '微信号',
'system.user.form.wechatIdPlaceholder': '请输入微信号',
'system.user.form.wechatQrCode': '微信二维码',
'system.user.form.wechatQrUpload': '上传二维码',
'system.user.table.employeeNo': '工号',
'system.user.table.phone': '电话',
'system.user.message.wechatQrTooLarge': '微信二维码图片不能超过 2MB',
'system.user.search.organizationPlaceholder': '选择组织筛选',
'system.user.search.accountOrNamePlaceholder': '搜索用户名或账号',
'system.user.rules.name.required': '请输入用户名',
@ -1153,7 +1206,7 @@ export default {
'system.org.form.containsPosition': '包含职位',
'system.org.form.placeholder.selectParentOrg': '请选择上级组织',
'system.org.form.placeholder.orgName': '请输入组织名称',
'system.org.form.placeholder.orgCode': '请填写组织编码,不填则为随机值',
'system.org.form.placeholder.orgCode': '保存后自动生成',
'system.org.form.placeholder.selectCategory': '请选择分类',
'system.org.form.placeholder.selectStatus': '请选择状态',
'system.org.form.placeholder.selectTargetOrg': '请选择目标组织',
@ -1497,6 +1550,10 @@ export default {
// 操作模块 - 通用
'operation.common.keepScanFocus': '焦点维持',
'operation.common.voiceBroadcast': '语音播报',
'operation.common.feedback.idle': '请扫描单号,结果将在此显示',
'operation.common.message.orderNotFound': '未找到对应订单',
'operation.common.message.lookupSuccess': '订单匹配成功',
// 操作模块 - 入库扫描
'operation.inbound.pageTitle': '运单信息',
@ -1530,7 +1587,10 @@ export default {
'operation.inbound.table.operateTime': '操作时间',
'operation.inbound.table.operator': '操作人',
'operation.inbound.message.waybillNumberRequired': '请输入或扫描运单号',
'operation.inbound.message.metricsRequired': '请填写收货实重与长宽高',
'operation.inbound.message.inboundSuccess': '签入成功',
'operation.inbound.message.inboundFailed': '入库失败',
'operation.inbound.message.loadRecentFailed': '加载最近入库记录失败',
// 操作模块 - 出库扫描
'operation.outbound.pageTitle': '运单信息',
@ -1551,6 +1611,7 @@ export default {
'operation.outbound.form.hint.supportedTypes': '支持:仅支持运单号',
'operation.outbound.actions.reset': '重置',
'operation.outbound.actions.outbound': '出库',
'operation.outbound.recentOrders.title': '最近操作的订单',
'operation.outbound.recentOrders.subtitle': '此处最多显示最近操作的50张订单',
'operation.outbound.recentOrders.empty': '暂无数据',
'operation.outbound.table.index': '序号',
@ -1565,6 +1626,8 @@ export default {
'operation.outbound.table.operator': '操作人',
'operation.outbound.message.waybillNumberRequired': '请输入或扫描运单号',
'operation.outbound.message.outboundSuccess': '出库成功',
'operation.outbound.message.outboundFailed': '出库失败',
'operation.outbound.message.loadRecentFailed': '加载最近出库记录失败',
// 操作模块 - 打印标签
'operation.printLabel.pageTitle': '打印标签',

View File

@ -39,6 +39,16 @@ const ORDER: AppRouteRecordRaw = {
requiresAuth: true,
},
},
{
path: 'templateMapping',
name: 'OrderTemplateMapping',
component: () => import('@/views/order/templateMapping/index.vue'),
meta: {
locale: 'menu.order.templateMapping',
requiresAuth: true,
hideInMenu: true,
},
},
{
path: 'batchJobs',
name: 'OrderBatchJobs',

View File

@ -0,0 +1,49 @@
export type UserProfileExt = {
phone?: string
employeeNo?: string
wechatId?: string
wechatQrCode?: string
}
export type UserFormExtState = UserProfileExt & {
wechatQrFiles?: Array<{
uid?: string
name?: string
url?: string
status?: string
response?: { url?: string }
}>
}
function pickStr(row: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const v = row[key]
if (v != null && String(v).trim() !== '') return String(v).trim()
}
return ''
}
export function pickUserProfileExt(
user: Record<string, unknown> | null | undefined,
): UserProfileExt {
if (!user || typeof user !== 'object') return {}
return {
phone: pickStr(user, 'phone', 'Phone'),
employeeNo: pickStr(user, 'employeeNo', 'EmployeeNo'),
wechatId: pickStr(user, 'wechatId', 'WechatId'),
wechatQrCode: pickStr(user, 'wechatQrCode', 'WechatQrCode'),
}
}
export function toUserProfilePayload(state: UserFormExtState): UserProfileExt {
const files = state.wechatQrFiles || []
const fromFile = files
.map((f) => f.response?.url ?? f.url)
.find((u) => u != null && String(u).trim() !== '')
return {
phone: state.phone?.trim() || undefined,
employeeNo: state.employeeNo?.trim() || undefined,
wechatId: state.wechatId?.trim() || undefined,
wechatQrCode: (fromFile ? String(fromFile) : state.wechatQrCode?.trim()) || undefined,
}
}

View File

@ -0,0 +1,30 @@
export const DEFAULT_MAX_IMAGE_BYTES = 2 * 1024 * 1024
export function readFileAsDataUrl(
file: File,
maxBytes = DEFAULT_MAX_IMAGE_BYTES,
tooLargeMessage = 'Image is too large',
): Promise<string> {
if (file.size > maxBytes) {
return Promise.reject(new Error(tooLargeMessage))
}
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error('read failed'))
reader.readAsDataURL(file)
})
}
export function urlToUploadFileList(url?: string | null, prefix = 'img') {
if (!url || String(url).trim() === '') return []
const value = String(url)
return [
{
uid: `${prefix}-${value.slice(0, 24)}`,
name: 'image',
url: value,
status: 'done',
},
]
}

View File

@ -1,6 +1,11 @@
import type { OrganizationTreeDto } from '@/api/rbac'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
/** 岗位为末级,不可再挂下级;公司/部门/小组均可新增子组织 */
export function canOrganizationHaveChildren(type?: number | null): boolean {
return type !== 4
}
export function buildOrganizationLeftMenuTree(
data: OrganizationTreeDto[],
getTypeLabel?: (type?: number, depth?: number, childCount?: number) => string,
@ -9,19 +14,23 @@ export function buildOrganizationLeftMenuTree(
return data.map((item) => {
const hasChildren = Boolean(item.children?.length)
const childCount = item.children?.length ?? 0
const isLeaf = !canOrganizationHaveChildren(item.type)
return {
key: `org-${item.id}`,
title: item.name || item.code || '',
isLeaf: !hasChildren,
isLeaf,
id: item.id,
name: item.name,
code: item.code || '',
type: item.type,
depth,
childCount,
subtitle: getTypeLabel?.(item.type, depth, childCount),
children: hasChildren
? buildOrganizationLeftMenuTree(item.children!, getTypeLabel, depth + 1)
: undefined,
: isLeaf
? undefined
: [],
}
})
}

View File

@ -0,0 +1,62 @@
import type { OrganizationTreeDto } from '@/api/rbac';
/** 组织类型 → 编码前缀 */
export const ORGANIZATION_TYPE_CODES: Record<number, string> = {
1: 'GS', // 公司
2: 'BM', // 部门
3: 'XZ', // 小组
4: 'GW', // 岗位
};
export function getOrganizationTypeCode(type?: number | null): string {
return ORGANIZATION_TYPE_CODES[type ?? 0] ?? 'ORG';
}
export function flattenOrganizationTree(
nodes: OrganizationTreeDto[] | null | undefined,
): OrganizationTreeDto[] {
const list: OrganizationTreeDto[] = [];
const walk = (items: OrganizationTreeDto[]) => {
for (const item of items) {
list.push(item);
if (item.children?.length) {
walk(item.children);
}
}
};
if (nodes?.length) {
walk(nodes);
}
return list;
}
/**
* 按「组织类型编码 + 两位序号」生成唯一组织编码,如 BM01、GW02。
*/
export function generateOrganizationCode(
type?: number | null,
organizations: Array<{ code?: string | null }> = [],
): string {
const typeCode = getOrganizationTypeCode(type);
const pattern = new RegExp(`^${escapeRegExp(typeCode)}(\\d+)$`, 'i');
let maxSeq = 0;
for (const org of organizations) {
const code = org.code?.trim();
if (!code) continue;
const matched = code.match(pattern);
if (!matched) continue;
const seq = Number.parseInt(matched[1], 10);
if (Number.isFinite(seq) && seq > maxSeq) {
maxSeq = seq;
}
}
const next = maxSeq + 1;
const width = Math.max(2, String(next).length);
return `${typeCode}${String(next).padStart(width, '0')}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@ -10,6 +10,8 @@ const routeNameMap: Record<string, string> = {
'/order/priceCheck': 'PriceCheck',
'order_my_orders': 'MyOrders',
'/order/myOrders': 'MyOrders',
'order_template_mapping': 'OrderTemplateMapping',
'/order/templateMapping': 'OrderTemplateMapping',
'order_batch_jobs': 'OrderBatchJobs',
'/order/batchJobs': 'OrderBatchJobs',
'order': 'Order',

View File

@ -0,0 +1,17 @@
const EMPLOYEE_NO_PATTERN = /^NE(\d{4})$/i
export function generateEmployeeNo(
existing: Array<{ employeeNo?: string | null; EmployeeNo?: string | null }>,
): string {
let max = 0
for (const row of existing) {
const raw = String(row.employeeNo ?? row.EmployeeNo ?? '').trim()
const match = raw.match(EMPLOYEE_NO_PATTERN)
if (match) {
max = Math.max(max, Number.parseInt(match[1], 10))
}
}
const next = max + 1
if (next > 9999) return `NE${next}`
return `NE${String(next).padStart(4, '0')}`
}

View File

@ -24,7 +24,7 @@
<div v-if="shellReady" ref="rightPaneRef" class="login-pane login-pane--right">
<div ref="loginFormRef" :class="['login-form', { 'login-form--ready': shellReady }]">
<div class="login-form-toolbar">
<ContactService v-if="isCustomerLogin" variant="login" placement="bottomRight" />
<ContactService v-if="isCustomerLogin" variant="login" />
<LoginLangToggle />
<a-tooltip :content="isLightMode ? t('login.theme.toNight') : t('login.theme.toDay')">
<a-button

View File

@ -7,6 +7,9 @@
<a-checkbox v-model="keepScanFocus" size="small">
{{ $t('operation.common.keepScanFocus') }}
</a-checkbox>
<a-checkbox v-model="voiceEnabled" size="small">
{{ $t('operation.common.voiceBroadcast') }}
</a-checkbox>
</div>
<a-form :model="formData" layout="vertical" class="waybill-form">
<!-- 扫描单号 -->
@ -82,7 +85,7 @@
<a-form-item>
<div class="form-actions">
<a-button class="reset-btn" @click="handleReset">{{ $t('operation.inbound.actions.reset') }}</a-button>
<a-button type="primary" class="submit-btn" @click="handleSubmit">{{ $t('operation.inbound.actions.inbound') }}</a-button>
<a-button type="primary" class="submit-btn" :loading="submitting" @click="handleSubmit">{{ $t('operation.inbound.actions.inbound') }}</a-button>
</div>
</a-form-item>
</a-form>
@ -92,7 +95,13 @@
<div class="recent-orders-section">
<div class="list-toolbar">
<div class="toolbar-left">
<p
v-if="feedbackMessage"
class="scan-feedback-text"
:class="`is-${feedbackType}`"
>
{{ feedbackMessage }}
</p>
</div>
<div class="toolbar-right">
<div class="pagination-controls">
@ -127,22 +136,33 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { ref, reactive, computed, onMounted } from 'vue';
import { $baseMessage } from '@/utils';
import HandsontablePro from '@/components/HandsontablePro/index.vue';
import PaginationPro from '@/components/PaginationPro/index.vue';
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
import { useOperationScanFocus } from '@/composables/useOperationScanFocus';
import { useOperationScanFeedback } from '@/composables/useOperationScanFeedback';
import { inboundCreate, inboundRecentList, inboundOrderLookup } from '@/api/operation';
const { t } = useI18n();
const { tableContainerRef, tableHeight } = useTableContainerHeight();
const scanInputRef = ref<{ focus?: () => void } | null>(null);
const { keepScanFocus, maybeRefocusScan } = useOperationScanFocus('inboundScan', scanInputRef);
const {
voiceEnabled,
feedbackType,
feedbackMessage,
clearFeedback,
showError,
showSuccess,
showWarning,
} = useOperationScanFeedback('inboundScan');
defineOptions({
name: 'InboundScan',
});
const submitting = ref(false);
// 表单数据
const formData = reactive({
waybillNumber: '',
@ -196,7 +216,7 @@ async function loadRecent() {
operator: row.operatorName ?? row.operator ?? '-',
}));
} catch (e: any) {
$baseMessage(e?.message || '加载最近入库记录失败', 'error');
showError(e?.message || t('operation.inbound.message.loadRecentFailed'));
recentOrders.value = [];
}
}
@ -216,29 +236,31 @@ async function handleWaybillLookup() {
formData.salesProductName = '';
formData.destinationCountryName = '';
formData.orderId = undefined;
clearFeedback();
return;
}
try {
const info = await inboundOrderLookup(no);
if (!info?.orderId) {
throw new Error('未找到对应订单');
throw new Error(t('operation.common.message.orderNotFound'));
}
formData.orderId = info.orderId;
formData.salesProductName = info.salesProductName || info.productCode || '';
formData.destinationCountryName =
info.destinationCountryName || info.destinationCountry || '';
showSuccess(t('operation.common.message.lookupSuccess'));
} catch (e: any) {
formData.salesProductName = '';
formData.destinationCountryName = '';
formData.orderId = undefined;
$baseMessage(e?.message || '未找到对应订单', 'warning');
showError(e?.message || t('operation.common.message.orderNotFound'));
maybeRefocusScan();
}
}
async function handleSubmit() {
if (!formData.waybillNumber?.trim()) {
$baseMessage(t('operation.inbound.message.waybillNumberRequired'), 'warning');
showWarning(t('operation.inbound.message.waybillNumberRequired'));
maybeRefocusScan();
return;
}
@ -249,10 +271,11 @@ async function handleSubmit() {
}
}
if (!formData.weight || !formData.length || !formData.width || !formData.height) {
$baseMessage('请填写收货实重与长宽高', 'warning');
showWarning(t('operation.inbound.message.metricsRequired'));
maybeRefocusScan();
return;
}
submitting.value = true;
try {
await inboundCreate({
waybillNo: formData.waybillNumber.trim(),
@ -261,18 +284,20 @@ async function handleSubmit() {
width: Number(formData.width),
height: Number(formData.height),
});
$baseMessage(t('operation.inbound.message.inboundSuccess'), 'success');
handleReset();
showSuccess(t('operation.inbound.message.inboundSuccess'));
handleReset(false);
await loadRecent();
maybeRefocusScan();
} catch (e: any) {
$baseMessage(e?.message || '入库失败', 'error');
showError(e?.message || t('operation.inbound.message.inboundFailed'));
maybeRefocusScan();
} finally {
submitting.value = false;
}
}
// 重置表单
function handleReset() {
function handleReset(clearStatus = true) {
formData.waybillNumber = '';
formData.weight = '';
formData.length = '';
@ -281,6 +306,9 @@ function handleReset() {
formData.salesProductName = '';
formData.destinationCountryName = '';
formData.orderId = undefined;
if (clearStatus) {
clearFeedback();
}
maybeRefocusScan();
}
@ -310,40 +338,6 @@ onMounted(() => {
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-shrink: 0;
.header-left {
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.sound-icon {
font-size: 18px;
color: #595959;
}
.broadcast-text {
font-size: 14px;
color: #595959;
}
}
}
.form-section {
background: #fff;
border-radius: 8px;
@ -361,6 +355,7 @@ onMounted(() => {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16px;
margin: -4px 0 12px;
flex-shrink: 0;
}
@ -485,10 +480,33 @@ onMounted(() => {
margin-bottom: 16px;
flex-shrink: 0;
flex-wrap: nowrap;
gap: 12px;
.toolbar-left .section-subtitle {
font-size: 12px;
color: #8c8c8c;
.toolbar-left {
flex: 1;
min-width: 0;
}
.scan-feedback-text {
margin: 0;
font-size: 16px;
line-height: 1.5;
word-break: break-word;
&.is-error {
color: #f53f3f;
font-weight: 700;
}
&.is-success {
color: #00b42a;
font-weight: 600;
}
&.is-warning {
color: #d46b08;
font-weight: 600;
}
}
.toolbar-right .pagination-controls {
@ -498,20 +516,6 @@ onMounted(() => {
white-space: nowrap;
flex-wrap: nowrap;
}
.toolbar-right .total-records,
.toolbar-right .go-to-text,
.toolbar-right .page-size-text,
.toolbar-right .page-text {
font-size: 12px;
color: #4e5969;
}
.toolbar-right .current-page {
font-size: 12px;
font-weight: 500;
color: #1d2129;
}
}
.list-wrap {
@ -547,4 +551,3 @@ onMounted(() => {
}
}
</style>

View File

@ -7,6 +7,9 @@
<a-checkbox v-model="keepScanFocus" size="small">
{{ $t('operation.common.keepScanFocus') }}
</a-checkbox>
<a-checkbox v-model="voiceEnabled" size="small">
{{ $t('operation.common.voiceBroadcast') }}
</a-checkbox>
</div>
<a-form :model="formData" layout="vertical" class="waybill-form">
<!-- 扫描单号 -->
@ -91,7 +94,7 @@
<a-form-item>
<div class="form-actions">
<a-button class="reset-btn" @click="handleReset">{{ $t('operation.outbound.actions.reset') }}</a-button>
<a-button type="primary" class="submit-btn" @click="handleSubmit">{{ $t('operation.outbound.actions.outbound') }}</a-button>
<a-button type="primary" class="submit-btn" :loading="submitting" @click="handleSubmit">{{ $t('operation.outbound.actions.outbound') }}</a-button>
</div>
</a-form-item>
</a-form>
@ -101,7 +104,13 @@
<div class="recent-orders-section">
<div class="list-toolbar">
<div class="toolbar-left">
<p
v-if="feedbackMessage"
class="scan-feedback-text"
:class="`is-${feedbackType}`"
>
{{ feedbackMessage }}
</p>
</div>
<div class="toolbar-right">
<div class="pagination-controls">
@ -136,22 +145,33 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { ref, reactive, computed, onMounted } from 'vue';
import { $baseMessage } from '@/utils';
import HandsontablePro from '@/components/HandsontablePro/index.vue';
import PaginationPro from '@/components/PaginationPro/index.vue';
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
import { useOperationScanFocus } from '@/composables/useOperationScanFocus';
import { useOperationScanFeedback } from '@/composables/useOperationScanFeedback';
import { outboundCreate, outboundRecentList, outboundOrderLookup } from '@/api/operation';
const { t } = useI18n();
const { tableContainerRef, tableHeight } = useTableContainerHeight();
const scanInputRef = ref<{ focus?: () => void } | null>(null);
const { keepScanFocus, maybeRefocusScan } = useOperationScanFocus('outboundScan', scanInputRef);
const {
voiceEnabled,
feedbackType,
feedbackMessage,
clearFeedback,
showError,
showSuccess,
showWarning,
} = useOperationScanFeedback('outboundScan');
defineOptions({
name: 'OutboundScan',
});
const submitting = ref(false);
// 表单数据
const formData = reactive({
waybillNumber: '',
@ -197,12 +217,13 @@ async function handleWaybillLookup() {
formData.destinationCountryName = '';
formData.serviceChannelName = '';
formData.orderId = undefined;
clearFeedback();
return;
}
try {
const info = await outboundOrderLookup(no);
if (!info?.orderId) {
throw new Error('未找到对应订单');
throw new Error(t('operation.common.message.orderNotFound'));
}
formData.orderId = info.orderId;
formData.salesProductName = info.salesProductName || info.productCode || '';
@ -210,12 +231,13 @@ async function handleWaybillLookup() {
info.destinationCountryName || info.destinationCountry || '';
formData.serviceChannelName =
info.serviceChannelName || info.channelCode || '';
showSuccess(t('operation.common.message.lookupSuccess'));
} catch (e: any) {
formData.salesProductName = '';
formData.destinationCountryName = '';
formData.serviceChannelName = '';
formData.orderId = undefined;
$baseMessage(e?.message || '未找到对应订单', 'warning');
showError(e?.message || t('operation.common.message.orderNotFound'));
maybeRefocusScan();
}
}
@ -238,7 +260,7 @@ async function loadRecent() {
operator: row.operatorName ?? row.operator ?? '-',
}));
} catch (e: any) {
$baseMessage(e?.message || '加载最近出库记录失败', 'error');
showError(e?.message || t('operation.outbound.message.loadRecentFailed'));
recentOrders.value = [];
}
}
@ -254,7 +276,7 @@ function handlePageSizeChange(size: number) {
async function handleSubmit() {
if (!formData.waybillNumber?.trim()) {
$baseMessage(t('operation.outbound.message.waybillNumberRequired'), 'warning');
showWarning(t('operation.outbound.message.waybillNumberRequired'));
maybeRefocusScan();
return;
}
@ -264,6 +286,7 @@ async function handleSubmit() {
return;
}
}
submitting.value = true;
try {
await outboundCreate({
waybillNo: formData.waybillNumber.trim(),
@ -272,18 +295,20 @@ async function handleSubmit() {
width: formData.width ? Number(formData.width) : undefined,
height: formData.height ? Number(formData.height) : undefined,
});
$baseMessage(t('operation.outbound.message.outboundSuccess'), 'success');
handleReset();
showSuccess(t('operation.outbound.message.outboundSuccess'));
handleReset(false);
await loadRecent();
maybeRefocusScan();
} catch (e: any) {
$baseMessage(e?.message || '出库失败', 'error');
showError(e?.message || t('operation.outbound.message.outboundFailed'));
maybeRefocusScan();
} finally {
submitting.value = false;
}
}
// 重置表单
function handleReset() {
function handleReset(clearStatus = true) {
formData.waybillNumber = '';
formData.weight = '';
formData.length = '';
@ -293,6 +318,9 @@ function handleReset() {
formData.destinationCountryName = '';
formData.serviceChannelName = '';
formData.orderId = undefined;
if (clearStatus) {
clearFeedback();
}
maybeRefocusScan();
}
@ -322,40 +350,6 @@ onMounted(() => {
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-shrink: 0;
.header-left {
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.sound-icon {
font-size: 18px;
color: #595959;
}
.broadcast-text {
font-size: 14px;
color: #595959;
}
}
}
.form-section {
background: #fff;
border-radius: 8px;
@ -373,6 +367,7 @@ onMounted(() => {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16px;
margin: -4px 0 12px;
flex-shrink: 0;
}
@ -497,10 +492,33 @@ onMounted(() => {
margin-bottom: 16px;
flex-shrink: 0;
flex-wrap: nowrap;
gap: 12px;
.toolbar-left .section-subtitle {
font-size: 12px;
color: #8c8c8c;
.toolbar-left {
flex: 1;
min-width: 0;
}
.scan-feedback-text {
margin: 0;
font-size: 16px;
line-height: 1.5;
word-break: break-word;
&.is-error {
color: #f53f3f;
font-weight: 700;
}
&.is-success {
color: #00b42a;
font-weight: 600;
}
&.is-warning {
color: #d46b08;
font-weight: 600;
}
}
.toolbar-right .pagination-controls {
@ -510,20 +528,6 @@ onMounted(() => {
white-space: nowrap;
flex-wrap: nowrap;
}
.toolbar-right .total-records,
.toolbar-right .go-to-text,
.toolbar-right .page-size-text,
.toolbar-right .page-text {
font-size: 12px;
color: #4e5969;
}
.toolbar-right .current-page {
font-size: 12px;
font-weight: 500;
color: #1d2129;
}
}
.list-wrap {
@ -559,4 +563,3 @@ onMounted(() => {
}
}
</style>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
@ -24,6 +25,7 @@ import BatchJobDetailDrawer from './components/BatchJobDetailDrawer.vue'
defineOptions({ name: 'OrderBatchJobs' })
const { t } = useI18n()
const route = useRoute()
const { tableContainerRef, tableHeight: handsontableHeight } = useTableContainerHeight()
const listLoading = ref(false)
@ -213,9 +215,13 @@ function actionRenderer(rowData: any, _rowIndex: number) {
.join('')
}
onMounted(() => {
loadList()
onMounted(async () => {
await loadList()
loadDlxStats()
const jobId = Number(route.query.jobId)
if (Number.isFinite(jobId) && jobId > 0) {
openDetail(jobId)
}
})
</script>

View File

@ -6,7 +6,7 @@ import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { CLAIM_CURRENCY_OPTIONS, CLAIM_TYPE_OPTIONS } from '@/constants/claimType'
import { postOrderMarkClaimed } from '@/api/order'
import { postOrderMarkClaimed, postOrderBatchJob, waitOrderBatchJob, isOrderBatchQueueDisabledError, formatBatchJobFailureMessage } from '@/api/order'
const props = defineProps<{
visible: boolean
@ -140,6 +140,31 @@ watch(
},
)
async function submitBatchJob(payload: Record<string, unknown>) {
const job = await postOrderBatchJob({
action: 'mark-claimed',
orderIds: props.orderIds,
payloadJson: JSON.stringify(payload),
})
const { job: finishedJob, items } = await waitOrderBatchJob(job.jobId)
if (finishedJob.failCount > 0) {
const msg = formatBatchJobFailureMessage(finishedJob, items)
if (finishedJob.successCount > 0) {
Message.warning(msg)
emit('success')
emit('update:visible', false)
modalMethods.closeModal()
return
}
Message.error(msg)
return
}
Message.success(t('order.myOrders.claimed.success'))
emit('success')
emit('update:visible', false)
modalMethods.closeModal()
}
async function handleSubmit() {
const valid = await formRef.value?.formRef?.validate?.()
if (valid) return
@ -150,13 +175,24 @@ async function handleSubmit() {
submitting.value = true
try {
const result = await postOrderMarkClaimed({
orderIds: props.orderIds,
const payload = {
claimType: values.claimType,
claimAmount: Number(values.claimAmount),
claimCurrency: values.claimCurrency,
claimReason,
remark: remark || undefined,
}
try {
await submitBatchJob(payload)
return
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e
}
}
const result = await postOrderMarkClaimed({
orderIds: props.orderIds,
...payload,
})
if (result.failures?.length) {
const lines = result.failures

View File

@ -6,7 +6,7 @@ import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { ISSUE_TYPE_OPTIONS, getIssueTemplateDetail, getIssueTemplateOptions } from '@/api/channel/issueTemplate'
import { postOrderMarkProblematic } from '@/api/order'
import { postOrderMarkProblematic, postOrderBatchJob, waitOrderBatchJob, isOrderBatchQueueDisabledError, formatBatchJobFailureMessage } from '@/api/order'
const props = defineProps<{
visible: boolean
@ -149,18 +149,54 @@ watch(
},
)
async function submitBatchJob(payload: Record<string, unknown>) {
const job = await postOrderBatchJob({
action: 'mark-problematic',
orderIds: props.orderIds,
payloadJson: JSON.stringify(payload),
})
const { job: finishedJob, items } = await waitOrderBatchJob(job.jobId)
if (finishedJob.failCount > 0) {
const msg = formatBatchJobFailureMessage(finishedJob, items)
if (finishedJob.successCount > 0) {
Message.warning(msg)
emit('success')
emit('update:visible', false)
modalMethods.closeModal()
return
}
Message.error(msg)
return
}
Message.success(t('order.myOrders.problematic.success'))
emit('success')
emit('update:visible', false)
modalMethods.closeModal()
}
async function handleSubmit() {
const valid = await formRef.value?.formRef?.validate?.()
if (valid) return
submitting.value = true
try {
const result = await postOrderMarkProblematic({
orderIds: props.orderIds,
const payload = {
issueTemplateId: formState.issueTemplateId,
issueType: formState.issueType,
title: formState.title,
content: formState.content,
remark: formState.remark,
}
try {
await submitBatchJob(payload)
return
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e
}
}
const result = await postOrderMarkProblematic({
orderIds: props.orderIds,
...payload,
})
if (result.failures?.length) {
const lines = result.failures

View File

@ -1000,6 +1000,7 @@ const orderBasicFields = [
{ key: 'consigneeCountryName', labelKey: 'order.myOrders.drawer.consigneeCountryName' },
{ key: 'cargoAttribute', labelKey: 'order.myOrders.drawer.cargoAttribute' },
{ key: 'orderStatus', labelKey: 'order.myOrders.drawer.orderStatus' },
{ key: 'predeclareFailReason', labelKey: 'order.myOrders.drawer.predeclareFailReason' },
{ key: 'warehouse', labelKey: 'order.myOrders.drawer.warehouse' },
{ key: 'receiveTime', labelKey: 'order.myOrders.drawer.receiveTime' },
{ key: 'shipTime', labelKey: 'order.myOrders.drawer.shipTime' },
@ -1040,7 +1041,7 @@ const orderBasicFieldGroups = [
{
key: 'timeline',
labelKey: 'order.myOrders.drawer.basic.group.timeline',
fields: ['orderStatus', 'receiveTime', 'shipTime', 'signTime', 'payTime', 'isPaid'],
fields: ['orderStatus', 'predeclareFailReason', 'receiveTime', 'shipTime', 'signTime', 'payTime', 'isPaid'],
},
{
key: 'flags',
@ -1266,6 +1267,7 @@ function getOrderValue(key: string) {
consigneeCountryName: ['consigneeCountryName'],
cargoAttribute: ['cargoAttribute'],
orderStatus: ['status', 'orderStatus'],
predeclareFailReason: ['predeclareFailReason', 'PredeclareFailReason'],
warehouse: ['warehouse', 'warehouseName'],
receiveTime: ['receiveTime'],
shipTime: ['shipTime'],

View File

@ -19,6 +19,11 @@
postOrderExport,
postOrderTrackUpsertSmallBag,
postOrderDownstreamForwardCreateOne,
postOrderBatchJob,
waitOrderBatchJob,
isOrderBatchQueueDisabledError,
formatBatchJobFailureMessage,
parseBatchJobPrintUrl,
postOrderHandleStatusUpdate,
type OrderTrackUpsertResult,
type OrderDownstreamForwardResult,
@ -110,6 +115,7 @@
'',
receivableAmount: pick(raw, 'receivableAmount', 'ReceivableAmount') ?? '',
orderStatus: pick(raw, 'orderStatus', 'OrderStatus') ?? raw?.orderStatus,
predeclareFailReason: pick(raw, 'predeclareFailReason', 'PredeclareFailReason') ?? '',
status: resolveOrderStatusCode(raw) || pick(raw, 'status', 'Status') || raw?.status,
handleStatus: pick(raw, 'handleStatus', 'HandleStatus') ?? raw?.handleStatus,
handleStatusName: pick(raw, 'handleStatusName', 'HandleStatusName') ?? raw?.handleStatusName,
@ -296,10 +302,87 @@
return lines.join('\n');
}
const handleCreateTrackOrder = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
await withOrderActionLoading('track', async () => {
function formatBatchJobResultText(
title: string,
job: { jobId: number; status: string; total: number; successCount: number; failCount: number },
items: Array<{ orderId: number; serviceNo?: string; status: string; failReason?: string }>,
rows: any[]
): string {
const lines = [
`【${title}】任务 #${job.jobId}`,
`状态:${job.status},合计 ${job.total},成功 ${job.successCount},失败 ${job.failCount}`,
'',
];
for (const item of items) {
const row = rows.find((r) => Number(getOrderRowId(r)) === item.orderId);
const waybillNo = item.serviceNo || row?.waybillNo || row?.serviceNo || String(item.orderId);
if (item.status === 'success') {
lines.push(`【${waybillNo}】成功`);
} else if (item.status === 'failed') {
lines.push(`【${waybillNo}】失败:${item.failReason || '未知原因'}`);
} else {
lines.push(`【${waybillNo}】${item.status || '处理中'}`);
}
}
return lines.join('\n');
}
async function runSelectedOrderBatch(options: {
action: string;
rows: any[];
payload?: Record<string, unknown>;
loadingKey: string;
syncRunner: () => Promise<void>;
successMessage?: string;
onAsyncComplete?: (ctx: {
job: { successCount: number; failCount: number };
items: Array<{ orderId: number; serviceNo?: string; status: string; failReason?: string; resultJson?: string }>;
rows: any[];
}) => void | Promise<void>;
}) {
const orderIds = options.rows.map((row) => Number(getOrderRowId(row)!));
try {
const job = await postOrderBatchJob({
action: options.action,
orderIds,
payloadJson: options.payload ? JSON.stringify(options.payload) : undefined,
});
const result = await waitOrderBatchJob(job.jobId);
if (options.onAsyncComplete) {
await options.onAsyncComplete({ ...result, rows: options.rows });
} else if (result.job.failCount > 0) {
Message.warning(
formatBatchJobFailureMessage(result.job, result.items, options.rows, (row, item) =>
String(row?.waybillNo || row?.serviceNo || item.serviceNo || item.orderId)
)
);
} else if (options.successMessage) {
Message.success(options.successMessage);
}
await refreshListAndTabCounts();
return;
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e;
}
}
await options.syncRunner();
}
async function runTrackUpsertBatch(rows: any[]) {
const orderIds = rows.map((row) => Number(getOrderRowId(row)!));
try {
const job = await postOrderBatchJob({ action: 'track-upsert-small-bag', orderIds });
const { job: finishedJob, items } = await waitOrderBatchJob(job.jobId);
trackUpsertResultText.value = formatBatchJobResultText('创建轨迹', finishedJob, items, rows);
trackUpsertModalVisible.value = true;
return;
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e;
}
}
const resultBlocks: string[] = [];
for (const row of rows) {
const id = getOrderRowId(row)!;
@ -307,12 +390,19 @@
try {
const result = await postOrderTrackUpsertSmallBag({ id });
resultBlocks.push(formatTrackUpsertResult(waybillNo, result));
} catch (e: any) {
resultBlocks.push(`【${waybillNo}】失败:${e?.message || '请求异常'}`);
} catch (err: any) {
resultBlocks.push(`【${waybillNo}】失败:${err?.message || '请求异常'}`);
}
}
trackUpsertResultText.value = resultBlocks.join('\n\n');
trackUpsertModalVisible.value = true;
}
const handleCreateTrackOrder = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
await withOrderActionLoading('track', async () => {
await runTrackUpsertBatch(rows);
}).catch(() => undefined);
};
@ -339,10 +429,20 @@
return lines.join('\n');
}
const handleForwardDownstreamCreate = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
await withOrderActionLoading('downstream', async () => {
async function runDownstreamForwardBatch(rows: any[]) {
const orderIds = rows.map((row) => Number(getOrderRowId(row)!));
try {
const job = await postOrderBatchJob({ action: 'downstream-forward-create-one', orderIds });
const { job: finishedJob, items } = await waitOrderBatchJob(job.jobId);
downstreamForwardResultText.value = formatBatchJobResultText('预报下游', finishedJob, items, rows);
downstreamForwardModalVisible.value = true;
return;
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e;
}
}
const resultBlocks: string[] = [];
for (const row of rows) {
const id = getOrderRowId(row)!;
@ -350,12 +450,19 @@
try {
const result = await postOrderDownstreamForwardCreateOne({ id });
resultBlocks.push(formatDownstreamForwardResult(waybillNo, result));
} catch (e: any) {
resultBlocks.push(`【${waybillNo}】失败:${e?.message || '请求异常'}`);
} catch (err: any) {
resultBlocks.push(`【${waybillNo}】失败:${err?.message || '请求异常'}`);
}
}
downstreamForwardResultText.value = resultBlocks.join('\n\n');
downstreamForwardModalVisible.value = true;
}
const handleForwardDownstreamCreate = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
await withOrderActionLoading('downstream', async () => {
await runDownstreamForwardBatch(rows);
}).catch(() => undefined);
};
@ -446,12 +553,20 @@
if (!rows.length) return;
try {
await withOrderActionLoading('pay', async () => {
await runSelectedOrderBatch({
action: 'pay',
rows,
loadingKey: 'pay',
successMessage: '支付请求已提交',
syncRunner: async () => {
for (const row of rows) {
await postOrderPay({ id: getOrderRowId(row)! });
}
Message.success('支付请求已提交');
notifyCustomerBalanceChanged();
await refreshListAndTabCounts();
},
});
notifyCustomerBalanceChanged();
});
} catch (e: any) {
if (e?.message !== 'busy') {
@ -465,11 +580,19 @@
if (!rows.length) return;
try {
await withOrderActionLoading('predeclare', async () => {
await runSelectedOrderBatch({
action: 'pre-declare',
rows,
loadingKey: 'predeclare',
successMessage: '预报已提交',
syncRunner: async () => {
for (const row of rows) {
await postOrderPreDeclare({ id: getOrderRowId(row)! });
}
Message.success('预报已提交');
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
@ -483,11 +606,19 @@
if (!rows.length) return;
try {
await withOrderActionLoading('intercept', async () => {
await runSelectedOrderBatch({
action: 'intercept',
rows,
loadingKey: 'intercept',
successMessage: '拦截已提交',
syncRunner: async () => {
for (const row of rows) {
await postOrderIntercept({ id: getOrderRowId(row)! });
}
Message.success('拦截已提交');
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
@ -501,11 +632,19 @@
if (!rows.length) return;
try {
await withOrderActionLoading('cancelIntercept', async () => {
await runSelectedOrderBatch({
action: 'cancel-intercept',
rows,
loadingKey: 'cancelIntercept',
successMessage: '已取消拦截',
syncRunner: async () => {
for (const row of rows) {
await postOrderCancelIntercept({ id: getOrderRowId(row)! });
}
Message.success('已取消拦截');
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
@ -528,11 +667,19 @@
onOk: async () => {
try {
await withOrderActionLoading('delete', async () => {
await runSelectedOrderBatch({
action: 'delete',
rows,
loadingKey: 'delete',
successMessage: '已删除',
syncRunner: async () => {
for (const row of rows) {
await postOrderDelete({ id: getOrderRowId(row)! });
}
Message.success('已删除');
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
@ -600,20 +747,54 @@
}
try {
await withOrderActionLoading('batchPrint', async () => {
const items: Array<{ id: number | string; label: string; url: string }> = [];
await runSelectedOrderBatch({
action: 'print',
rows,
loadingKey: 'batchPrint',
onAsyncComplete: async ({ items, rows: selectedRows, job }) => {
const itemsByOrderId = new Map(items.map((item) => [item.orderId, item]));
const printItems: Array<{ id: number | string; label: string; url: string }> = [];
for (const row of selectedRows) {
const orderId = Number(getOrderRowId(row)!);
const item = itemsByOrderId.get(orderId);
if (item?.status !== 'success') continue;
const printUrl = parseBatchJobPrintUrl(item);
if (isUsablePrintUrl(printUrl)) {
printItems.push(buildPrintItem(row, printUrl));
}
}
if (!printItems.length) {
if (job.failCount > 0) {
Message.warning(formatBatchJobFailureMessage(job, items, selectedRows));
} else {
Message.info('未获取到可打印文件,已打开打印弹窗供预览布局');
}
batchPrintVisible.value = true;
return;
}
batchPrintItems.value = printItems;
batchPrintVisible.value = true;
if (job.failCount > 0) {
Message.warning(formatBatchJobFailureMessage(job, items, selectedRows));
}
},
syncRunner: async () => {
const printItems: Array<{ id: number | string; label: string; url: string }> = [];
for (const row of rows) {
const { printUrl } = await postOrderPrint({ id: getOrderRowId(row)! });
if (isUsablePrintUrl(printUrl)) {
items.push(buildPrintItem(row, printUrl));
printItems.push(buildPrintItem(row, printUrl));
}
}
if (!items.length) {
if (!printItems.length) {
Message.info('未获取到可打印文件,已打开打印弹窗供预览布局');
batchPrintVisible.value = true;
return;
}
batchPrintItems.value = items;
batchPrintItems.value = printItems;
batchPrintVisible.value = true;
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
@ -668,6 +849,13 @@
if (!rows.length) return;
try {
await withOrderActionLoading('markProcessed', async () => {
await runSelectedOrderBatch({
action: 'handle-status-update',
rows,
loadingKey: 'markProcessed',
payload: { handleStatus: 'processed' },
successMessage: t('order.myOrders.handleStatus.success'),
syncRunner: async () => {
const result = await postOrderHandleStatusUpdate({
orderIds: rows.map((row) => Number(getOrderRowId(row)!)),
handleStatus: 'processed',
@ -693,6 +881,8 @@
Message.success(t('order.myOrders.handleStatus.success'));
}
await refreshListAndTabCounts();
},
});
});
} catch (e: any) {
if (e?.message !== 'busy') {
@ -702,6 +892,9 @@
};
const onMoreActionClick = (key: string) => {
if (key === 'createProblematic') return openProblematicModal();
if (key === 'createClaimed') return openClaimedModal();
if (key === 'createTrackOrder') return handleCreateTrackOrder();
if (key === 'intercept') return handleIntercept();
if (key === 'cancelIntercept') return handleCancelIntercept();
if (key === 'delete') return handleDelete();
@ -742,6 +935,7 @@
{ key: 'ioss', label: t('order.myOrders.table.ioss'), width: 100 },
{ key: 'eori', label: t('order.myOrders.table.eori'), width: 100 },
{ key: 'orderStatus', label: t('order.myOrders.table.orderStatus'), width: 130 },
{ key: 'predeclareFailReason', label: t('order.myOrders.table.predeclareFailReason'), width: 200 },
{ key: 'createTime', label: t('order.myOrders.table.createTime'), width: 150 },
{ key: 'receiveTime', label: t('order.myOrders.table.receiveTime'), width: 150 },
{ key: 'shipTime', label: t('order.myOrders.table.shipTime'), width: 150 },
@ -784,6 +978,16 @@
: '';
return `<span style="display:inline-flex;align-items:center;flex-wrap:wrap;gap:4px;max-width:100%;"><span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;color:${tagColor};background-color:${tagBgColor};white-space:nowrap;">${statusText}</span>${handleTagHtml}</span>`;
}
if (colKey === 'predeclareFailReason') {
const reason = rowData?.predeclareFailReason || value || '';
if (!reason) return null;
const escaped = String(reason)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
return `<span title="${escaped}" style="display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#f53f3f;font-size:12px;">${escaped}</span>`;
}
if (colKey === 'isPaid') {
const paid = rowData?.isPaid === true || rowData?.isPaid === 1 || rowData?.IsPaid === true || rowData?.IsPaid === 1;
const tagColor = paid ? '#00b42a' : '#86909c';
@ -842,20 +1046,6 @@
</div>
<div class="actions-right">
<a-button
v-if="showExceptionCreateActions"
class="btn-problematic"
:loading="isActionLoading('markProblematic')"
:disabled="isActionBusy('markProblematic')"
@click="openProblematicModal"
>{{ t('order.myOrders.actions.createProblematic') }}</a-button>
<a-button
v-if="showExceptionCreateActions"
class="btn-claimed"
:loading="isActionLoading('markClaimed')"
:disabled="isActionBusy('markClaimed')"
@click="openClaimedModal"
>{{ t('order.myOrders.actions.createClaimed') }}</a-button>
<a-button
v-if="showMarkProcessedAction"
type="primary"
@ -875,12 +1065,6 @@
:disabled="isActionBusy('predeclare')"
@click="handlePreDeclare"
>{{ t('order.myOrders.actions.predeclare') }}</a-button>
<a-button
class="btn-track"
:loading="isActionLoading('track')"
:disabled="isActionBusy('track')"
@click="handleCreateTrackOrder"
>{{ t('order.myOrders.actions.createTrackOrder') }}</a-button>
<a-button
class="btn-downstream"
:loading="isActionLoading('downstream')"
@ -905,6 +1089,20 @@
<icon-down class="btn-more-arrow" />
</a-button>
<template #content>
<a-doption
v-if="showExceptionCreateActions"
:disabled="isActionBusy('markProblematic')"
@click="onMoreActionClick('createProblematic')"
>{{ t('order.myOrders.actions.createProblematic') }}</a-doption>
<a-doption
v-if="showExceptionCreateActions"
:disabled="isActionBusy('markClaimed')"
@click="onMoreActionClick('createClaimed')"
>{{ t('order.myOrders.actions.createClaimed') }}</a-doption>
<a-doption
:disabled="isActionBusy('track')"
@click="onMoreActionClick('createTrackOrder')"
>{{ t('order.myOrders.actions.createTrackOrder') }}</a-doption>
<a-doption @click="onMoreActionClick('intercept')">{{ t('order.myOrders.actions.intercept') }}</a-doption>
<a-doption @click="onMoreActionClick('cancelIntercept')">{{ t('order.myOrders.actions.cancelIntercept') }}</a-doption>
<a-doption @click="onMoreActionClick('delete')">{{ t('order.myOrders.actions.delete') }}</a-doption>

View File

@ -0,0 +1,797 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Message, Modal } from '@arco-design/web-vue';
import { IconUpload, IconFile } from '@arco-design/web-vue/es/icon';
import * as XLSX from 'xlsx';
import {
getOrderTemplateCustomDetail,
getOrderTemplateFieldSchema,
postOrderTemplateCustomUpload,
postOrderTemplateSaveMapping,
type OrderTemplateCustomDetail,
type OrderTemplateFieldSchemaItem,
} from '@/api/order';
const DEFAULT_HEADER_ROW_INDEX = 1;
const DEFAULT_DATA_START_ROW_INDEX = 2;
export interface StandardTemplateOption {
templateKey: string;
label: string;
id?: number;
}
const props = defineProps<{
visible: boolean;
templateId?: number;
standardTemplateOptions: StandardTemplateOption[];
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'success', payload?: { templateId: number }): void;
}>();
const loading = ref(false);
const saving = ref(false);
const fieldsLoading = ref(false);
const detail = ref<OrderTemplateCustomDetail | null>(null);
const name = ref('');
const file = ref<File | null>(null);
const dragActive = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const sourceHeaders = ref<string[]>([]);
const standardTemplateKey = ref('dedicated_small_parcel_v1');
const standardFields = ref<OrderTemplateFieldSchemaItem[]>([]);
const mappingMap = ref<Record<string, string>>({});
const baselineSnapshot = ref('');
const isCreateMode = computed(() => !props.templateId || props.templateId <= 0);
const modalTitle = computed(() => {
if (isCreateMode.value) return '添加自定义模板';
const templateName = detail.value?.name || name.value;
return templateName ? `编辑自定义模板 - ${templateName}` : '编辑自定义模板';
});
const standardSelectOptions = computed(() =>
props.standardTemplateOptions.map((item) => ({
label: item.label,
value: item.templateKey,
})),
);
const mappingFieldsVisible = computed(() => {
if (isCreateMode.value) {
return standardFields.value.length > 0;
}
return !!detail.value && standardFields.value.length > 0;
});
const canSubmitSave = computed(() => {
if (isCreateMode.value) {
return (
!!name.value.trim() &&
!!file.value &&
sourceHeaders.value.length > 0 &&
standardFields.value.length > 0
);
}
return !!detail.value && sourceHeaders.value.length > 0 && standardFields.value.length > 0;
});
const isDirty = computed(() => baselineSnapshot.value !== serializeFormState());
const groupLabels: Record<string, string> = {
basic: '基本信息',
sender: '发件人',
receiver: '收件人',
declaration: '申报信息',
};
const groupedFields = computed(() => {
const groups = new Map<string, OrderTemplateFieldSchemaItem[]>();
standardFields.value.forEach((field) => {
const group = field.group || 'basic';
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(field);
});
return Array.from(groups.entries()).map(([key, items]) => ({
key,
label: groupLabels[key] || key,
items,
}));
});
const sourceHeaderOptions = computed(() => [
{ label: '不映射', value: '' },
...sourceHeaders.value.map((h) => ({ label: h, value: h })),
]);
const missingRequiredLabels = computed(() =>
standardFields.value
.filter((f) => f.required)
.filter((f) => !String(mappingMap.value[f.fieldKey] ?? '').trim())
.map((f) => f.label),
);
const unmappedSourceHeaders = computed(() => {
const used = new Set(Object.values(mappingMap.value).filter(Boolean));
return sourceHeaders.value.filter((h) => !used.has(h));
});
function defaultStandardKey() {
return props.standardTemplateOptions[0]?.templateKey || 'dedicated_small_parcel_v1';
}
function serializeFormState() {
return JSON.stringify({
name: name.value.trim(),
standardTemplateKey: standardTemplateKey.value,
fileName: file.value?.name || '',
sourceHeaders: sourceHeaders.value,
mappingMap: mappingMap.value,
});
}
function captureBaseline() {
baselineSnapshot.value = serializeFormState();
}
function normalizeHeader(text: string) {
return String(text || '')
.trim()
.replace(/[((].*?[))]/g, '')
.replace(/[\s*]/g, '')
.toLowerCase();
}
function buildSuggestedMapping(
fields: OrderTemplateFieldSchemaItem[],
headers: string[],
existing?: Record<string, string>,
) {
const next: Record<string, string> = {};
const used = new Set<string>();
fields.forEach((field) => {
const prev = existing?.[field.fieldKey];
if (prev && headers.includes(prev) && !used.has(prev)) {
next[field.fieldKey] = prev;
used.add(prev);
return;
}
const normalizedLabel = normalizeHeader(field.label);
const matched = headers.find((header) => {
if (used.has(header)) return false;
const normalizedHeader = normalizeHeader(header);
return (
normalizedHeader === normalizedLabel ||
normalizedHeader.includes(normalizedLabel) ||
normalizedLabel.includes(normalizedHeader)
);
});
next[field.fieldKey] = matched ?? '';
if (matched) used.add(matched);
});
return next;
}
function resetCreateForm() {
name.value = '';
file.value = null;
sourceHeaders.value = [];
mappingMap.value = {};
dragActive.value = false;
if (fileInputRef.value) fileInputRef.value.value = '';
standardTemplateKey.value = defaultStandardKey();
}
function resetAll() {
detail.value = null;
standardFields.value = [];
baselineSnapshot.value = '';
resetCreateForm();
}
async function parseFileHeaders(picked: File) {
const buffer = await picked.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
sourceHeaders.value = [];
return;
}
const sheet = workbook.Sheets[sheetName];
const table = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }) as any[][];
const row = table[0] || [];
sourceHeaders.value = row
.map((cell) => String(cell ?? '').trim())
.filter(Boolean);
mappingMap.value = buildSuggestedMapping(standardFields.value, sourceHeaders.value, mappingMap.value);
}
function acceptFile(picked: File | null | undefined) {
if (!picked) return;
const ext = picked.name.toLowerCase();
if (!ext.endsWith('.xlsx') && !ext.endsWith('.xls')) {
Message.warning('仅支持 .xlsx / .xls 文件');
return;
}
file.value = picked;
if (!name.value.trim()) {
name.value = picked.name.replace(/\.(xlsx|xls)$/i, '');
}
void parseFileHeaders(picked);
}
function triggerPicker() {
if (!isCreateMode.value) return;
fileInputRef.value?.click();
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
acceptFile(input.files?.[0]);
}
function onDragOver() {
if (!isCreateMode.value) return;
dragActive.value = true;
}
function onDragLeave() {
dragActive.value = false;
}
function onDrop(e: DragEvent) {
if (!isCreateMode.value) return;
dragActive.value = false;
acceptFile(e.dataTransfer?.files?.[0]);
}
async function loadStandardFields(preserveMapping = true) {
if (!standardTemplateKey.value) return;
fieldsLoading.value = true;
try {
const schema = await getOrderTemplateFieldSchema(standardTemplateKey.value);
standardFields.value = schema.fields ?? [];
const existing = preserveMapping ? { ...mappingMap.value } : undefined;
mappingMap.value = buildSuggestedMapping(standardFields.value, sourceHeaders.value, existing);
} catch (e: any) {
Message.error(e?.message || '加载标准模板字段失败');
} finally {
fieldsLoading.value = false;
}
}
function applySavedMappings(data: OrderTemplateCustomDetail) {
sourceHeaders.value = data.sourceHeaders ?? [];
const saved: Record<string, string> = {};
(data.mapping?.mappings ?? []).forEach((item) => {
if (item.standardFieldKey && item.sourceColumn) {
saved[item.standardFieldKey] = item.sourceColumn;
}
});
mappingMap.value = buildSuggestedMapping(standardFields.value, sourceHeaders.value, saved);
}
async function loadDetail() {
if (!props.templateId || props.templateId <= 0) return;
loading.value = true;
try {
const data = await getOrderTemplateCustomDetail(props.templateId);
detail.value = data;
name.value = data.name;
standardTemplateKey.value = data.standardTemplateKey || data.mapping?.standardTemplateKey || defaultStandardKey();
await loadStandardFields(false);
applySavedMappings(data);
captureBaseline();
} catch (e: any) {
Message.error(e?.message || '加载模板详情失败');
} finally {
loading.value = false;
}
}
async function handleStandardTemplateChange() {
await loadStandardFields(true);
}
function buildSaveMappings() {
return Object.entries(mappingMap.value)
.filter(([, sourceColumn]) => String(sourceColumn ?? '').trim())
.map(([standardFieldKey, sourceColumn]) => ({ standardFieldKey, sourceColumn }));
}
async function saveMappingForTemplate(templateId: number) {
return postOrderTemplateSaveMapping({
templateId,
standardTemplateKey: standardTemplateKey.value,
headerRowIndex: DEFAULT_HEADER_ROW_INDEX,
dataStartRowIndex: DEFAULT_DATA_START_ROW_INDEX,
mappings: buildSaveMappings(),
});
}
async function handleSave(): Promise<boolean> {
if (isCreateMode.value) {
const templateName = name.value.trim();
if (!templateName) {
Message.warning('请输入模板名称');
return false;
}
if (!file.value) {
Message.warning('请上传 Excel 样例文件');
return false;
}
if (!standardTemplateKey.value) {
Message.warning('请选择映射的标准模板');
return false;
}
if (!canSubmitSave.value) {
Message.warning('请完成字段映射配置');
return false;
}
} else if (!props.templateId) {
return false;
}
saving.value = true;
try {
let targetId = props.templateId ?? 0;
if (isCreateMode.value && file.value) {
const upload = await postOrderTemplateCustomUpload(name.value.trim(), file.value);
targetId = upload.templateId;
}
if (!Number.isFinite(targetId) || targetId <= 0) {
Message.error('模板 ID 无效,请重新上传样例文件');
return false;
}
const result = await saveMappingForTemplate(targetId);
if (result.missingRequiredFields?.length) {
Message.warning(`仍有必填字段未映射:${result.missingRequiredFields.join('、')}`);
if (!isCreateMode.value) {
await loadDetail();
}
return false;
}
Message.success(isCreateMode.value ? '自定义模板已创建' : '映射已保存');
emit('success', { templateId: targetId });
forceClose();
return true;
} catch (e: any) {
Message.error(e?.message || '保存失败');
return false;
} finally {
saving.value = false;
}
}
function forceClose() {
emit('update:visible', false);
}
function handleCloseRequest() {
if (!isDirty.value) {
forceClose();
return;
}
Modal.confirm({
title: '是否保存?',
content: isCreateMode.value
? '关闭后未保存的内容将不会创建模板及映射关联。'
: '关闭后未保存的映射修改将丢失。',
okText: '保存',
cancelText: '不保存',
maskClosable: false,
onOk: async () => {
const saved = await handleSave();
if (!saved) {
throw new Error('save failed');
}
},
onCancel: () => {
forceClose();
},
});
}
watch(
() => [props.visible, props.templateId] as const,
async ([open, templateId]) => {
if (!open) {
resetAll();
return;
}
if (templateId && templateId > 0) {
await loadDetail();
} else {
standardTemplateKey.value = defaultStandardKey();
await loadStandardFields(false);
captureBaseline();
}
},
);
</script>
<template>
<a-modal
:visible="visible"
:title="modalTitle"
:width="1320"
:mask-closable="false"
unmount-on-close
class="custom-template-modal"
@cancel="handleCloseRequest"
>
<a-spin :loading="loading || fieldsLoading" class="modal-spin">
<div class="modal-layout">
<aside class="panel-left">
<div class="panel-title">模板信息</div>
<a-form layout="vertical" class="left-form">
<a-form-item label="映射标准模板" required>
<a-select
v-model="standardTemplateKey"
:options="standardSelectOptions"
placeholder="选择标准模板"
@change="handleStandardTemplateChange"
/>
</a-form-item>
<a-form-item label="模板名称" :required="isCreateMode">
<a-input
v-if="isCreateMode"
v-model="name"
placeholder="例如:客户A导入模板"
allow-clear
/>
<div v-else class="readonly-text">{{ detail?.name || name }}</div>
</a-form-item>
<a-form-item v-if="isCreateMode" label="Excel 样例" required>
<div
class="upload-zone"
:class="{ 'has-file': !!file, 'is-dragover': dragActive }"
@click="triggerPicker"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<icon-upload v-if="!file" class="upload-icon" />
<icon-file v-else class="upload-icon is-file" />
<div v-if="file" class="upload-file-name">{{ file.name }}</div>
<div v-else class="upload-placeholder">点击或拖拽上传 Excel</div>
<div class="upload-tip">第一行需为列名表头,支持 .xlsx / .xls</div>
</div>
<input
ref="fileInputRef"
type="file"
accept=".xlsx,.xls"
class="hidden-input"
@change="onFileChange"
/>
</a-form-item>
</a-form>
<div v-if="!isCreateMode" class="status-row">
<span class="status-label">映射状态</span>
<a-tag v-if="detail?.mappingStatus === 'ready'" color="green" size="small">已就绪</a-tag>
<a-tag v-else color="orangered" size="small">待配置</a-tag>
</div>
</aside>
<section class="panel-right">
<div class="panel-title panel-title-right">
<span>字段映射</span>
<span v-if="sourceHeaders.length" class="header-count">已识别 {{ sourceHeaders.length }} 列</span>
</div>
<div v-if="!mappingFieldsVisible" class="mapping-empty">
<a-empty description="正在加载标准字段…" />
</div>
<template v-else>
<div v-if="isCreateMode && !sourceHeaders.length" class="mapping-hint">
标准字段已加载,上传 Excel 样例后可选择各列映射
</div>
<div v-for="group in groupedFields" :key="group.key" class="mapping-group">
<div class="mapping-group-title">{{ group.label }}</div>
<div class="mapping-table-wrap">
<table class="mapping-table">
<thead>
<tr>
<th>标准字段</th>
<th>上传模板列</th>
</tr>
</thead>
<tbody>
<tr v-for="field in group.items" :key="field.fieldKey">
<td class="field-label-col">
<span v-if="field.required" class="required-star">*</span>
{{ field.label }}
<span class="field-key">{{ field.fieldKey }}</span>
</td>
<td>
<a-select
v-model="mappingMap[field.fieldKey]"
allow-search
allow-clear
:disabled="!sourceHeaders.length"
:placeholder="sourceHeaders.length ? '选择上传模板列' : '请先上传 Excel'"
:options="sourceHeaderOptions"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mapping-summary">
<div v-if="missingRequiredLabels.length" class="summary-warn">
缺失必填映射:{{ missingRequiredLabels.join('、') }}
</div>
<div v-if="unmappedSourceHeaders.length" class="summary-muted">
未映射的上传列:{{ unmappedSourceHeaders.join('、') }}
</div>
</div>
</template>
</section>
</div>
</a-spin>
<template #footer>
<a-button @click="handleCloseRequest">取消</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">
{{ isCreateMode ? '创建并保存' : '保存映射' }}
</a-button>
</template>
</a-modal>
</template>
<style scoped lang="less">
.modal-spin {
display: block;
width: 100%;
}
.modal-layout {
display: flex;
gap: 0;
min-height: 560px;
max-height: 78vh;
}
.panel-left {
width: 300px;
flex-shrink: 0;
border-right: 1px solid #eef0f3;
padding-right: 20px;
overflow-y: auto;
}
.panel-right {
flex: 1;
min-width: 0;
padding-left: 20px;
overflow-y: auto;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 14px;
font-weight: 600;
color: #1d2129;
&::before {
content: '';
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--app-primary-color);
}
}
.panel-title-right {
justify-content: space-between;
}
.header-count {
font-size: 12px;
font-weight: 400;
color: #86909c;
&::before {
display: none;
}
}
.left-form {
:deep(.arco-form-item) {
margin-bottom: 14px;
}
:deep(.arco-form-item-label) {
font-size: 13px;
color: #4e5969;
}
}
.readonly-text {
min-height: 32px;
line-height: 32px;
font-size: 14px;
color: #1d2129;
}
.upload-zone {
border: 1px dashed #c9cdd4;
border-radius: 4px;
padding: 20px 16px;
text-align: center;
cursor: pointer;
background: #f7f8fa;
transition: border-color 0.2s, background 0.2s;
&:hover,
&.is-dragover {
border-color: var(--app-primary-color);
background: rgba(var(--primary-6), 0.04);
}
&.has-file {
border-style: solid;
border-color: rgba(var(--primary-6), 0.35);
background: #fff;
}
}
.upload-icon {
font-size: 28px;
color: #86909c;
margin-bottom: 8px;
&.is-file {
color: var(--app-primary-color);
}
}
.upload-placeholder {
font-size: 13px;
color: #4e5969;
margin-bottom: 4px;
}
.upload-file-name {
font-size: 13px;
font-weight: 500;
color: #1d2129;
margin-bottom: 4px;
word-break: break-all;
}
.upload-tip {
font-size: 12px;
color: #86909c;
}
.hidden-input {
display: none;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
border-top: 1px solid #eef0f3;
margin-top: 4px;
}
.status-label {
font-size: 13px;
color: #4e5969;
}
.mapping-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 420px;
}
.mapping-hint {
margin-bottom: 12px;
padding: 8px 12px;
font-size: 13px;
color: #4e5969;
background: #f7f8fa;
border-radius: 4px;
border: 1px solid #eef0f3;
}
.mapping-group {
margin-bottom: 14px;
}
.mapping-group-title {
font-size: 13px;
font-weight: 600;
color: #1d2129;
margin-bottom: 8px;
}
.mapping-table-wrap {
border: 1px solid #e8eaed;
border-radius: 4px;
overflow: hidden;
}
.mapping-table {
width: 100%;
border-collapse: collapse;
th,
td {
border-bottom: 1px solid #eef0f3;
padding: 8px 12px;
text-align: left;
vertical-align: middle;
}
th {
background: #f7f8fa;
font-size: 12px;
font-weight: 600;
color: #4e5969;
}
tr:last-child td {
border-bottom: none;
}
:deep(.arco-select) {
width: 100%;
min-width: 180px;
}
}
.field-label-col {
width: 40%;
min-width: 200px;
}
.required-star {
color: #f53f3f;
margin-right: 4px;
}
.field-key {
display: block;
margin-top: 2px;
font-size: 12px;
color: #86909c;
}
.mapping-summary {
margin-top: 8px;
font-size: 13px;
}
.summary-warn {
color: #f53f3f;
margin-bottom: 6px;
}
.summary-muted {
color: #86909c;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getOrderTemplateList } from '@/api/order';
import CustomTemplateModal from '@/views/order/placeOrder/components/CustomTemplateModal.vue';
defineOptions({ name: 'OrderTemplateMapping' });
const route = useRoute();
const router = useRouter();
const visible = ref(true);
const standardTemplateOptions = ref<Array<{ templateKey: string; label: string; id?: number }>>([]);
const templateId = computed(() => Number(route.query.templateId || 0));
async function loadStandardTemplates() {
const result = await getOrderTemplateList({ page: 1, pageSize: 100, type: 'standard' });
const items = (result.items || []).map((raw: any) => ({
templateKey: String(raw.templateKey ?? raw.TemplateKey ?? 'dedicated_small_parcel_v1'),
label: String(raw.name ?? raw.Name ?? raw.templateKey ?? '标准模板'),
id: Number(raw.id ?? raw.Id ?? 0) || undefined,
}));
standardTemplateOptions.value = items.length
? items
: [{ templateKey: 'dedicated_small_parcel_v1', label: '专线小包模板' }];
}
function handleClose() {
visible.value = false;
router.replace({ path: '/order/placeOrder' });
}
onMounted(async () => {
if (!templateId.value) {
handleClose();
return;
}
await loadStandardTemplates();
});
</script>
<template>
<CustomTemplateModal
v-model:visible="visible"
:template-id="templateId"
:standard-template-options="standardTemplateOptions"
@update:visible="(open) => !open && handleClose()"
@success="handleClose"
/>
</template>

View File

@ -163,12 +163,8 @@
<a-form-item :label="t('system.organization.form.name')" field="name">
<a-input v-model="orgFormState.name" :placeholder="t('system.organization.form.namePlaceholder')" />
</a-form-item>
<a-form-item :label="t('system.organization.form.code')" field="code">
<a-input
v-model="orgFormState.code"
:placeholder="t('system.organization.form.codePlaceholder')"
:disabled="orgIsEdit"
/>
<a-form-item v-if="orgIsEdit" :label="t('system.organization.form.code')" field="code">
<a-input v-model="orgFormState.code" disabled />
</a-form-item>
<a-form-item :label="t('system.organization.form.type')" field="type">
<a-select v-model="orgFormState.type" :placeholder="t('system.organization.form.typePlaceholder')">
@ -242,6 +238,15 @@
<a-form-item :label="t('system.user.form.avatar')" field="avatar">
<a-input v-model="userFormState.avatar" :placeholder="t('system.user.form.avatarPlaceholder')" />
</a-form-item>
<UserProfileFields
v-model:phone="userFormState.phone"
v-model:employee-no="userFormState.employeeNo"
v-model:wechat-id="userFormState.wechatId"
v-model:wechat-qr-files="userFormState.wechatQrFiles"
:employee-no-readonly="userIsEdit"
/>
<a-form-item v-if="!userIsEdit" :label="t('system.user.form.roles')" field="roleIds">
<a-select
v-model="userFormState.roleIds"
@ -279,6 +284,7 @@ import { IconPlus, IconRefresh, IconEdit, IconDelete } from '@arco-design/web-vu
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import UserProfileFields from '@/components/UserProfileFields/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
getInternalUserList,
@ -306,7 +312,12 @@ import {
findOrganizationByMenuKey,
filterOrganizationMenuTree,
} from '@/utils/orgLeftMenuTree'
import { generateOrganizationCode, flattenOrganizationTree } from '@/utils/organization-code'
import dayjs from 'dayjs'
import type { UserFormExtState } from '@/types/rbac-user-profile'
import { pickUserProfileExt, toUserProfilePayload } from '@/types/rbac-user-profile'
import { generateEmployeeNo } from '@/utils/user-employee-no'
import { urlToUploadFileList } from '@/utils/image-data-url'
const { t } = useI18n()
@ -360,6 +371,8 @@ const hotColumns = computed(() => [
{ key: 'avatar', label: t('system.user.table.avatar'), width: 80 },
{ key: 'name', label: t('system.user.table.realName'), width: 120 },
{ key: 'account', label: t('system.user.table.account'), width: 120 },
{ key: 'employeeNo', label: t('system.user.table.employeeNo'), width: 100 },
{ key: 'phone', label: t('system.user.table.phone'), width: 130 },
{ key: 'email', label: t('system.user.table.email'), width: 200 },
{ key: 'organizationName', label: t('system.user.table.orgName'), width: 150 },
{ key: 'roles', label: t('system.user.table.roles'), width: 220 },
@ -396,7 +409,6 @@ const orgModalTitle = computed(() =>
const orgFormRules = computed(() => ({
name: [{ required: true, message: t('system.organization.rules.name.required') }],
code: [{ required: true, message: t('system.organization.rules.code.required') }],
type: [{ required: true, message: t('system.organization.rules.type.required') }],
}))
@ -406,7 +418,7 @@ const userModalLoading = ref(false)
const userIsEdit = ref(false)
const userFormRef = ref()
const userCurrentId = ref('')
const userFormState = reactive<UserCreateDto & { organizationId?: string }>({
const userFormState = reactive<UserCreateDto & UserFormExtState & { organizationId?: string }>({
name: '',
account: '',
password: '',
@ -414,6 +426,11 @@ const userFormState = reactive<UserCreateDto & { organizationId?: string }>({
avatar: '',
organizationId: undefined,
roleIds: [],
phone: '',
employeeNo: '',
wechatId: '',
wechatQrCode: '',
wechatQrFiles: [],
})
const userModalTitle = computed(() =>
@ -480,6 +497,11 @@ function customCellRenderer(_row: number, col: number, _value: any, rowData: Use
if (key === 'organizationName') {
return rowData?.organizationName || '--'
}
if (key === 'phone' || key === 'employeeNo') {
const profile = pickUserProfileExt(rowData as Record<string, unknown>)
const value = key === 'phone' ? profile.phone : profile.employeeNo
return value || (rowData as Record<string, unknown>)?.[key] as string || '--'
}
if (key === 'roles') {
const roleNames = (rowData?.roles || []).map((r) => r.name).filter(Boolean)
if (!roleNames.length) return '--'
@ -609,11 +631,15 @@ function handleUserPageSizeChange(size: number) {
function handleAdd(parentId?: string) {
orgIsEdit.value = false
orgCurrentId.value = ''
const parentOrg = parentId
? findOrganizationByMenuKey(`org-${parentId}`, orgTree.value)
: undefined
const defaultType = parentOrg?.type === 1 ? 2 : parentOrg?.type === 2 ? 3 : parentOrg?.type === 3 ? 4 : 2
Object.assign(orgFormState, {
parentId: parentId || undefined,
name: '',
code: '',
type: 2,
type: defaultType,
leaderId: undefined,
order: 0,
description: '',
@ -663,7 +689,17 @@ async function handleOrgModalOk() {
})
Message.success(t('system.role.message.updateSuccess'))
} else {
await OrganizationService.postRbacOrganizationCreate({ requestBody: orgFormState })
const createData: OrganizationCreateDto = {
parentId: orgFormState.parentId,
name: orgFormState.name,
code: generateOrganizationCode(orgFormState.type, flattenOrganizationTree(orgTree.value)),
type: orgFormState.type,
leaderId: orgFormState.leaderId,
order: orgFormState.order,
description: orgFormState.description,
isEnabled: orgFormState.isEnabled,
}
await OrganizationService.postRbacOrganizationCreate({ requestBody: createData })
Message.success(t('system.role.message.createSuccess'))
}
@ -696,9 +732,16 @@ async function handleDelete(id?: string) {
})
}
function handleAddUser() {
async function handleAddUser() {
userIsEdit.value = false
userCurrentId.value = ''
let employeeNo = 'NE0001'
try {
const result = await getInternalUserList({ page: 1, pageSize: 1000 })
employeeNo = generateEmployeeNo(result.items || [])
} catch {
// keep default
}
Object.assign(userFormState, {
name: '',
account: '',
@ -707,6 +750,11 @@ function handleAddUser() {
avatar: '',
organizationId: selectedOrganization.value?.id,
roleIds: [],
phone: '',
employeeNo,
wechatId: '',
wechatQrCode: '',
wechatQrFiles: [],
})
userModalVisible.value = true
}
@ -716,6 +764,7 @@ async function handleEditUser(record: UserDto) {
userCurrentId.value = record.id || ''
try {
const userDetail = await UserService.getRbacUserGet(record.id)
const profile = pickUserProfileExt(userDetail as Record<string, unknown>)
Object.assign(userFormState, {
name: userDetail.name || '',
account: userDetail.account || '',
@ -723,8 +772,14 @@ async function handleEditUser(record: UserDto) {
avatar: userDetail.avatar || '',
organizationId: (userDetail as UserDto & { organizationId?: string }).organizationId,
roleIds: userDetail.roles?.map((r) => r.id).filter(Boolean) || [],
phone: profile.phone || '',
employeeNo: profile.employeeNo || '',
wechatId: profile.wechatId || '',
wechatQrCode: profile.wechatQrCode || '',
wechatQrFiles: urlToUploadFileList(profile.wechatQrCode, 'user-wx'),
})
} catch {
const profile = pickUserProfileExt(record as Record<string, unknown>)
Object.assign(userFormState, {
name: record.name || '',
account: record.account || '',
@ -732,6 +787,11 @@ async function handleEditUser(record: UserDto) {
avatar: record.avatar || '',
organizationId: (record as UserDto & { organizationId?: string }).organizationId,
roleIds: record.roles?.map((r) => r.id).filter(Boolean) || [],
phone: profile.phone || '',
employeeNo: profile.employeeNo || '',
wechatId: profile.wechatId || '',
wechatQrCode: profile.wechatQrCode || '',
wechatQrFiles: urlToUploadFileList(profile.wechatQrCode, 'user-wx'),
})
}
userModalVisible.value = true
@ -754,16 +814,19 @@ async function handleUserModalOk() {
userModalLoading.value = true
if (userIsEdit.value) {
const updateData: UserUpdateDto & { organizationId?: string } = {
const profilePayload = toUserProfilePayload(userFormState)
const updateData: UserUpdateDto & { organizationId?: string } & UserFormExtState = {
name: userFormState.name,
email: userFormState.email || undefined,
avatar: userFormState.avatar || undefined,
organizationId: userFormState.organizationId,
...profilePayload,
}
await UserService.putRbacUserUpdate(userCurrentId.value, updateData)
Message.success(t('system.role.message.updateSuccess'))
} else {
const createData: UserCreateDto & { organizationId?: string } = {
const profilePayload = toUserProfilePayload(userFormState)
const createData: UserCreateDto & { organizationId?: string } & UserFormExtState = {
name: userFormState.name,
account: userFormState.account,
password: userFormState.password,
@ -771,6 +834,7 @@ async function handleUserModalOk() {
avatar: userFormState.avatar || undefined,
roleIds: userFormState.roleIds || undefined,
organizationId: userFormState.organizationId,
...profilePayload,
}
await UserService.postRbacUserCreate(createData)
Message.success(t('system.user.message.createSuccess'))

View File

@ -114,6 +114,14 @@
<a-input v-model="formState.avatar" :placeholder="t('system.user.form.avatarPlaceholder')" />
</a-form-item>
<UserProfileFields
v-model:phone="formState.phone"
v-model:employee-no="formState.employeeNo"
v-model:wechat-id="formState.wechatId"
v-model:wechat-qr-files="formState.wechatQrFiles"
:employee-no-readonly="isEdit"
/>
<a-form-item v-if="!isEdit" :label="t('system.user.form.roles')" field="roleIds">
<a-select
v-model="formState.roleIds"
@ -165,8 +173,13 @@ import { handleApiError } from '@/utils/rbac-error-handler'
import { resolveAvatarUrl } from '@/utils/avatar'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import UserProfileFields from '@/components/UserProfileFields/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import dayjs from 'dayjs'
import type { UserFormExtState } from '@/types/rbac-user-profile'
import { pickUserProfileExt, toUserProfilePayload } from '@/types/rbac-user-profile'
import { generateEmployeeNo } from '@/utils/user-employee-no'
import { urlToUploadFileList } from '@/utils/image-data-url'
const loading = ref(false)
const dataSource = ref<UserDto[]>([])
@ -206,6 +219,8 @@ const hotColumns = computed(() => [
{ key: 'avatar', label: t('system.user.table.avatar'), width: 80 },
{ key: 'name', label: t('system.user.table.realName'), width: 120 },
{ key: 'account', label: t('system.user.table.account'), width: 120 },
{ key: 'employeeNo', label: t('system.user.table.employeeNo'), width: 100 },
{ key: 'phone', label: t('system.user.table.phone'), width: 130 },
{ key: 'email', label: t('system.user.table.email'), width: 200 },
{ key: 'organizationName', label: t('system.user.table.orgName'), width: 150 },
{ key: 'roles', label: t('system.user.table.roles'), width: 220 },
@ -228,6 +243,11 @@ function customCellRenderer(_row: number, col: number, _value: any, rowData: Use
if (key === 'organizationName') {
return rowData?.organizationName || '--'
}
if (key === 'phone' || key === 'employeeNo') {
const profile = pickUserProfileExt(rowData as Record<string, unknown>)
const value = key === 'phone' ? profile.phone : profile.employeeNo
return value || (rowData as Record<string, unknown>)?.[key] as string || '--'
}
if (key === 'roles') {
const roleNames = (rowData?.roles || []).map((r) => r.name).filter(Boolean)
if (!roleNames.length) return '--'
@ -278,7 +298,7 @@ const isEdit = ref(false)
const formRef = ref()
const currentId = ref('')
const formState = reactive<UserCreateDto & { id?: string }>({
const formState = reactive<UserCreateDto & UserFormExtState & { id?: string; organizationId?: string }>({
name: '',
account: '',
password: '',
@ -286,6 +306,11 @@ const formState = reactive<UserCreateDto & { id?: string }>({
avatar: '',
organizationId: undefined,
roleIds: [],
phone: '',
employeeNo: '',
wechatId: '',
wechatQrCode: '',
wechatQrFiles: [],
})
const formRules = computed(() => ({
@ -354,9 +379,16 @@ function handleSearch() {
}
// 新增用户
function handleAdd() {
async function handleAdd() {
isEdit.value = false
currentId.value = ''
let employeeNo = 'NE0001'
try {
const result = await getInternalUserList({ page: 1, pageSize: 1000 })
employeeNo = generateEmployeeNo(result.items || [])
} catch {
// keep default
}
Object.assign(formState, {
name: '',
account: '',
@ -365,6 +397,11 @@ function handleAdd() {
avatar: '',
organizationId: undefined,
roleIds: [],
phone: '',
employeeNo,
wechatId: '',
wechatQrCode: '',
wechatQrFiles: [],
})
modalVisible.value = true
}
@ -377,6 +414,7 @@ async function handleEdit(record: UserDto) {
// 获取用户详情(包含组织ID)
try {
const userDetail = await UserService.getRbacUserGet(record.id)
const profile = pickUserProfileExt(userDetail as Record<string, unknown>)
Object.assign(formState, {
name: userDetail.name || '',
account: userDetail.account || '',
@ -384,9 +422,14 @@ async function handleEdit(record: UserDto) {
avatar: userDetail.avatar || '',
organizationId: (userDetail as any).organizationId,
roleIds: userDetail.roles?.map(r => r.id).filter(Boolean) || [],
phone: profile.phone || '',
employeeNo: profile.employeeNo || '',
wechatId: profile.wechatId || '',
wechatQrCode: profile.wechatQrCode || '',
wechatQrFiles: urlToUploadFileList(profile.wechatQrCode, 'user-wx'),
})
} catch (error) {
// 如果获取详情失败,使用列表中的数据
const profile = pickUserProfileExt(record as Record<string, unknown>)
Object.assign(formState, {
name: record.name || '',
account: record.account || '',
@ -394,6 +437,11 @@ async function handleEdit(record: UserDto) {
avatar: record.avatar || '',
organizationId: (record as any).organizationId,
roleIds: record.roles?.map(r => r.id).filter(Boolean) || [],
phone: profile.phone || '',
employeeNo: profile.employeeNo || '',
wechatId: profile.wechatId || '',
wechatQrCode: profile.wechatQrCode || '',
wechatQrFiles: urlToUploadFileList(profile.wechatQrCode, 'user-wx'),
})
}
@ -419,16 +467,19 @@ async function handleModalOk() {
modalLoading.value = true
if (isEdit.value) {
const updateData: UserUpdateDto & { organizationId?: string } = {
const profilePayload = toUserProfilePayload(formState)
const updateData: UserUpdateDto & { organizationId?: string } & UserFormExtState = {
name: formState.name,
email: formState.email || undefined,
avatar: formState.avatar || undefined,
organizationId: formState.organizationId,
...profilePayload,
}
await UserService.putRbacUserUpdate(currentId.value!, updateData)
Message.success('更新成功')
} else {
const createData: UserCreateDto & { organizationId?: string } = {
const profilePayload = toUserProfilePayload(formState)
const createData: UserCreateDto & { organizationId?: string } & UserFormExtState = {
name: formState.name,
account: formState.account,
password: formState.password,
@ -436,6 +487,7 @@ async function handleModalOk() {
avatar: formState.avatar || undefined,
roleIds: formState.roleIds || undefined,
organizationId: formState.organizationId,
...profilePayload,
}
await UserService.postRbacUserCreate(createData)
Message.success(t('system.user.message.createSuccess'))

View File

@ -35,30 +35,6 @@
<span class="label">{{ $t('system.warehouseAddress.fields.shippingAddress') }}:</span>
<span class="value">{{ item.shippingAddress || '-' }}</span>
</div>
<div class="card-row">
<span class="label">{{ $t('system.warehouseAddress.fields.servicePhone') }}:</span>
<span class="value">{{ item.servicePhone || '-' }}</span>
</div>
<div class="card-row card-row-wechat">
<span class="label">{{ $t('system.warehouseAddress.fields.serviceWechat') }}:</span>
<div class="wechat-upload">
<a-upload
v-model:file-list="item.serviceWechatFiles"
list-type="picture-card"
:limit="1"
accept="image/*"
:custom-request="(opt) => handleWechatCustomRequest(opt)"
@change="(fileList) => handleCardWechatFileListChange(item, fileList)"
>
<template #upload-button>
<div class="upload-placeholder">
<icon-plus class="upload-icon" />
<span>{{ $t('system.warehouseAddress.uploadWechat') }}</span>
</div>
</template>
</a-upload>
</div>
</div>
</div>
<div class="card-actions">
<a-space>
@ -123,32 +99,13 @@
:auto-size="{ minRows: 2 }"
/>
</a-form-item>
<a-form-item :label="$t('system.warehouseAddress.fields.servicePhone')">
<a-input v-model="formData.servicePhone" :placeholder="$t('system.warehouseAddress.placeholder.input')" />
</a-form-item>
<a-form-item :label="$t('system.warehouseAddress.fields.serviceWechat')">
<a-upload
v-model:file-list="formData.serviceWechatFiles"
list-type="picture-card"
:limit="1"
accept="image/*"
:custom-request="(opt) => handleWechatCustomRequest(opt)"
>
<template #upload-button>
<div class="upload-placeholder">
<icon-plus class="upload-icon" />
<span>{{ $t('system.warehouseAddress.uploadWechat') }}</span>
</div>
</template>
</a-upload>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, nextTick } from 'vue';
import { ref, reactive, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconPlus, IconHome } from '@arco-design/web-vue/es/icon';
import { Modal } from '@arco-design/web-vue';
@ -167,8 +124,6 @@ defineOptions({
name: 'WarehouseAddress',
});
const MAX_WECHAT_IMAGE_BYTES = 2 * 1024 * 1024;
const list = ref<any[]>([]);
const modalVisible = ref(false);
const isEdit = ref(false);
@ -220,100 +175,8 @@ const formData = reactive({
recipient: '',
contactPhone: '',
shippingAddress: '',
servicePhone: '',
serviceWechat: '',
serviceWechatFiles: [] as any[],
});
function urlsToFileList(urls: string[]): any[] {
if (!Array.isArray(urls)) return [];
return urls
.filter((u) => u != null && String(u).trim() !== '')
.map((url, i) => ({
uid: `wh-wx-${i}-${String(url).slice(0, 24)}`,
name: 'wechat-qrcode',
url: String(url),
status: 'done',
}));
}
function fileItemToUrl(f: any): string {
if (f == null) return '';
if (typeof f === 'string') return f;
const u = f.response?.url ?? f.url;
return u != null && String(u).trim() !== '' ? String(u) : '';
}
function fileListToUrls(files: any[]): string[] {
if (!Array.isArray(files)) return [];
return files.map(fileItemToUrl).filter(Boolean);
}
function readFileAsDataUrl(file: File): Promise<string> {
if (file.size > MAX_WECHAT_IMAGE_BYTES) {
return Promise.reject(new Error(t('system.warehouseAddress.message.imageTooLarge')));
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('read failed'));
reader.readAsDataURL(file);
});
}
async function handleWechatCustomRequest(options: any) {
const { fileItem, onSuccess, onError } = options;
const file = fileItem?.file as File | undefined;
if (!file) {
onError?.();
return;
}
try {
const dataUrl = await readFileAsDataUrl(file);
fileItem.url = dataUrl;
fileItem.status = 'done';
fileItem.response = { url: dataUrl };
onSuccess?.({ url: dataUrl });
} catch (e: any) {
fileItem.status = 'error';
$baseMessage(e?.message || t('system.warehouseAddress.message.imageTooLarge'), 'warning');
onError?.(e);
}
}
function buildWarehousePayload(item: any, serviceWechatFiles: string[]) {
return {
warehouseName: item.warehouseName,
warehouseType: item.warehouseType,
recipient: item.recipient,
contactPhone: item.contactPhone,
shippingAddress: item.shippingAddress,
servicePhone: item.servicePhone,
serviceWechat: item.serviceWechat,
serviceWechatFiles,
};
}
async function handleCardWechatFileListChange(item: any, fileList: any[]) {
if (item?.id == null) return;
const pending = fileList.some((f) => f.status === 'uploading' || f.status === 'init');
if (pending) return;
if (fileList.some((f) => f.status === 'error')) return;
await nextTick();
const urls = fileListToUrls(fileList);
try {
await updateWarehouse({
id: item.id,
...buildWarehousePayload(item, urls),
});
$baseMessage(t('system.warehouseAddress.message.editSuccess'), 'success');
} catch (e: any) {
$baseMessage(e?.message || t('system.warehouseAddress.message.wechatQrSaveFailed'), 'error');
await loadList();
}
}
function handleDelete(item: any) {
if (item?.id == null) return;
Modal.confirm({
@ -335,10 +198,7 @@ function handleDelete(item: any) {
async function loadList() {
try {
const { items } = await getWarehouseList({ page: 1, pageSize: 200 });
list.value = (items || []).map((it: any) => ({
...it,
serviceWechatFiles: urlsToFileList(it.serviceWechatFiles),
}));
list.value = items || [];
} catch (e: any) {
$baseMessage(e?.message || '加载仓库列表失败', 'error');
list.value = [];
@ -354,9 +214,6 @@ function handleAdd() {
recipient: '',
contactPhone: '',
shippingAddress: '',
servicePhone: '',
serviceWechat: '',
serviceWechatFiles: [],
});
modalVisible.value = true;
}
@ -383,9 +240,6 @@ function handleEdit(item: any) {
recipient: item.recipient,
contactPhone: item.contactPhone,
shippingAddress: item.shippingAddress,
servicePhone: item.servicePhone,
serviceWechat: item.serviceWechat,
serviceWechatFiles: Array.isArray(item.serviceWechatFiles) ? [...item.serviceWechatFiles] : [],
});
modalVisible.value = true;
}
@ -395,16 +249,12 @@ async function handleModalOk() {
$baseMessage(t('system.warehouseAddress.message.nameRequired'), 'warning');
return;
}
const urls = fileListToUrls(formData.serviceWechatFiles || []);
const payload = {
warehouseName: formData.warehouseName.trim(),
warehouseType: formData.warehouseType,
recipient: formData.recipient,
contactPhone: formData.contactPhone,
shippingAddress: formData.shippingAddress,
servicePhone: formData.servicePhone,
serviceWechat: formData.serviceWechat,
serviceWechatFiles: urls,
};
try {
if (isEdit.value && editingId.value != null) {
@ -636,38 +486,5 @@ onMounted(() => {
word-break: break-all;
}
}
.card-row-wechat {
align-items: flex-start;
.wechat-upload {
:deep(.arco-upload-list) {
margin: 0;
}
:deep(.arco-upload-list-item) {
margin: 0;
}
:deep(.arco-upload-picture-card) {
width: 80px;
height: 80px;
}
}
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8c8c8c;
font-size: 12px;
.upload-icon {
font-size: 20px;
margin-bottom: 4px;
}
}
</style>