diff --git a/src/api/channel/kpiArea.ts b/src/api/channel/kpiArea.ts index 18dc30b..a05871b 100644 --- a/src/api/channel/kpiArea.ts +++ b/src/api/channel/kpiArea.ts @@ -1,5 +1,6 @@ import { axiosInstance } from '@/api/rbac' import { assertBusinessOk, omsUrl, parseListPayload, unwrapData } from '@/api/oms/common' +import * as XLSX from 'xlsx' function sanitizePayload(body?: Record) { if (!body || typeof body !== 'object') return body @@ -119,37 +120,127 @@ export type KpiAreaInfoImportResult = { failures: Array<{ rowNumber: number; reason: string }> } -/** GET /channel/kpi-area/info/import-template */ -export async function downloadKpiAreaInfoImportTemplate( +export type KpiAreaInfoExcelRow = { + 国家代码: string + 匹配维度: string + 匹配方式: string + '州/省': string + 城市: string + 起始邮编: string + 截止邮编: string +} + +const KPI_AREA_ENUM_ROWS = [ + { 枚举类型: '匹配维度', 编码: 'country', 名称: '仅国家' }, + { 枚举类型: '匹配维度', 编码: 'zip', 名称: '邮编' }, + { 枚举类型: '匹配维度', 编码: 'state', 名称: '州/省' }, + { 枚举类型: '匹配维度', 编码: 'city', 名称: '城市' }, + { 枚举类型: '匹配方式', 编码: 'prefix', 名称: '前缀' }, + { 枚举类型: '匹配方式', 编码: 'exact', 名称: '精确' }, + { 枚举类型: '匹配方式', 编码: 'range', 名称: '区间' }, + { 枚举类型: '匹配方式', 编码: 'all', 名称: '不限' }, +] + +const KPI_AREA_TEMPLATE_SAMPLE_ROWS: KpiAreaInfoExcelRow[] = [ + { + 国家代码: 'US', + 匹配维度: 'zip', + 匹配方式: 'range', + '州/省': '', + 城市: '', + 起始邮编: '90000', + 截止邮编: '90199', + }, + { + 国家代码: 'US', + 匹配维度: 'zip', + 匹配方式: 'prefix', + '州/省': '', + 城市: '', + 起始邮编: '90', + 截止邮编: '', + }, + { + 国家代码: 'US', + 匹配维度: 'state', + 匹配方式: 'exact', + '州/省': 'CA', + 城市: '', + 起始邮编: '', + 截止邮编: '', + }, +] + +function writeKpiAreaInfoWorkbook(rows: KpiAreaInfoExcelRow[], fileName: string) { + const dataRows = + rows.length > 0 + ? rows + : [ + { + 国家代码: '', + 匹配维度: '', + 匹配方式: '', + '州/省': '', + 城市: '', + 起始邮编: '', + 截止邮编: '', + }, + ] + const wb = XLSX.utils.book_new() + const templateSheet = XLSX.utils.json_to_sheet(dataRows) + templateSheet['!cols'] = [ + { wch: 12 }, + { wch: 12 }, + { wch: 12 }, + { wch: 12 }, + { wch: 12 }, + { wch: 12 }, + { wch: 12 }, + ] + const enumSheet = XLSX.utils.json_to_sheet(KPI_AREA_ENUM_ROWS) + enumSheet['!cols'] = [{ wch: 12 }, { wch: 12 }, { wch: 12 }] + XLSX.utils.book_append_sheet(wb, templateSheet, '导入模板') + XLSX.utils.book_append_sheet(wb, enumSheet, '枚举值') + XLSX.writeFile(wb, fileName.endsWith('.xlsx') ? fileName : `${fileName}.xlsx`) +} + +/** 前端生成导入模板(Sheet1 导入模板 / Sheet2 枚举值) */ +export function downloadKpiAreaInfoImportTemplate( areaType: 'blacklist' | 'remote' | 'price', fileName?: string ) { - const res = await axiosInstance.get(omsUrl('/channel/kpi-area/info/import-template'), { - params: { areaType }, - 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 ?? '下载失败') - } const defaultNames: Record = { blacklist: '黑名单分区明细导入模板.xlsx', remote: '偏远分区明细导入模板.xlsx', price: '价格分区明细导入模板.xlsx', } - const name = fileName || defaultNames[areaType] || '分区明细导入模板.xlsx' - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = name.endsWith('.xlsx') ? name : `${name}.xlsx` - a.style.display = 'none' - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + writeKpiAreaInfoWorkbook( + KPI_AREA_TEMPLATE_SAMPLE_ROWS, + fileName || defaultNames[areaType] || '分区明细导入模板.xlsx' + ) +} + +export function exportKpiAreaInfoExcel(rows: KpiAreaInfoExcelRow[], fileName: string) { + writeKpiAreaInfoWorkbook(rows, fileName) +} + +export function mapKpiAreaInfoToExcelRow(item: Record): KpiAreaInfoExcelRow { + const pick = (...keys: string[]) => { + for (const key of keys) { + const value = item[key] + if (value != null && String(value).trim()) return String(value).trim() + } + return '' + } + return { + 国家代码: pick('countryCode', 'CountryCode'), + 匹配维度: pick('matchDim', 'MatchDim'), + 匹配方式: pick('matchMode', 'MatchMode'), + '州/省': pick('province', 'Province'), + 城市: pick('city', 'City'), + 起始邮编: pick('leftPostcode', 'leftPostCode', 'LeftPostcode', 'LeftPostCode'), + 截止邮编: pick('rightPostcode', 'rightPostCode', 'RightPostcode', 'RightPostCode'), + } } /** POST /channel/kpi-area/info/import multipart */ diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index f79049c..757deb5 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -5460,6 +5460,8 @@ export default { 'channel.area.actions.addDetail': 'Add area detail', 'channel.area.actions.edit': 'Edit', 'channel.area.actions.delete': 'Delete', + 'channel.area.actions.import': 'Import', + 'channel.area.actions.export': 'Export', 'channel.area.pagination.total': 'Total {count} records', 'channel.area.pagination.prev': 'Previous', 'channel.area.pagination.next': 'Next', @@ -5554,6 +5556,23 @@ export default { 'channel.area.message.destCountryQuickTagMiss': 'Country not matched. Please select from the dropdown.', 'channel.area.message.editMissingRow': 'Record not found. Please refresh and try again.', 'channel.area.message.deleteFailed': 'Delete failed. Please try again later.', + 'channel.area.import.modalTitle': 'Import area details', + 'channel.area.import.tip': + 'Download the template first. Country code is required (country name is filled from the code). Put match dimension/mode codes from the Enum sheet. One row, one dimension: postcode rules if filled; state only = state match; state+city = city match; empty = country fallback.', + 'channel.area.import.downloadTemplate': 'Download template', + 'channel.area.import.submit': 'Import', + 'channel.area.import.downloadSuccess': 'Template downloaded', + 'channel.area.import.downloadFailed': 'Failed to download template', + 'channel.area.import.needSelectArea': 'Select an area on the left first', + 'channel.area.import.needFile': 'Please choose an Excel file', + 'channel.area.import.success': 'Import succeeded', + 'channel.area.import.failed': 'Import failed', + 'channel.area.import.partialSuccess': 'Some rows failed. See details below.', + 'channel.area.import.allFailed': 'Import failed. Please check the file.', + 'channel.area.import.resultSummary': '{success} succeeded, {fail} failed', + 'channel.area.import.failRow': 'Row {row}: {reason}', + 'channel.area.export.success': 'Exported {count} row(s)', + 'channel.area.export.failed': 'Export failed', // Channel - Weight Rules 'channel.weightRules.listTitle': 'List', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 208632f..184de88 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -5569,7 +5569,7 @@ export default { 'channel.area.search.button.query': '查询', 'channel.area.search.button.reset': '重置', 'channel.area.import.modalTitle': '导入分区明细', - 'channel.area.import.tip': '请先下载模板。国家代码必填。一行一个维度:填邮编则为邮编规则(只填起始=前缀;起止相同=精确;起止不同=闭区间);不填邮编只填州=州匹配;州+城市=城市匹配;都空=仅国家兜底。', + 'channel.area.import.tip': '请先下载模板。国家代码必填(无需填国家名称,导入时按代码自动带出)。匹配维度、匹配方式请填 Sheet「枚举值」中的编码。一行一个维度:填邮编则为邮编规则(只填起始=前缀;起止相同=精确;起止不同=闭区间);不填邮编只填州=州匹配;州+城市=城市匹配;都空=仅国家兜底。', 'channel.area.import.downloadTemplate': '下载模板', 'channel.area.import.submit': '开始导入', 'channel.area.import.downloadSuccess': '模板下载成功', @@ -5582,7 +5582,10 @@ export default { 'channel.area.import.allFailed': '导入失败,请检查文件内容', 'channel.area.import.resultSummary': '成功 {success} 条,失败 {fail} 条', 'channel.area.import.failRow': '第 {row} 行:{reason}', + 'channel.area.export.success': '已导出 {count} 条', + 'channel.area.export.failed': '导出失败', 'channel.area.actions.import': '导入', + 'channel.area.actions.export': '导出', // 渠道配置页通用 'channel.config.emptySelect': '请先在左侧选择配置项', diff --git a/src/utils/printPreview.ts b/src/utils/printPreview.ts index 2a1ac2c..46a1fa1 100644 --- a/src/utils/printPreview.ts +++ b/src/utils/printPreview.ts @@ -271,12 +271,25 @@ async function fetchPreviewBlob(rawUrl: string): Promise { const errors: string[] = [] const isCos = /^https?:\/\//i.test(raw) && isCosAbsoluteUrl(raw) - // 1) 鉴权 stream(含签名 URL 服务端代拉)— 生产主路径,无浏览器 CORS + // 开发:测试 Admin 可能尚未部署 /file-storage,先走同源 COS 代理 + if (import.meta.env.DEV && isCos) { + try { + const blob = await fetchBlobViaCosProxy(raw) + const bytes = await normalizePdfBytes(blob, raw) + return new Blob([bytes], { + type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw), + }) + } catch (e: unknown) { + errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败') + } + } + + // 鉴权 stream(含签名 URL 服务端代拉)— 生产主路径,无浏览器 CORS const streamed = await fetchViaFileStorageStream(raw, errors) if (streamed) return streamed - // 2) 同源 /__cos__ 代理(开发 Vite;生产需 nginx 配好) - if (isCos) { + // 同源 /__cos__ 代理(生产 nginx;开发上面已试过则跳过) + if (isCos && !import.meta.env.DEV) { try { const blob = await fetchBlobViaCosProxy(raw) const bytes = await normalizePdfBytes(blob, raw) diff --git a/src/views/channel/components/KpiAreaImportModal.vue b/src/views/channel/components/KpiAreaImportModal.vue index 68064ab..c7308fe 100644 --- a/src/views/channel/components/KpiAreaImportModal.vue +++ b/src/views/channel/components/KpiAreaImportModal.vue @@ -13,7 +13,10 @@
{{ t('channel.area.import.tip') }}
- + + {{ t('channel.area.import.downloadTemplate') }}
@@ -50,6 +53,7 @@ import { useI18n } from 'vue-i18n'; import type { FileItem } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue'; + import { IconDownload } from '@arco-design/web-vue/es/icon'; import { downloadKpiAreaInfoImportTemplate, importKpiAreaInfo, @@ -91,7 +95,7 @@ async function handleDownloadTemplate() { downloading.value = true; try { - await downloadKpiAreaInfoImportTemplate(props.areaType); + downloadKpiAreaInfoImportTemplate(props.areaType); Message.success(t('channel.area.import.downloadSuccess')); } catch (e: any) { Message.error(e?.message || t('channel.area.import.downloadFailed')); diff --git a/src/views/channel/components/KpiAreaPage.vue b/src/views/channel/components/KpiAreaPage.vue index 748ff77..65e7f8a 100644 --- a/src/views/channel/components/KpiAreaPage.vue +++ b/src/views/channel/components/KpiAreaPage.vue @@ -81,6 +81,9 @@ {{ t('channel.area.actions.import') }} + + {{ t('channel.area.actions.export') }} +
@@ -204,6 +207,8 @@ onSubmitRight, openImportModal, onImportSuccess, + exporting, + handleExport, } = useKpiAreaPage(props.areaType); diff --git a/src/views/channel/composables/useKpiAreaPage.ts b/src/views/channel/composables/useKpiAreaPage.ts index ed53779..469565e 100644 --- a/src/views/channel/composables/useKpiAreaPage.ts +++ b/src/views/channel/composables/useKpiAreaPage.ts @@ -9,6 +9,8 @@ import { postForewarningServiceKpiAreaModifyKpiAreaInfo, postForewarningServiceKpiAreaModifyKpiArea, postForewarningServiceKpiAreaGetKpiAreas, + exportKpiAreaInfoExcel, + mapKpiAreaInfoToExcelRow, } from '@/api/channel/kpiArea'; import { getCountryList } from '@/api/system/country'; import { @@ -497,6 +499,7 @@ export function useKpiAreaPage(areaType: KpiAreaType) { const modalFormLeftRef = ref(); const modalFormRightRef = ref(); const importModalRef = ref<{ open: (id: number) => void } | null>(null); + const exporting = ref(false); const tableData = ref([]); const leftModalMode = ref('add'); @@ -823,6 +826,52 @@ export function useKpiAreaPage(areaType: KpiAreaType) { loadTableData(); } + async function fetchAllAreaInfos(areaId: number) { + const pageSizeAll = 200; + const all: Record[] = []; + let page = 1; + let total = Number.POSITIVE_INFINITY; + while (all.length < total && page <= 200) { + const response: any = await postForewarningServiceKpiAreaGetKpiAreaInfos({ + areaId, + pageIndex: page, + pageSize: pageSizeAll, + }); + const items = filterByAreaType(response?.items ?? response?.data?.items ?? []); + total = Number(response?.totalCount ?? response?.total ?? items.length ?? 0); + all.push(...items); + if (items.length < pageSizeAll) break; + page += 1; + } + return all; + } + + async function handleExport() { + if (!guardAreaSelected()) { + $baseMessage(t('channel.area.import.needSelectArea'), 'warning'); + return; + } + exporting.value = true; + try { + const items = await fetchAllAreaInfos(currentId.value!.id); + const rows = items.map((item) => { + const inferred = inferGeoMatch(item); + return mapKpiAreaInfoToExcelRow({ + ...item, + matchDim: item.matchDim || inferred.matchDim, + matchMode: item.matchMode || inferred.matchMode, + }); + }); + const areaName = String(currentId.value?.name || t('channel.area.listTitle')).trim(); + exportKpiAreaInfoExcel(rows, `${areaName}-分区明细.xlsx`); + $baseMessage(t('channel.area.export.success', { count: rows.length }), 'success'); + } catch (e: any) { + $baseMessage(e?.message || t('channel.area.export.failed'), 'error'); + } finally { + exporting.value = false; + } + } + watch( () => currentId.value, (val) => { @@ -888,6 +937,8 @@ export function useKpiAreaPage(areaType: KpiAreaType) { onSubmitRight, openImportModal, onImportSuccess, + exporting, + handleExport, guardAreaSelected, }; } diff --git a/src/views/order/placeOrder/index.vue b/src/views/order/placeOrder/index.vue index 03dca5d..4876260 100644 --- a/src/views/order/placeOrder/index.vue +++ b/src/views/order/placeOrder/index.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted, reactive, nextTick, watch } from 'vue'; import { useRouter, useRoute } from 'vue-router'; import { - IconExport, + IconDownload, IconDelete, IconPlus, IconEdit, @@ -4624,7 +4624,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu :title="t('order.placeOrder.template.download')" @click="handleDownloadTemplate(template)" > - + @@ -4677,7 +4677,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu :title="t('order.placeOrder.template.download')" @click="handleDownloadTemplate(template)" > - + { target: 'https://cos.ideship.com', changeOrigin: true, secure: false, + xfwd: false, timeout: 30000, proxyTimeout: 30000, router: (req) => { - const path = req.url?.split('?')[0] ?? ''; - const host = path.replace(/^\/__cos__\//, '').split('/')[0]; + const raw = String(req.originalUrl || req.url || '').split('?')[0]; + const host = raw.replace(/^\/__cos__\//, '').split('/')[0]; return host ? `https://${host}` : 'https://cos.ideship.com'; }, rewrite: (path) => path.replace(/^\/__cos__\/[^/]+/, '') || '/', + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq, req) => { + proxyReq.removeHeader('authorization'); + proxyReq.removeHeader('cookie'); + proxyReq.removeHeader('origin'); + proxyReq.removeHeader('referer'); + const raw = String(req.originalUrl || req.url || '').split('?')[0]; + const host = raw.replace(/^\/__cos__\//, '').split('/')[0]; + if (host) { + proxyReq.setHeader('Host', host); + } + }); + }, }, '/marketing': { target: omsAdminTarget,