后台店铺数据记录页新增 Tab 切换与重复 ASIN 分析;task-169/170 HTTP 客户端配置接入;dedupe/brand 相关改造
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:
2026-09-03 01:57:41 +08:00
parent 152ea6eec0
commit 417a2bf831
20 changed files with 1883 additions and 475 deletions
@@ -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),
);
// 模板结构以中文国家名为 keyJava 的 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) {