feat: 附件预览优先走 COS 代理,并补齐 KPI 导出与模板下载
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m43s
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m43s
开发环境避免不存在的 file-storage 404 挡住预览;分区明细改为前端生成 Excel。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d771462a30
commit
5178b6379f
@ -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<string, any>) {
|
||||
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<string, string> = {
|
||||
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<string, unknown>): 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 */
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '请先在左侧选择配置项',
|
||||
|
||||
@ -271,12 +271,25 @@ async function fetchPreviewBlob(rawUrl: string): Promise<Blob> {
|
||||
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)
|
||||
|
||||
@ -13,7 +13,10 @@
|
||||
<div class="kpi-area-import-modal">
|
||||
<div class="import-tip">{{ t('channel.area.import.tip') }}</div>
|
||||
<div class="import-actions">
|
||||
<a-button type="outline" :loading="downloading" @click="handleDownloadTemplate">
|
||||
<a-button class="app-btn-primary-soft" :loading="downloading" @click="handleDownloadTemplate">
|
||||
<template #icon>
|
||||
<IconDownload />
|
||||
</template>
|
||||
{{ t('channel.area.import.downloadTemplate') }}
|
||||
</a-button>
|
||||
</div>
|
||||
@ -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'));
|
||||
|
||||
@ -81,6 +81,9 @@
|
||||
<a-button :disabled="!currentId?.id" @click="openImportModal">
|
||||
{{ t('channel.area.actions.import') }}
|
||||
</a-button>
|
||||
<a-button :disabled="!currentId?.id" :loading="exporting" @click="handleExport">
|
||||
{{ t('channel.area.actions.export') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -204,6 +207,8 @@
|
||||
onSubmitRight,
|
||||
openImportModal,
|
||||
onImportSuccess,
|
||||
exporting,
|
||||
handleExport,
|
||||
} = useKpiAreaPage(props.areaType);
|
||||
</script>
|
||||
|
||||
|
||||
@ -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<any[]>([]);
|
||||
|
||||
const leftModalMode = ref<DialogType>('add');
|
||||
@ -823,6 +826,52 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
|
||||
loadTableData();
|
||||
}
|
||||
|
||||
async function fetchAllAreaInfos(areaId: number) {
|
||||
const pageSizeAll = 200;
|
||||
const all: Record<string, unknown>[] = [];
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)"
|
||||
>
|
||||
<template #icon><icon-export /></template>
|
||||
<template #icon><icon-download /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@ -4677,7 +4677,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
:title="t('order.placeOrder.template.download')"
|
||||
@click="handleDownloadTemplate(template)"
|
||||
>
|
||||
<template #icon><icon-export /></template>
|
||||
<template #icon><icon-download /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
|
||||
@ -282,14 +282,28 @@ export default defineConfig(({ mode }) => {
|
||||
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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user