后台店铺数据记录页新增 Tab 切换与重复 ASIN 分析;task-169/170 HTTP 客户端配置接入;dedupe/brand 相关改造
Build Backend JAR / build (push) Has been cancelled
Build Backend JAR / build (push) Has been cancelled
- 管理后台:店铺数据记录改为列表分页展示(去任务号/文件列/累计标题,'最新'改'更新时间'),新增'重复 ASIN' Tab 按跨店聚合展示 ASIN/店铺/分组/国家/日期/价格;修复失败/进行中任务被过滤不显示的问题 - task-169: BrandCheckHttpConfigResolver 从 aiimage.http-client.* 读取品牌检查超时/重试(默认同现状、钳制),附 8 条测试 - task-170: surefire 内存调整为 1536m - dedupe: DedupeRunService 重构(事务边界/进度)、新增 DedupeRunProgressVo、Controller 与结果 VO 调整,PermissionMenuService 相应适配 - brand/dedupe 前端:BrandDedupeTab/BrandPatrolDeleteTab 改造、新增 CountrySelector 组件与 country-options、类型与接口端对齐、测试更新 - 移除无引用文件:backend/static/logo.jpg、prompts/
This commit is contained in:
@@ -79,7 +79,7 @@
|
||||
{{ cleanRunning ? '清洗中...' : '开始清洗' }}
|
||||
</button>
|
||||
<span class="loading-msg">
|
||||
{{ cleanRunning ? '正在处理文件并上传结果,请稍候…' : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
|
||||
{{ cleanRunning ? `正在处理文件并上传结果,已处理 ${cleanProgressProcessed}/${cleanSummary.total || 0} 个文件…` : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -148,7 +148,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from './BrandTopBar.vue'
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunVo } from '@/shared/api/java-modules'
|
||||
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
|
||||
@@ -163,6 +163,7 @@ const cleanKeepIntegerIds = ref(false)
|
||||
const cleanKeepUnderscoreIds = ref(true)
|
||||
const cleanKeepIntegerMainIdsWhenNoSubIds = ref(true)
|
||||
const cleanRunning = ref(false)
|
||||
const cleanProgressProcessed = ref(0)
|
||||
const cleanResultItems = ref<DedupeResultItem[]>([])
|
||||
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
||||
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
|
||||
@@ -290,7 +291,7 @@ async function submitCleanRun() {
|
||||
|
||||
try {
|
||||
cleanRunning.value = true
|
||||
const result = await runDedupe({
|
||||
const progress = await runDedupe({
|
||||
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
|
||||
selectedColumns: cleanSelectedColumns.value,
|
||||
keepIntegerIds: cleanKeepIntegerIds.value,
|
||||
@@ -301,6 +302,15 @@ async function submitCleanRun() {
|
||||
? cleanArchiveName.value
|
||||
: undefined,
|
||||
})
|
||||
const result = await pollDedupeRunProgress(progress.runId, (latest) => {
|
||||
cleanProgressProcessed.value = latest.processedCount
|
||||
cleanSummary.value = {
|
||||
total: latest.total,
|
||||
successCount: latest.successCount,
|
||||
failedCount: latest.failedCount,
|
||||
items: cleanResultItems.value,
|
||||
}
|
||||
})
|
||||
cleanSummary.value = result
|
||||
cleanResultItems.value = result.items || []
|
||||
await loadCleanHistory()
|
||||
@@ -325,6 +335,36 @@ async function submitCleanRun() {
|
||||
}
|
||||
}
|
||||
|
||||
// 去重任务进度轮询:2 秒一次,10 分钟超时(超时任务由后端继续执行,结果可在历史列表中查看)
|
||||
const DEDUPE_POLL_INTERVAL_MS = 2000
|
||||
const DEDUPE_POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
async function pollDedupeRunProgress(runId: string, onProgress?: (progress: DedupeRunProgressVo) => void): Promise<DedupeRunVo> {
|
||||
const deadline = Date.now() + DEDUPE_POLL_TIMEOUT_MS
|
||||
while (true) {
|
||||
const progress = await getDedupeRunProgress(runId)
|
||||
if (progress.status === 'not_found') {
|
||||
throw new Error('去重任务不存在或已过期')
|
||||
}
|
||||
if (progress.status !== 'running') {
|
||||
if (progress.status === 'failed') {
|
||||
throw new Error(progress.error || '去重任务执行失败')
|
||||
}
|
||||
if (!progress.result) {
|
||||
throw new Error(progress.error || '去重任务未返回结果')
|
||||
}
|
||||
return progress.result
|
||||
}
|
||||
onProgress?.(progress)
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`去重任务仍在处理中(已处理 ${progress.processedCount}/${progress.total} 个文件),请稍后在历史列表中查看结果`,
|
||||
)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, DEDUPE_POLL_INTERVAL_MS))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCleanHistory() {
|
||||
try {
|
||||
const response = await getDedupeHistory()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="section-title">店铺输入</div>
|
||||
<div class="input-zone">
|
||||
<div class="hint">
|
||||
左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“推送到 Python 队列”才会创建并执行任务。
|
||||
左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“推送到 Python 队列”才会创建并执行任务。任务只会跑下方勾选的国家。
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<el-input
|
||||
@@ -113,6 +113,12 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<CountrySelector
|
||||
v-model="selectedCountryCodes"
|
||||
title="巡店国家与顺序"
|
||||
min-selected-warning="至少保留 1 个国家,否则任务没有可巡查的站点"
|
||||
/>
|
||||
|
||||
<ZiniaoVersionSetting v-model="ziniaoVersion" />
|
||||
|
||||
<div class="run-row">
|
||||
@@ -363,7 +369,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import BrandTopBar from "@/pages/brand/components/BrandTopBar.vue";
|
||||
import {
|
||||
@@ -399,8 +405,13 @@ import { checkQueuePayload } from "@/shared/dispatch-guard";
|
||||
import { passGuard } from "@/shared/dispatch-guard-ui";
|
||||
import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue";
|
||||
import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
|
||||
import CountrySelector from "@/shared/components/CountrySelector.vue";
|
||||
import {
|
||||
EU_COUNTRY_CODES,
|
||||
countryLabel,
|
||||
sanitizeCountryCodes,
|
||||
} from "@/shared/country-options";
|
||||
|
||||
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
|
||||
const MAX_TRANSIENT_ERRORS = 30;
|
||||
const ziniaoVersion = useZiniaoVersion();
|
||||
|
||||
@@ -410,6 +421,7 @@ const candidates = ref<PatrolDeleteCandidateVo[]>([]);
|
||||
const selectedCandidates = ref<PatrolDeleteCandidateVo[]>([]);
|
||||
const deleteConditions = ref<PatrolDeleteConditionVo[]>([]);
|
||||
const selectedConditionIds = ref<number[]>([]);
|
||||
const selectedCountryCodes = ref<string[]>([...EU_COUNTRY_CODES]);
|
||||
const matchedItems = ref<PatrolDeleteShopQueueItem[]>([]);
|
||||
const historyItems = ref<PatrolDeleteHistoryItem[]>([]);
|
||||
const dashboard = ref<PatrolDeleteDashboardVo>({
|
||||
@@ -432,6 +444,10 @@ const timers = createCategorizedTimers("patrol-delete");
|
||||
const matchedRunnableItems = computed(() =>
|
||||
matchedItems.value.filter((item) => item.matched),
|
||||
);
|
||||
// 模板结构以中文国家名为 key(Java 的 Excel 列、Python 的站点切换都按中文名匹配)
|
||||
const selectedCountryNames = computed(() =>
|
||||
selectedCountryCodes.value.map((code) => countryLabel(code)),
|
||||
);
|
||||
const taskRecordItems = computed(() => groupHistoryItemsByTask(historyItems.value));
|
||||
const currentSectionItems = computed(() =>
|
||||
taskRecordItems.value.filter((item) => !isTaskTerminal(item.taskStatus)),
|
||||
@@ -459,6 +475,10 @@ function queueStateStorageKey() {
|
||||
return `patrol-delete:queue-state:${uidForStorage()}`;
|
||||
}
|
||||
|
||||
function countryCodesStorageKey() {
|
||||
return `patrol-delete:country-codes:${uidForStorage()}`;
|
||||
}
|
||||
|
||||
function rowKeyForMatch(row: PatrolDeleteShopQueueItem) {
|
||||
return `${(row.shopName || "").trim()}::${row.shopId || ""}`;
|
||||
}
|
||||
@@ -559,7 +579,7 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
|
||||
}
|
||||
|
||||
function buildTemplateCountrySections(): PatrolDeleteCountrySection[] {
|
||||
return COUNTRY_TEMPLATE.map((country) => ({
|
||||
return selectedCountryNames.value.map((country) => ({
|
||||
country,
|
||||
rows: [
|
||||
{
|
||||
@@ -573,7 +593,7 @@ function buildTemplateCountrySections(): PatrolDeleteCountrySection[] {
|
||||
}
|
||||
|
||||
function buildTemplateCartRatios(): PatrolDeleteCartRatio[] {
|
||||
return COUNTRY_TEMPLATE.map((country) => ({
|
||||
return selectedCountryNames.value.map((country) => ({
|
||||
country,
|
||||
ratio: "",
|
||||
}));
|
||||
@@ -745,6 +765,29 @@ function loadQueueState() {
|
||||
}
|
||||
}
|
||||
|
||||
function saveCountryCodes() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(
|
||||
countryCodesStorageKey(),
|
||||
JSON.stringify(selectedCountryCodes.value),
|
||||
);
|
||||
}
|
||||
|
||||
function loadCountryCodes() {
|
||||
try {
|
||||
const raw =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem(countryCodesStorageKey())
|
||||
: null;
|
||||
// 没存过(首次进入)时保持默认的五国全选,与改动前的行为一致
|
||||
selectedCountryCodes.value = raw
|
||||
? sanitizeCountryCodes(JSON.parse(raw))
|
||||
: [...EU_COUNTRY_CODES];
|
||||
} catch {
|
||||
selectedCountryCodes.value = [...EU_COUNTRY_CODES];
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveQueueTask() {
|
||||
activeTaskId.value = null;
|
||||
saveQueueState();
|
||||
@@ -1011,6 +1054,8 @@ function buildQueuePayload(taskId: number, items: PatrolDeleteHistoryItem[]) {
|
||||
source: "frontend-vue-patrol-delete",
|
||||
delete_conditions: deleteConditionsForTask,
|
||||
deleteConditions: deleteConditionsForTask,
|
||||
// 与店铺数据抓取 / 跟价 / 商品风险等页面统一:国家一律用 country_codes 传代码
|
||||
country_codes: [...selectedCountryCodes.value],
|
||||
items: items.map((item) => ({
|
||||
shopName: item.shopName,
|
||||
shopId: item.shopId,
|
||||
@@ -1122,10 +1167,11 @@ async function processQueue() {
|
||||
queuePayloadText.value = JSON.stringify(payload, null, 2);
|
||||
|
||||
// 删除条件为空时 Python 端没有可执行的判定规则,任务会一直停在执行中
|
||||
// country_codes 为空时 Python 端没有可遍历的站点,同样会空转
|
||||
const guard = checkQueuePayload(payload, {
|
||||
expectedType: "patrol-delete-run",
|
||||
requiredDataKeys: ["taskId"],
|
||||
nonEmptyArrayKeys: ["items", "delete_conditions"],
|
||||
nonEmptyArrayKeys: ["items", "delete_conditions", "country_codes"],
|
||||
});
|
||||
if (!(await passGuard(guard))) {
|
||||
await submitPatrolDeleteTaskResult(created.taskId, {
|
||||
@@ -1171,7 +1217,7 @@ async function processQueue() {
|
||||
}
|
||||
|
||||
for (const item of runnable) removeMatchedRowLocally(item);
|
||||
queuePushResult.value = `任务 ${created.taskId} 已入队,共 ${createdItems.length} 个店铺,等待执行完成`;
|
||||
queuePushResult.value = `任务 ${created.taskId} 已入队,共 ${createdItems.length} 个店铺、${selectedCountryNames.value.length} 个国家,等待执行完成`;
|
||||
|
||||
const finalStatus = await waitForTaskTerminal(created.taskId);
|
||||
queuePushResult.value = `任务 ${created.taskId} ${finalStatus === "SUCCESS" ? "已完成" : finalStatus === "DELETED" ? "已删除" : "执行失败"}`;
|
||||
@@ -1198,7 +1244,11 @@ async function pushToPythonQueue() {
|
||||
ElMessage.warning("请先匹配可用店铺");
|
||||
return;
|
||||
}
|
||||
queuePushResult.value = `开始创建任务,共 ${runnable.length} 个店铺`;
|
||||
if (!selectedCountryCodes.value.length) {
|
||||
ElMessage.warning("请至少勾选 1 个巡店国家");
|
||||
return;
|
||||
}
|
||||
queuePushResult.value = `开始创建任务,共 ${runnable.length} 个店铺、${selectedCountryNames.value.length} 个国家(${selectedCountryNames.value.join("、")})`;
|
||||
await processQueue();
|
||||
}
|
||||
|
||||
@@ -1250,6 +1300,8 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
|
||||
onMounted(async () => {
|
||||
loadMatchedItems();
|
||||
loadQueueState();
|
||||
loadCountryCodes();
|
||||
watch(selectedCountryCodes, saveCountryCodes, { deep: true });
|
||||
await Promise.all([loadCandidates(), loadConditions(), loadDashboard(), loadHistory()]);
|
||||
|
||||
if (activeTaskId.value) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
dedupe: {
|
||||
run: '/api/dedupe/run',
|
||||
runProgress: '/api/dedupe/run/{runId}/progress',
|
||||
history: '/api/dedupe/history',
|
||||
historyDelete: '/api/dedupe/history/{resultId}',
|
||||
resultDownload: '/api/dedupe/results/{resultId}/download',
|
||||
|
||||
@@ -35,11 +35,25 @@ export interface DedupeRunRequest {
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
export interface DedupeRunProgressVo {
|
||||
runId: string;
|
||||
/** running / success / failed / not_found */
|
||||
status: string;
|
||||
total: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
finished: boolean;
|
||||
error?: string;
|
||||
/** 任务完成后才有 */
|
||||
result?: DedupeRunVo | null;
|
||||
}
|
||||
|
||||
export function runDedupe(
|
||||
request: Omit<DedupeRunRequest, "user_id"> | DedupeRunRequest,
|
||||
) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<DedupeRunVo>, DedupeRunRequest>(
|
||||
post<JavaApiResponse<DedupeRunProgressVo>, DedupeRunRequest>(
|
||||
buildJavaUrl(API_ENDPOINTS.dedupe.run),
|
||||
{
|
||||
...request,
|
||||
@@ -49,6 +63,14 @@ export function runDedupe(
|
||||
);
|
||||
}
|
||||
|
||||
export function getDedupeRunProgress(runId: string) {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<DedupeRunProgressVo>>(
|
||||
buildJavaUrl(API_ENDPOINTS.dedupe.runProgress.replace('{runId}', encodeURIComponent(runId))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getDedupeHistory() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<DedupeHistoryVo>>(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<section class="country-selector">
|
||||
<div v-if="title" class="selector-title">{{ title }}</div>
|
||||
<div class="country-pref-checks">
|
||||
<label v-for="row in checkboxRows" :key="row.code" class="country-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="country-check-input"
|
||||
:checked="isSelected(row.code)"
|
||||
:disabled="disabled || isLastSelected(row.code)"
|
||||
@change="onNativeChange(row.code, $event)"
|
||||
/>
|
||||
<span>{{ row.text }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="country-order-panel">
|
||||
<div class="country-order-caption">{{ orderCaption }}</div>
|
||||
<div v-if="!modelValue.length" class="country-order-empty">尚未选择国家</div>
|
||||
<div v-else class="country-order-list">
|
||||
<div
|
||||
v-for="(code, index) in modelValue"
|
||||
:key="code"
|
||||
class="country-drag-row"
|
||||
:class="{ dragging: dragIndex === index }"
|
||||
:draggable="!disabled"
|
||||
@dragstart="dragIndex = index"
|
||||
@dragend="dragIndex = null"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop(index)"
|
||||
>
|
||||
<span class="drag-handle" title="拖动排序">⋮⋮</span>
|
||||
<span>{{ textOf(code) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { EU_COUNTRY_OPTIONS, type CountryOption } from '@/shared/country-options'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选国家代码,数组顺序即执行顺序 */
|
||||
modelValue: string[]
|
||||
options?: readonly CountryOption[]
|
||||
title?: string
|
||||
orderCaption?: string
|
||||
minSelectedWarning?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
options: () => EU_COUNTRY_OPTIONS,
|
||||
title: '国家与顺序',
|
||||
orderCaption: '已选顺序(拖动可调整执行先后)',
|
||||
minSelectedWarning: '至少保留 1 个国家',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||
|
||||
const dragIndex = ref<number | null>(null)
|
||||
|
||||
/** 国家代码和展示名相同时(例如直接用中文名作 code)不再重复追加括号 */
|
||||
function textOf(code: string) {
|
||||
const label = props.options.find((row) => row.code === code)?.label || code
|
||||
return label === code ? label : `${label}(${code})`
|
||||
}
|
||||
|
||||
// 已勾选的按当前顺序排在前面,未勾选的按选项原始顺序补在后面
|
||||
const checkboxRows = computed(() => {
|
||||
const selected = new Set(props.modelValue)
|
||||
return [
|
||||
...props.modelValue.map((code) => ({ code, text: textOf(code) })),
|
||||
...props.options
|
||||
.filter((row) => !selected.has(row.code))
|
||||
.map((row) => ({ code: row.code, text: textOf(row.code) })),
|
||||
]
|
||||
})
|
||||
|
||||
function isSelected(code: string) {
|
||||
return props.modelValue.includes(code)
|
||||
}
|
||||
|
||||
function isLastSelected(code: string) {
|
||||
return props.modelValue.length === 1 && props.modelValue[0] === code
|
||||
}
|
||||
|
||||
function onNativeChange(code: string, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.checked && isLastSelected(code)) {
|
||||
input.checked = true
|
||||
ElMessage.warning(props.minSelectedWarning)
|
||||
return
|
||||
}
|
||||
emit(
|
||||
'update:modelValue',
|
||||
input.checked
|
||||
? [...props.modelValue, code]
|
||||
: props.modelValue.filter((item) => item !== code),
|
||||
)
|
||||
}
|
||||
|
||||
function onDrop(toIndex: number) {
|
||||
const from = dragIndex.value
|
||||
dragIndex.value = null
|
||||
if (props.disabled || from == null || from === toIndex) return
|
||||
const next = [...props.modelValue]
|
||||
const [moved] = next.splice(from, 1)
|
||||
next.splice(toIndex, 0, moved)
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.country-selector { margin: 16px 0; }
|
||||
.selector-title { margin-bottom: 10px; color: #bbb; font-size: 13px; }
|
||||
.country-pref-checks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
.country-check-row { display: flex; align-items: center; gap: 7px; min-height: 32px; color: #ccc; font-size: 13px; }
|
||||
.country-check-input { width: 15px; height: 15px; }
|
||||
.country-check-input:disabled { cursor: not-allowed; }
|
||||
.country-order-panel { padding: 10px; border: 1px solid #333; border-radius: 6px; background: #242424; }
|
||||
.country-order-caption { margin-bottom: 8px; color: #888; font-size: 12px; }
|
||||
.country-order-empty { color: #666; font-size: 12px; }
|
||||
.country-order-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.country-drag-row { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 0 9px; border: 1px solid #383838; border-radius: 4px; color: #ccc; font-size: 13px; cursor: grab; }
|
||||
.country-drag-row.dragging { opacity: .5; }
|
||||
.drag-handle { color: #777; }
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 各功能页共用的欧洲五国选项。
|
||||
*
|
||||
* code 用于接口参数与本地存储(和店铺数据抓取 / 跟价 / 商品风险等页面的
|
||||
* country_codes 保持一致),label 用于界面展示;巡店删除的模板结构直接以
|
||||
* 中文国家名作为 key,取 label 即可。
|
||||
*/
|
||||
export interface CountryOption {
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const EU_COUNTRY_OPTIONS: readonly CountryOption[] = [
|
||||
{ code: 'DE', label: '德国' },
|
||||
{ code: 'UK', label: '英国' },
|
||||
{ code: 'FR', label: '法国' },
|
||||
{ code: 'IT', label: '意大利' },
|
||||
{ code: 'ES', label: '西班牙' },
|
||||
]
|
||||
|
||||
export const EU_COUNTRY_CODES: readonly string[] = EU_COUNTRY_OPTIONS.map((row) => row.code)
|
||||
|
||||
export function countryLabel(code: string) {
|
||||
return EU_COUNTRY_OPTIONS.find((row) => row.code === code)?.label || code
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出合法国家代码并去重,用于校验本地缓存或接口返回的顺序。
|
||||
* 结果为空时回落到 fallback,避免出现「一个国家都没有」导致任务空转。
|
||||
*/
|
||||
export function sanitizeCountryCodes(
|
||||
raw: unknown,
|
||||
fallback: readonly string[] = EU_COUNTRY_CODES,
|
||||
): string[] {
|
||||
if (!Array.isArray(raw)) return [...fallback]
|
||||
const valid = new Set(EU_COUNTRY_CODES)
|
||||
const result: string[] = []
|
||||
for (const item of raw) {
|
||||
const code = String(item ?? '').trim().toUpperCase()
|
||||
if (valid.has(code) && !result.includes(code)) result.push(code)
|
||||
}
|
||||
return result.length ? result : [...fallback]
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import assert from 'node:assert/strict'
|
||||
import { http } from '../src/shared/api/http.ts'
|
||||
import {
|
||||
runDedupe,
|
||||
getDedupeRunProgress,
|
||||
getDedupeHistory,
|
||||
deleteDedupeHistory,
|
||||
getDedupeResultDownloadUrl,
|
||||
type DedupeRunRequest,
|
||||
type DedupeRunVo,
|
||||
type DedupeRunProgressVo,
|
||||
} from '../src/shared/api/types/modules/dedupe.ts'
|
||||
|
||||
function setupWindow() {
|
||||
@@ -26,12 +27,22 @@ function mockRequest(
|
||||
|
||||
const okResponse = (data: unknown) => Promise.resolve({ data: { success: true, message: 'ok', data } })
|
||||
|
||||
const runningProgress = (runId: string): DedupeRunProgressVo => ({
|
||||
runId,
|
||||
status: 'running',
|
||||
total: 2,
|
||||
processedCount: 1,
|
||||
successCount: 1,
|
||||
failedCount: 0,
|
||||
finished: false,
|
||||
})
|
||||
|
||||
test('test_dedupe_run_url', async (t) => {
|
||||
setupWindow()
|
||||
let captured: { url?: string } = {}
|
||||
mockRequest(t, (config) => {
|
||||
captured = config
|
||||
return okResponse({ total: 1, successCount: 1, failedCount: 0, items: [] })
|
||||
return okResponse(runningProgress('run-1'))
|
||||
})
|
||||
await runDedupe({ files: [{ fileKey: 'k1' }], selectedColumns: ['a'], keepIntegerIds: false, keepUnderscoreIds: false, keepIntegerMainIdsWhenNoSubIds: true })
|
||||
assert.equal(captured.url, '/newApi/api/dedupe/run')
|
||||
@@ -42,7 +53,7 @@ test('test_dedupe_run_method_payload', async (t) => {
|
||||
let captured: { method?: string; data?: unknown } = {}
|
||||
mockRequest(t, (config) => {
|
||||
captured = config
|
||||
return okResponse({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
||||
return okResponse(runningProgress('run-1'))
|
||||
})
|
||||
await runDedupe({ files: [{ fileKey: 'k1' }], selectedColumns: ['a'], keepIntegerIds: true, keepUnderscoreIds: true, keepIntegerMainIdsWhenNoSubIds: false })
|
||||
assert.equal(captured.method, 'POST')
|
||||
@@ -56,6 +67,30 @@ test('test_dedupe_run_method_payload', async (t) => {
|
||||
})
|
||||
})
|
||||
|
||||
test('test_dedupe_run_returns_progress', async (t) => {
|
||||
setupWindow()
|
||||
mockRequest(t, () => okResponse(runningProgress('run-1')))
|
||||
const progress = await runDedupe({ files: [{ fileKey: 'k1' }], selectedColumns: ['a'], keepIntegerIds: false, keepUnderscoreIds: false, keepIntegerMainIdsWhenNoSubIds: true })
|
||||
assert.equal(progress.runId, 'run-1', 'run 应立即返回 runId')
|
||||
assert.equal(progress.status, 'running')
|
||||
assert.equal(progress.finished, false)
|
||||
assert.equal(progress.result, undefined, '运行中不应携带最终结果')
|
||||
})
|
||||
|
||||
test('test_dedupe_progress_url', async (t) => {
|
||||
setupWindow()
|
||||
let captured: { url?: string; method?: string } = {}
|
||||
mockRequest(t, (config) => {
|
||||
captured = config
|
||||
return okResponse({ runId: 'run-1', status: 'success', total: 2, processedCount: 2, successCount: 2, failedCount: 0, finished: true, result: { total: 2, successCount: 2, failedCount: 0, items: [] } })
|
||||
})
|
||||
const progress = await getDedupeRunProgress('run-1')
|
||||
assert.equal(captured.url, '/newApi/api/dedupe/run/run-1/progress')
|
||||
assert.equal(captured.method, 'GET')
|
||||
assert.equal(progress.status, 'success')
|
||||
assert.ok(progress.result, '完成后应返回结果')
|
||||
})
|
||||
|
||||
test('test_dedupe_history_url', async (t) => {
|
||||
setupWindow()
|
||||
let captured: { url?: string; method?: string; params?: Record<string, unknown> } = {}
|
||||
@@ -90,6 +125,7 @@ test('test_dedupe_download_url', async (t) => {
|
||||
test('test_dedupe_signature_unchanged', async (t) => {
|
||||
setupWindow()
|
||||
assert.equal(runDedupe.length, 1, 'runDedupe 应保持单参数')
|
||||
assert.equal(getDedupeRunProgress.length, 1, 'getDedupeRunProgress 应保持单参数')
|
||||
assert.equal(getDedupeHistory.length, 0, 'getDedupeHistory 应保持无参')
|
||||
assert.equal(deleteDedupeHistory.length, 1, 'deleteDedupeHistory 应保持单参数')
|
||||
assert.equal(getDedupeResultDownloadUrl.length, 1, 'getDedupeResultDownloadUrl 应保持单参数')
|
||||
@@ -114,9 +150,9 @@ test('test_dedupe_export_compat', async (t) => {
|
||||
assert.equal(fromJavaModules.getDedupeResultDownloadUrl, fromDedupe.getDedupeResultDownloadUrl)
|
||||
})
|
||||
|
||||
test('test_dedupe_unwrap', async (t) => {
|
||||
test('test_dedupe_unwrap_progress', async (t) => {
|
||||
setupWindow()
|
||||
mockRequest(t, () => okResponse({ total: 2, successCount: 1, failedCount: 1, items: [{ success: true }] }))
|
||||
const result = await runDedupe<DedupeRunVo>({ files: [{ fileKey: 'k' }], selectedColumns: [], keepIntegerIds: false, keepUnderscoreIds: false, keepIntegerMainIdsWhenNoSubIds: false })
|
||||
assert.deepEqual(result, { total: 2, successCount: 1, failedCount: 1, items: [{ success: true }] }, '应返回解包后的 data')
|
||||
mockRequest(t, () => okResponse(runningProgress('run-1')))
|
||||
const progress = await runDedupe<DedupeRunProgressVo>({ files: [{ fileKey: 'k' }], selectedColumns: [], keepIntegerIds: false, keepUnderscoreIds: false, keepIntegerMainIdsWhenNoSubIds: false })
|
||||
assert.deepEqual(progress, runningProgress('run-1'), '应返回解包后的 data')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user