feat: 用户扩展、Nana双Tab、仓库与客服入口优化
扩展用户管理字段与组织编码生成,顶部改为仓库下拉;Nana 分 AI 与专属顾问 Tab,仓库页移除客服字段,并完善扫描语音与订单相关页面。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e356ddb9cf
commit
6a7f8f1bcb
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
2
src/components.d.ts
vendored
@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,228 +26,91 @@ 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>
|
||||
<a-select
|
||||
v-model="serviceWarehouseSelect"
|
||||
style="width: 100%"
|
||||
:loading="serviceWarehouseListLoading"
|
||||
popup-container="body"
|
||||
>
|
||||
<a-option
|
||||
v-for="opt in serviceWarehouseSelectData"
|
||||
:key="String(opt.value)"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</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')"
|
||||
<div class="contact-service" :class="`contact-service--${variant}`">
|
||||
<a-select
|
||||
v-model="serviceWarehouseSelect"
|
||||
class="contact-service__select"
|
||||
:loading="serviceWarehouseListLoading"
|
||||
:placeholder="t('nav.contactService.selectWarehouse')"
|
||||
popup-container="body"
|
||||
allow-search
|
||||
>
|
||||
<a
|
||||
class="contact-service-trigger"
|
||||
:class="`contact-service-trigger--${variant}`"
|
||||
@click.prevent
|
||||
<a-option
|
||||
v-for="opt in serviceWarehouseSelectData"
|
||||
:key="String(opt.value)"
|
||||
:value="opt.value"
|
||||
>
|
||||
<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>
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</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 {
|
||||
padding: 0 8px;
|
||||
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;
|
||||
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;
|
||||
:deep(.arco-select-view) {
|
||||
min-width: 120px;
|
||||
height: 32px;
|
||||
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>
|
||||
|
||||
@ -5,60 +5,84 @@
|
||||
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>
|
||||
<div class="nana-chat">
|
||||
<div ref="chatBoxRef" class="nana-chat__messages">
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
:class="['nana-chat__row', msg.role === 'user' ? 'is-user' : 'is-assistant']"
|
||||
>
|
||||
<img
|
||||
v-if="msg.role === 'assistant'"
|
||||
:src="nanaImg"
|
||||
alt="Nana"
|
||||
class="nana-chat__avatar"
|
||||
/>
|
||||
<div class="nana-chat__bubble">
|
||||
<pre class="nana-chat__text">{{ msg.content }}</pre>
|
||||
<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
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
:class="['nana-chat__row', msg.role === 'user' ? 'is-user' : 'is-assistant']"
|
||||
>
|
||||
<img
|
||||
v-if="msg.role === 'assistant'"
|
||||
:src="nanaImg"
|
||||
alt="Nana"
|
||||
class="nana-chat__avatar"
|
||||
/>
|
||||
<div class="nana-chat__bubble">
|
||||
<pre class="nana-chat__text">{{ msg.content }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="chatLoading" class="nana-chat__row is-assistant">
|
||||
<img :src="nanaImg" alt="Nana" class="nana-chat__avatar" />
|
||||
<div class="nana-chat__bubble is-typing">
|
||||
<span class="dot" />
|
||||
<span class="dot" />
|
||||
<span class="dot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="chatLoading" class="nana-chat__row is-assistant">
|
||||
<img :src="nanaImg" alt="Nana" class="nana-chat__avatar" />
|
||||
<div class="nana-chat__bubble is-typing">
|
||||
<span class="dot" />
|
||||
<span class="dot" />
|
||||
<span class="dot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nana-chat__input">
|
||||
<a-textarea
|
||||
v-model="inputText"
|
||||
:placeholder="t('nanaAssistant.inputPlaceholder')"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
:disabled="chatLoading || !providerKey"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="chatLoading"
|
||||
:disabled="!canSend"
|
||||
@click="sendMessage"
|
||||
>
|
||||
{{ t('nanaAssistant.send') }}
|
||||
</a-button>
|
||||
</div>
|
||||
<p v-if="!providerKey && !providerLoading" class="nana-chat__hint">
|
||||
{{ t('nanaAssistant.noProvider') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="nana-chat__input">
|
||||
<a-textarea
|
||||
v-model="inputText"
|
||||
:placeholder="t('nanaAssistant.inputPlaceholder')"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
:disabled="chatLoading || !providerKey"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="chatLoading"
|
||||
:disabled="!canSend"
|
||||
@click="sendMessage"
|
||||
>
|
||||
{{ t('nanaAssistant.send') }}
|
||||
</a-button>
|
||||
</div>
|
||||
<p v-if="!providerKey && !providerLoading" class="nana-chat__hint">
|
||||
{{ 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;
|
||||
ensureGreeting();
|
||||
scrollToBottom();
|
||||
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>
|
||||
|
||||
99
src/components/UserProfileFields/index.vue
Normal file
99
src/components/UserProfileFields/index.vue
Normal 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>
|
||||
52
src/components/icons/ExcelFileIcon.vue
Normal file
52
src/components/icons/ExcelFileIcon.vue
Normal 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>
|
||||
51
src/composables/useOperationScanFeedback.ts
Normal file
51
src/composables/useOperationScanFeedback.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
src/composables/useOperationVoiceBroadcast.ts
Normal file
66
src/composables/useOperationVoiceBroadcast.ts
Normal 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 };
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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': '打印标签',
|
||||
|
||||
@ -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',
|
||||
|
||||
49
src/types/rbac-user-profile.ts
Normal file
49
src/types/rbac-user-profile.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
30
src/utils/image-data-url.ts
Normal file
30
src/utils/image-data-url.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
62
src/utils/organization-code.ts
Normal file
62
src/utils/organization-code.ts
Normal 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, '\\$&');
|
||||
}
|
||||
@ -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',
|
||||
|
||||
17
src/utils/user-employee-no.ts
Normal file
17
src/utils/user-employee-no.ts
Normal 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')}`
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -373,30 +368,30 @@ onMounted(() => {
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.large-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.input-with-unit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
.weight-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.unit-text {
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
min-width: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form-row-inline {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@ -455,11 +450,11 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
|
||||
|
||||
.reset-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
|
||||
.submit-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -385,7 +380,7 @@ onMounted(() => {
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.large-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
@ -467,11 +462,11 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
|
||||
|
||||
.reset-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
|
||||
.submit-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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,23 +302,107 @@
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
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)!;
|
||||
const waybillNo = row.waybillNo || row.serviceNo || String(id);
|
||||
try {
|
||||
const result = await postOrderTrackUpsertSmallBag({ id });
|
||||
resultBlocks.push(formatTrackUpsertResult(waybillNo, result));
|
||||
} 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 () => {
|
||||
const resultBlocks: string[] = [];
|
||||
for (const row of rows) {
|
||||
const id = getOrderRowId(row)!;
|
||||
const waybillNo = row.waybillNo || row.serviceNo || String(id);
|
||||
try {
|
||||
const result = await postOrderTrackUpsertSmallBag({ id });
|
||||
resultBlocks.push(formatTrackUpsertResult(waybillNo, result));
|
||||
} catch (e: any) {
|
||||
resultBlocks.push(`【${waybillNo}】失败:${e?.message || '请求异常'}`);
|
||||
}
|
||||
}
|
||||
trackUpsertResultText.value = resultBlocks.join('\n\n');
|
||||
trackUpsertModalVisible.value = true;
|
||||
await runTrackUpsertBatch(rows);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
@ -339,23 +429,40 @@
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
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)!;
|
||||
const waybillNo = row.waybillNo || row.serviceNo || String(id);
|
||||
try {
|
||||
const result = await postOrderDownstreamForwardCreateOne({ id });
|
||||
resultBlocks.push(formatDownstreamForwardResult(waybillNo, result));
|
||||
} 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 () => {
|
||||
const resultBlocks: string[] = [];
|
||||
for (const row of rows) {
|
||||
const id = getOrderRowId(row)!;
|
||||
const waybillNo = row.waybillNo || row.serviceNo || String(id);
|
||||
try {
|
||||
const result = await postOrderDownstreamForwardCreateOne({ id });
|
||||
resultBlocks.push(formatDownstreamForwardResult(waybillNo, result));
|
||||
} catch (e: any) {
|
||||
resultBlocks.push(`【${waybillNo}】失败:${e?.message || '请求异常'}`);
|
||||
}
|
||||
}
|
||||
downstreamForwardResultText.value = resultBlocks.join('\n\n');
|
||||
downstreamForwardModalVisible.value = true;
|
||||
await runDownstreamForwardBatch(rows);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
@ -446,12 +553,20 @@
|
||||
if (!rows.length) return;
|
||||
try {
|
||||
await withOrderActionLoading('pay', async () => {
|
||||
for (const row of rows) {
|
||||
await postOrderPay({ id: getOrderRowId(row)! });
|
||||
}
|
||||
Message.success('支付请求已提交');
|
||||
await runSelectedOrderBatch({
|
||||
action: 'pay',
|
||||
rows,
|
||||
loadingKey: 'pay',
|
||||
successMessage: '支付请求已提交',
|
||||
syncRunner: async () => {
|
||||
for (const row of rows) {
|
||||
await postOrderPay({ id: getOrderRowId(row)! });
|
||||
}
|
||||
Message.success('支付请求已提交');
|
||||
await refreshListAndTabCounts();
|
||||
},
|
||||
});
|
||||
notifyCustomerBalanceChanged();
|
||||
await refreshListAndTabCounts();
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.message !== 'busy') {
|
||||
@ -465,11 +580,19 @@
|
||||
if (!rows.length) return;
|
||||
try {
|
||||
await withOrderActionLoading('predeclare', async () => {
|
||||
for (const row of rows) {
|
||||
await postOrderPreDeclare({ id: getOrderRowId(row)! });
|
||||
}
|
||||
Message.success('预报已提交');
|
||||
await refreshListAndTabCounts();
|
||||
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 () => {
|
||||
for (const row of rows) {
|
||||
await postOrderIntercept({ id: getOrderRowId(row)! });
|
||||
}
|
||||
Message.success('拦截已提交');
|
||||
await refreshListAndTabCounts();
|
||||
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 () => {
|
||||
for (const row of rows) {
|
||||
await postOrderCancelIntercept({ id: getOrderRowId(row)! });
|
||||
}
|
||||
Message.success('已取消拦截');
|
||||
await refreshListAndTabCounts();
|
||||
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 () => {
|
||||
for (const row of rows) {
|
||||
await postOrderDelete({ id: getOrderRowId(row)! });
|
||||
}
|
||||
Message.success('已删除');
|
||||
await refreshListAndTabCounts();
|
||||
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 }> = [];
|
||||
for (const row of rows) {
|
||||
const { printUrl } = await postOrderPrint({ id: getOrderRowId(row)! });
|
||||
if (isUsablePrintUrl(printUrl)) {
|
||||
items.push(buildPrintItem(row, printUrl));
|
||||
}
|
||||
}
|
||||
if (!items.length) {
|
||||
Message.info('未获取到可打印文件,已打开打印弹窗供预览布局');
|
||||
batchPrintVisible.value = true;
|
||||
return;
|
||||
}
|
||||
batchPrintItems.value = items;
|
||||
batchPrintVisible.value = true;
|
||||
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)) {
|
||||
printItems.push(buildPrintItem(row, printUrl));
|
||||
}
|
||||
}
|
||||
if (!printItems.length) {
|
||||
Message.info('未获取到可打印文件,已打开打印弹窗供预览布局');
|
||||
batchPrintVisible.value = true;
|
||||
return;
|
||||
}
|
||||
batchPrintItems.value = printItems;
|
||||
batchPrintVisible.value = true;
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.message !== 'busy') {
|
||||
@ -668,31 +849,40 @@
|
||||
if (!rows.length) return;
|
||||
try {
|
||||
await withOrderActionLoading('markProcessed', async () => {
|
||||
const result = await postOrderHandleStatusUpdate({
|
||||
orderIds: rows.map((row) => Number(getOrderRowId(row)!)),
|
||||
handleStatus: 'processed',
|
||||
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',
|
||||
});
|
||||
if (result.failures?.length) {
|
||||
const lines = result.failures
|
||||
.slice(0, 5)
|
||||
.map((f) => `${f.serviceNo || f.orderId}: ${f.reason}`)
|
||||
.join('\n');
|
||||
if (result.successCount > 0) {
|
||||
Message.warning(
|
||||
t('order.myOrders.batch.partialSuccess', {
|
||||
success: result.successCount,
|
||||
fail: result.failures.length,
|
||||
detail: lines,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
Message.error(lines || t('order.myOrders.handleStatus.failed'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Message.success(t('order.myOrders.handleStatus.success'));
|
||||
}
|
||||
await refreshListAndTabCounts();
|
||||
},
|
||||
});
|
||||
if (result.failures?.length) {
|
||||
const lines = result.failures
|
||||
.slice(0, 5)
|
||||
.map((f) => `${f.serviceNo || f.orderId}: ${f.reason}`)
|
||||
.join('\n');
|
||||
if (result.successCount > 0) {
|
||||
Message.warning(
|
||||
t('order.myOrders.batch.partialSuccess', {
|
||||
success: result.successCount,
|
||||
fail: result.failures.length,
|
||||
detail: lines,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
Message.error(lines || t('order.myOrders.handleStatus.failed'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
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>
|
||||
|
||||
797
src/views/order/placeOrder/components/CustomTemplateModal.vue
Normal file
797
src/views/order/placeOrder/components/CustomTemplateModal.vue
Normal 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
50
src/views/order/templateMapping/index.vue
Normal file
50
src/views/order/templateMapping/index.vue
Normal 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>
|
||||
@ -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'))
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user