完成菜单权限添加和软件菜单改造
This commit is contained in:
4
app/.env
4
app/.env
@@ -13,7 +13,7 @@ client_name=ShuFuAI
|
||||
|
||||
|
||||
# java_api_base=http://47.111.163.154:18080
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://8.136.19.173:18080
|
||||
# java_api_base=http://127.0.0.1:18080
|
||||
java_api_base=http://8.136.19.173:18080
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D7_PdvyM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DysFlQbZ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-C5RGB8QW.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D7_PdvyM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DysFlQbZ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-C5RGB8QW.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>删除品牌 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D7_PdvyM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DysFlQbZ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-C5RGB8QW.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-Dl7T0pmm.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DfRLfrtm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D7_PdvyM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DysFlQbZ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-C5RGB8QW.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -15,126 +15,120 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
.top-bar {
|
||||
height: 56px;
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
gap: 20px;
|
||||
padding: 10px 24px 12px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.logo-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.top-bar .app-name { font-size: 18px; font-weight: 600; color: #fff; }
|
||||
.btn-home {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
color: #d2d2d2;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-home:hover { color: #fff; background: #2a2a2a; }
|
||||
.btn-home:hover { color: #fff; }
|
||||
|
||||
.nav-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tab-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.nav-dropdown {
|
||||
.nav-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-dropdown-trigger {
|
||||
.nav-section + .nav-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 14px;
|
||||
bottom: 8px;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
color: #e8dfcf;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.nav-section-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(83, 74, 67, 0.92) 0%, rgba(72, 65, 59, 0.96) 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 112px;
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
color: #999;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-dropdown:hover .nav-dropdown-trigger,
|
||||
.nav-dropdown.active .nav-dropdown-trigger {
|
||||
color: #fff;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.nav-dropdown.active .nav-dropdown-trigger {
|
||||
color: #3498db;
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
border-color: rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.nav-dropdown-arrow {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 220px;
|
||||
padding: 8px;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
background: #171717;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
||||
display: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.nav-dropdown:hover .nav-dropdown-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
color: #b8b8b8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-height: 28px;
|
||||
padding: 0 2px;
|
||||
color: #f3eee4;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.18s ease, border-color 0.18s ease, opacity 0.18s ease;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-dropdown-item:hover {
|
||||
.nav-item:hover {
|
||||
color: #fff;
|
||||
background: #2a2a2a;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.nav-dropdown-item.active {
|
||||
color: #3498db;
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
.nav-item.active {
|
||||
color: #fff;
|
||||
border-bottom-color: #8dc4ff;
|
||||
}
|
||||
|
||||
.nav-item.disabled {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
opacity: 0.82;
|
||||
cursor: default;
|
||||
}
|
||||
.top-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -397,27 +391,34 @@
|
||||
</div>
|
||||
<nav class="nav-tabs">
|
||||
<div class="nav-tab-group">
|
||||
<div class="nav-dropdown active">
|
||||
<button type="button" class="nav-dropdown-trigger">
|
||||
<span>前端工具</span>
|
||||
<span class="nav-dropdown-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-dropdown-menu">
|
||||
<button type="button" class="nav-dropdown-item active" data-panel="brandCheck">品牌检测</button>
|
||||
<a class="nav-dropdown-item" href="/new_web_source/dedupe.html">数据去重</a>
|
||||
<a class="nav-dropdown-item" href="/new_web_source/split.html">数据拆分</a>
|
||||
<a class="nav-dropdown-item" href="/new_web_source/convert.html">格式转换</a>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">前端工具</div>
|
||||
<div class="nav-section-items">
|
||||
<button type="button" class="nav-item active" data-panel="brandCheck">品牌检测</button>
|
||||
<span class="nav-item disabled">采集数据</span>
|
||||
<span class="nav-item disabled">变体分析</span>
|
||||
<a class="nav-item" href="/new_web_source/dedupe.html">数据去重</a>
|
||||
<a class="nav-item" href="/new_web_source/split.html">数据拆分</a>
|
||||
<a class="nav-item" href="/new_web_source/convert.html">格式转换</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-dropdown">
|
||||
<button type="button" class="nav-dropdown-trigger">
|
||||
<span>运营工具</span>
|
||||
<span class="nav-dropdown-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-dropdown-menu">
|
||||
<a class="nav-dropdown-item" href="/new_web_source/delete-brand.html">删除指定ASIN和品牌</a>
|
||||
<a class="nav-dropdown-item" href="/new_web_source/product-risk.html">商品风险解决</a>
|
||||
<a class="nav-dropdown-item" href="/new_web_source/shop-match.html">匹配店铺</a>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">运营工具</div>
|
||||
<div class="nav-section-items">
|
||||
<a class="nav-item" href="/new_web_source/delete-brand.html">删除ASIN</a>
|
||||
<a class="nav-item" href="/new_web_source/product-risk.html">商品风险解决</a>
|
||||
<a class="nav-item" href="/new_web_source/shop-match.html">定时匹配</a>
|
||||
<span class="nav-item disabled">跟价</span>
|
||||
<span class="nav-item disabled">巡店删除</span>
|
||||
<span class="nav-item disabled">取款</span>
|
||||
<span class="nav-item disabled">店铺状态查询</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">后勤工具</div>
|
||||
<div class="nav-section-items">
|
||||
<span class="nav-item disabled">采购</span>
|
||||
<span class="nav-item disabled">ERP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,11 +550,11 @@
|
||||
}
|
||||
|
||||
// 顶部栏目切换:点击当前页菜单项时显示对应 data-panel 的 tab-panel
|
||||
document.querySelectorAll('.nav-dropdown-item[data-panel]').forEach(function(tab) {
|
||||
document.querySelectorAll('.nav-item[data-panel]').forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
var panelId = this.getAttribute('data-panel');
|
||||
if (!panelId) return;
|
||||
document.querySelectorAll('.nav-dropdown-item[data-panel]').forEach(function(t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.nav-item[data-panel]').forEach(function(t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) {
|
||||
p.classList.toggle('active', p.getAttribute('data-panel') === panelId);
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
package com.nanri.aiimage.common.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class DistributedJobLockService {
|
||||
|
||||
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||
+ "return redis.call('del', KEYS[1]) "
|
||||
+ "else return 0 end",
|
||||
Long.class
|
||||
);
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final String ownerPrefix;
|
||||
|
||||
public DistributedJobLockService(StringRedisTemplate stringRedisTemplate) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.ownerPrefix = resolveOwnerPrefix();
|
||||
}
|
||||
|
||||
public LockHandle tryLock(String jobName, Duration ttl) {
|
||||
if (jobName == null || jobName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Duration actualTtl = (ttl == null || ttl.isNegative() || ttl.isZero()) ? Duration.ofMinutes(10) : ttl;
|
||||
String key = buildLockKey(jobName);
|
||||
String token = ownerPrefix + ":" + UUID.randomUUID();
|
||||
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(key, token, actualTtl);
|
||||
if (!Boolean.TRUE.equals(locked)) {
|
||||
return null;
|
||||
}
|
||||
return new LockHandle(key, token, jobName);
|
||||
}
|
||||
|
||||
public final class LockHandle implements AutoCloseable {
|
||||
|
||||
private final String key;
|
||||
private final String token;
|
||||
private final String jobName;
|
||||
private boolean released;
|
||||
|
||||
private LockHandle(String key, String token, String jobName) {
|
||||
this.key = key;
|
||||
this.token = token;
|
||||
this.jobName = jobName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
try {
|
||||
stringRedisTemplate.execute(RELEASE_SCRIPT, Collections.singletonList(key), token);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[job-lock] release failed jobName={} key={} msg={}", jobName, key, ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String buildLockKey(String jobName) {
|
||||
return "job-lock:" + jobName;
|
||||
}
|
||||
|
||||
private String resolveOwnerPrefix() {
|
||||
try {
|
||||
return InetAddress.getLocalHost().getHostName();
|
||||
} catch (Exception ignored) {
|
||||
return "unknown-host";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.config.BrandProgressProperties;
|
||||
import com.nanri.aiimage.config.StorageProperties;
|
||||
import com.nanri.aiimage.modules.brand.mapper.BrandCrawlTaskMapper;
|
||||
@@ -49,6 +50,7 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
@@ -69,6 +71,7 @@ import java.util.zip.ZipOutputStream;
|
||||
public class BrandTaskService {
|
||||
|
||||
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||
private static final String STATUS_PENDING = "pending";
|
||||
private static final String STATUS_RUNNING = "running";
|
||||
private static final String STATUS_SUCCESS = "success";
|
||||
@@ -80,6 +83,7 @@ public class BrandTaskService {
|
||||
private final StorageProperties storageProperties;
|
||||
private final BrandProgressProperties brandProgressProperties;
|
||||
private final BrandTaskProgressCacheService brandTaskProgressCacheService;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public BrandTaskCreateVo createTask(Long userId, BrandTaskCreateRequest request) {
|
||||
@@ -1051,6 +1055,12 @@ public class BrandTaskService {
|
||||
@Transactional
|
||||
@org.springframework.scheduling.annotation.Scheduled(cron = "${aiimage.brand-progress.stale-check-cron:0 */2 * * * *}")
|
||||
public void failStaleRunningTasks() {
|
||||
DistributedJobLockService.LockHandle lockHandle = distributedJobLockService.tryLock("brand:stale-check", STALE_CHECK_LOCK_TTL);
|
||||
if (lockHandle == null) {
|
||||
log.info("[brand-stale-check] skip because another instance holds the distributed lock");
|
||||
return;
|
||||
}
|
||||
try (lockHandle) {
|
||||
LocalDateTime threshold = LocalDateTime.now().minusMinutes(brandProgressProperties.getHeartbeatTimeoutMinutes());
|
||||
List<BrandCrawlTaskEntity> runningTasks = brandCrawlTaskMapper.selectList(new LambdaQueryWrapper<BrandCrawlTaskEntity>()
|
||||
.eq(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING)
|
||||
@@ -1077,6 +1087,7 @@ public class BrandTaskService {
|
||||
failStaleRunningTask(task.getId(), "前端长时间无响应,任务已自动失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void failStaleRunningTask(Long taskId, String message) {
|
||||
brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.deletebrand.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
@@ -14,15 +15,12 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 删除品牌任务的超时回收与 finalize 补偿。
|
||||
* 同时复用这套定时调度,对商品风险和匹配店铺任务做超时兜底收尾。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@@ -31,6 +29,8 @@ public class DeleteBrandStaleTaskService {
|
||||
private static final String MODULE_TYPE_DELETE_BRAND = "DELETE_BRAND";
|
||||
private static final String MODULE_TYPE_PRODUCT_RISK = "PRODUCT_RISK_RESOLVE";
|
||||
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
|
||||
private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||
private static final Duration FINALIZE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
||||
@@ -40,9 +40,17 @@ public class DeleteBrandStaleTaskService {
|
||||
private final ShopMatchTaskService shopMatchTaskService;
|
||||
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
|
||||
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
|
||||
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
|
||||
public void failStaleRunningTasks() {
|
||||
DistributedJobLockService.LockHandle lockHandle =
|
||||
distributedJobLockService.tryLock("delete-brand:stale-check", STALE_CHECK_LOCK_TTL);
|
||||
if (lockHandle == null) {
|
||||
log.info("[stale-check] skip because another instance holds the distributed lock");
|
||||
return;
|
||||
}
|
||||
try (lockHandle) {
|
||||
long startedAt = System.currentTimeMillis();
|
||||
log.info("[stale-check] scan started thread={}", Thread.currentThread().getName());
|
||||
failStaleDeleteBrandTasks();
|
||||
@@ -63,11 +71,11 @@ public class DeleteBrandStaleTaskService {
|
||||
System.currentTimeMillis() - startedAt,
|
||||
Thread.currentThread().getName());
|
||||
}
|
||||
}
|
||||
|
||||
private void failStaleDeleteBrandTasks() {
|
||||
LocalDateTime threshold = LocalDateTime.now().minusMinutes(deleteBrandProgressProperties.getHeartbeatTimeoutMinutes());
|
||||
|
||||
// 先按 DB updatedAt 粗筛,避免全表扫描。
|
||||
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
|
||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||
@@ -96,15 +104,12 @@ public class DeleteBrandStaleTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
LocalDateTime lastActivityTime;
|
||||
// 只有 has_progress=true 且心跳超时,才判定为异常卡住。
|
||||
if (lastHeartbeatAt > 0 && isActive) {
|
||||
lastActivityTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastHeartbeatAt), ZoneId.systemDefault());
|
||||
LocalDateTime lastActivityTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastHeartbeatAt), ZoneId.systemDefault());
|
||||
if (lastActivityTime.isAfter(threshold)) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// 没有心跳,或仍处于静默等待阶段时,使用更长的初始宽限期。
|
||||
LocalDateTime queuedThreshold = LocalDateTime.now().minusHours(12);
|
||||
if (task.getCreatedAt() != null && task.getCreatedAt().isAfter(queuedThreshold)) {
|
||||
continue;
|
||||
@@ -130,6 +135,8 @@ public class DeleteBrandStaleTaskService {
|
||||
ProductRiskStaleCheckStats stats = new ProductRiskStaleCheckStats();
|
||||
long minutes = Math.max(1L, deleteBrandProgressProperties.getProductRiskStaleTimeoutMinutes());
|
||||
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getProductRiskInitialTimeoutMinutes());
|
||||
long staleTimeoutMillis = Duration.ofMinutes(minutes).toMillis();
|
||||
long nowMillis = System.currentTimeMillis();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime threshold = now.minusMinutes(minutes);
|
||||
LocalDateTime initialThreshold = now.minusMinutes(initialMinutes);
|
||||
@@ -146,9 +153,16 @@ public class DeleteBrandStaleTaskService {
|
||||
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
|
||||
for (FileTaskEntity task : runningTasks) {
|
||||
boolean hasUploadedPayload = productRiskTaskCacheService.hasAnyShopMergedPayload(task.getId());
|
||||
long lastPayloadHeartbeatMillis = productRiskTaskCacheService.getTaskHeartbeatMillis(task.getId());
|
||||
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|
||||
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
|
||||
boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows;
|
||||
if (lastPayloadHeartbeatMillis > 0L && nowMillis - lastPayloadHeartbeatMillis < staleTimeoutMillis) {
|
||||
stats.skippedTaskCount++;
|
||||
log.info("[stale-check] product-risk skip recent-task-heartbeat taskId={} lastPayloadHeartbeatMillis={} timeoutMinutes={} updatedAt={}",
|
||||
task.getId(), lastPayloadHeartbeatMillis, minutes, task.getUpdatedAt());
|
||||
continue;
|
||||
}
|
||||
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
|
||||
stats.skippedTaskCount++;
|
||||
log.info("[stale-check] product-risk skip initial-grace taskId={} createdAt={} initialThreshold={}",
|
||||
@@ -241,6 +255,13 @@ public class DeleteBrandStaleTaskService {
|
||||
|
||||
@Scheduled(cron = "${aiimage.delete-brand-progress.finalize-check-cron:30 */2 * * * *}")
|
||||
public void finalizeCompletedRunningTasks() {
|
||||
DistributedJobLockService.LockHandle lockHandle =
|
||||
distributedJobLockService.tryLock("delete-brand:finalize-check", FINALIZE_CHECK_LOCK_TTL);
|
||||
if (lockHandle == null) {
|
||||
log.info("[finalize-check] skip because another instance holds the distributed lock");
|
||||
return;
|
||||
}
|
||||
try (lockHandle) {
|
||||
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
|
||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||
@@ -251,7 +272,8 @@ public class DeleteBrandStaleTaskService {
|
||||
try {
|
||||
deleteBrandRunService.tryFinalizeTask(task.getId(), true);
|
||||
} catch (Exception ignored) {
|
||||
// 定时补偿不影响主流程;下次调度或人工重试时再处理。
|
||||
// Keep compensation best-effort; the next schedule can retry.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,6 @@ public class ProductRiskShopPayloadDto {
|
||||
description = "五国数据:JSON 对象的键为枚举 ProductRiskCountryCode(DE、FR、ES、IT、UK),"
|
||||
+ "值为该国 sheet 的行列表;省略的键表示该国无数据行。")
|
||||
private Map<ProductRiskCountryCode, List<ProductRiskRowDto>> countries = new LinkedHashMap<>();
|
||||
|
||||
private Boolean shopDone;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.nanri.aiimage.modules.productrisk.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskShopPayloadDto;
|
||||
@@ -9,6 +8,7 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@@ -42,6 +42,7 @@ public class ProductRiskTaskCacheService {
|
||||
try {
|
||||
stringRedisTemplate.opsForHash().put(buildShopPayloadKey(taskId), shopKey, objectMapper.writeValueAsString(payload));
|
||||
stringRedisTemplate.expire(buildShopPayloadKey(taskId), Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||
touchTaskHeartbeat(taskId);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("暂存商品风险店铺缓存失败");
|
||||
}
|
||||
@@ -81,14 +82,49 @@ public class ProductRiskTaskCacheService {
|
||||
return size != null && size > 0;
|
||||
}
|
||||
|
||||
public long countShopMergedPayload(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId));
|
||||
return size == null ? 0L : size;
|
||||
}
|
||||
|
||||
public long getTaskHeartbeatMillis(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return 0L;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteTaskCache(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
|
||||
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
|
||||
}
|
||||
|
||||
private String buildShopPayloadKey(Long taskId) {
|
||||
return "product-risk:task:shop-payload:" + taskId;
|
||||
}
|
||||
|
||||
public void touchTaskHeartbeat(Long taskId) {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
buildTaskHeartbeatKey(taskId),
|
||||
String.valueOf(Instant.now().toEpochMilli()),
|
||||
Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||
}
|
||||
|
||||
private String buildTaskHeartbeatKey(Long taskId) {
|
||||
return "product-risk:task:heartbeat:" + taskId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ public class ProductRiskTaskService {
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
if (latest.isEmpty()) {
|
||||
log.warn("[product-risk] reconcile delete empty taskId={} oldStatus={} -> deleting task", taskId, oldStatus);
|
||||
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
return;
|
||||
}
|
||||
@@ -260,9 +261,7 @@ public class ProductRiskTaskService {
|
||||
log.warn("[product-risk] reconcile status changed taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} errorMessage={}",
|
||||
taskId, oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
|
||||
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
productRiskTaskCacheService.deleteTaskCache(taskId);
|
||||
}
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
}
|
||||
|
||||
public ProductRiskTaskBatchVo getTaskDetailsBatch(List<Long> taskIds) {
|
||||
@@ -338,6 +337,7 @@ public class ProductRiskTaskService {
|
||||
task.setCreatedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.insert(task);
|
||||
productRiskTaskCacheService.touchTaskHeartbeat(task.getId());
|
||||
|
||||
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
|
||||
for (ProductRiskShopQueueItemVo item : uniqueItems) {
|
||||
@@ -448,17 +448,18 @@ public class ProductRiskTaskService {
|
||||
|
||||
// 分次上报累积:合并到 Redis 缓存,再按“合并后是否完成”决定是否出包。
|
||||
ProductRiskShopPayloadDto mergedPayload = mergeShopPayload(taskId, shopKey, payload);
|
||||
boolean incomingDone = payloadHasAnyDoneTrue(payload);
|
||||
boolean incomingShopDone = hasShopDoneSignal(payload);
|
||||
int mergedRows = countPayloadRows(mergedPayload);
|
||||
boolean shopDone = isShopPayloadCompleted(mergedPayload);
|
||||
log.info("[product-risk] shop merged taskId={} shop={} incomingDone={} mergedDone={} mergedRows={} payloadCountries={}",
|
||||
log.info("[product-risk] shop merged taskId={} shop={} incomingShopDone={} mergedDone={} mergedRows={} payloadCountries={} mergedShopDoneFlag={}",
|
||||
taskId,
|
||||
shopKey,
|
||||
incomingDone,
|
||||
incomingShopDone,
|
||||
shopDone,
|
||||
mergedRows,
|
||||
mergedPayload.getCountries() == null ? 0 : mergedPayload.getCountries().size());
|
||||
if (incomingDone && mergedRows <= 0) {
|
||||
mergedPayload.getCountries() == null ? 0 : mergedPayload.getCountries().size(),
|
||||
mergedPayload.getShopDone());
|
||||
if (incomingShopDone && mergedRows <= 0) {
|
||||
markResultNoData(fr, "没有数据");
|
||||
productRiskTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
log.info("[product-risk] shop finished without data taskId={} shop={}", taskId, shopKey);
|
||||
@@ -535,6 +536,7 @@ public class ProductRiskTaskService {
|
||||
log.warn("[product-risk] submitResult status evaluated taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} matchedShopCount={} skippedUnmatchedCount={} waitingCount={} assembledCount={} fallbackMatchedCount={} batchErrors={}",
|
||||
taskId, oldStatus, task.getStatus(), ok, fail, allDone, matchedShopCount, skippedUnmatchedCount,
|
||||
waitingCount, assembledCount, fallbackMatchedCount, batchErrors);
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -577,8 +579,8 @@ public class ProductRiskTaskService {
|
||||
ProductRiskShopPayloadDto cachedPayload = shopKey == null ? null : cachedPayloadByShop.get(shopKey);
|
||||
|
||||
if (cachedPayload == null) {
|
||||
markResultFailed(fr, "Python interrupted before this shop finished uploading results");
|
||||
batchErrors.add(shopKey + ": Python interrupted before this shop finished uploading results");
|
||||
markResultFailed(fr, "Python 在该店铺结果上传完成前中断");
|
||||
batchErrors.add(shopKey + ": Python 在该店铺结果上传完成前中断");
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
@@ -591,7 +593,7 @@ public class ProductRiskTaskService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (countPayloadRows(cachedPayload) <= 0 && payloadHasAnyDoneTrue(cachedPayload)) {
|
||||
if (countPayloadRows(cachedPayload) <= 0 && isShopPayloadCompleted(cachedPayload)) {
|
||||
markResultNoData(fr, "没有数据");
|
||||
productRiskTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
@@ -601,21 +603,26 @@ public class ProductRiskTaskService {
|
||||
}
|
||||
|
||||
if (countPayloadRows(cachedPayload) <= 0) {
|
||||
markResultFailed(fr, "Python interrupted before any assembleable rows were uploaded");
|
||||
batchErrors.add(shopKey + ": Python interrupted before any assembleable rows were uploaded");
|
||||
markResultFailed(fr, "Python 在上传可组装结果前中断");
|
||||
batchErrors.add(shopKey + ": Python 在上传可组装结果前中断");
|
||||
productRiskTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isShopPayloadCompleted(cachedPayload)) {
|
||||
log.warn("[product-risk] stale finalize assembling partial payload taskId={} shop={} rowCount={} fromCompensation={}",
|
||||
taskId, shopKey, countPayloadRows(cachedPayload), fromCompensation);
|
||||
}
|
||||
|
||||
try {
|
||||
assembleShopResult(fr, shopKey, cachedPayload, workRoot);
|
||||
productRiskTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
log.warn("[product-risk] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={}",
|
||||
taskId, shopKey, fromCompensation, isShopPayloadCompleted(cachedPayload));
|
||||
log.warn("[product-risk] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={} rowCount={}",
|
||||
taskId, shopKey, fromCompensation, isShopPayloadCompleted(cachedPayload), countPayloadRows(cachedPayload));
|
||||
} catch (Exception ex) {
|
||||
String message = ex.getMessage() == null ? "assemble failed" : ex.getMessage();
|
||||
String message = ex.getMessage() == null ? "结果组装失败" : ex.getMessage();
|
||||
markResultFailed(fr, message);
|
||||
batchErrors.add(shopKey + ": " + message);
|
||||
productRiskTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
@@ -739,6 +746,19 @@ public class ProductRiskTaskService {
|
||||
fileTaskMapper.updateById(task);
|
||||
log.warn("[product-risk] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} batchErrors={} errorMessage={}",
|
||||
task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, batchErrors, task.getErrorMessage());
|
||||
cleanupTaskCacheIfTerminal(task.getId(), task.getStatus());
|
||||
}
|
||||
|
||||
private void cleanupTaskCacheIfTerminal(Long taskId, String taskStatus) {
|
||||
if (!"SUCCESS".equals(taskStatus) && !"FAILED".equals(taskStatus) && !"DELETE_EMPTY".equals(taskStatus)) {
|
||||
return;
|
||||
}
|
||||
long cachedShopCount = productRiskTaskCacheService.countShopMergedPayload(taskId);
|
||||
if (cachedShopCount > 0) {
|
||||
log.warn("[product-risk] clearing merged payload cache taskId={} status={} cachedShopCount={}",
|
||||
taskId, taskStatus, cachedShopCount);
|
||||
}
|
||||
productRiskTaskCacheService.deleteTaskCache(taskId);
|
||||
}
|
||||
|
||||
private ProductRiskTaskDetailVo buildTaskDetail(FileTaskEntity task) {
|
||||
@@ -879,6 +899,7 @@ public class ProductRiskTaskService {
|
||||
if (merged == null) {
|
||||
merged = new ProductRiskShopPayloadDto();
|
||||
}
|
||||
boolean incomingShopDone = hasShopDoneSignal(incoming);
|
||||
|
||||
if (incoming.getShopName() != null && !incoming.getShopName().isBlank()) {
|
||||
merged.setShopName(incoming.getShopName().trim());
|
||||
@@ -889,6 +910,9 @@ public class ProductRiskTaskService {
|
||||
if (incoming.getError() != null && !incoming.getError().isBlank()) {
|
||||
merged.setError(incoming.getError());
|
||||
}
|
||||
if (Boolean.TRUE.equals(merged.getShopDone()) || incomingShopDone || Boolean.TRUE.equals(incoming.getShopDone())) {
|
||||
merged.setShopDone(Boolean.TRUE);
|
||||
}
|
||||
|
||||
Map<com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode, List<ProductRiskRowDto>> nextCountries =
|
||||
merged.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(merged.getCountries());
|
||||
@@ -1017,7 +1041,27 @@ public class ProductRiskTaskService {
|
||||
}
|
||||
|
||||
private boolean isShopPayloadCompleted(ProductRiskShopPayloadDto payload) {
|
||||
return countPayloadRows(payload) > 0 && payloadHasAnyDoneTrue(payload);
|
||||
return Boolean.TRUE.equals(payload == null ? null : payload.getShopDone());
|
||||
}
|
||||
|
||||
private boolean hasShopDoneSignal(ProductRiskShopPayloadDto payload) {
|
||||
if (payload == null || payload.getCountries() == null) {
|
||||
return false;
|
||||
}
|
||||
for (List<ProductRiskRowDto> rows : payload.getCountries().values()) {
|
||||
if (rows == null) {
|
||||
continue;
|
||||
}
|
||||
for (ProductRiskRowDto row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
if (isDoneValue(row.getDone()) && isEmptyBusinessRow(row)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private int countPayloadRows(ProductRiskShopPayloadDto payload) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -13,6 +14,7 @@ import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ShopMatchTaskCacheService {
|
||||
|
||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||
@@ -78,7 +80,11 @@ public class ShopMatchTaskCacheService {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[shop-match] delete task cache failed taskId={} msg={}", taskId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildShopPayloadKey(Long taskId) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -49,6 +50,7 @@ public class ShopMatchTaskService {
|
||||
|
||||
private static final String MODULE_TYPE = "SHOP_MATCH";
|
||||
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
@@ -224,7 +226,7 @@ public class ShopMatchTaskService {
|
||||
|
||||
List<String> countryCodes = normalizeCountryCodes(request.getCountryCodes());
|
||||
List<LocalDateTime> scheduleTimes = normalizeScheduleTimes(request.getScheduleTimes());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = now();
|
||||
|
||||
ShopMatchCreateTaskRequest persistedRequest = new ShopMatchCreateTaskRequest();
|
||||
persistedRequest.setUserId(request.getUserId());
|
||||
@@ -263,7 +265,7 @@ public class ShopMatchTaskService {
|
||||
result.setSourceFileUrl(item.getShopId());
|
||||
result.setUserId(request.getUserId());
|
||||
result.setSuccess(0);
|
||||
result.setCreatedAt(LocalDateTime.now());
|
||||
result.setCreatedAt(now);
|
||||
fileResultMapper.insert(result);
|
||||
snapshot.add(toSnapshotVo(result, item, task.getStatus()));
|
||||
}
|
||||
@@ -302,7 +304,10 @@ public class ShopMatchTaskService {
|
||||
if (!"SCHEDULED".equals(task.getStatus())) {
|
||||
throw new BusinessException("任务状态不正确");
|
||||
}
|
||||
if (task.getScheduledAt() != null && LocalDateTime.now().isBefore(task.getScheduledAt().minusSeconds(90))) {
|
||||
LocalDateTime now = now();
|
||||
if (task.getScheduledAt() != null && now.isBefore(task.getScheduledAt().minusSeconds(90))) {
|
||||
log.warn("[shop-match] activate rejected taskId={} stageIndex={} now={} scheduledAt={} earliestActivateAt={}",
|
||||
taskId, stageIndex, now, task.getScheduledAt(), task.getScheduledAt().minusSeconds(90));
|
||||
throw new BusinessException("未到定时执行时间");
|
||||
}
|
||||
ShopMatchCreateTaskRequest state = parseTaskRequest(task);
|
||||
@@ -315,7 +320,7 @@ public class ShopMatchTaskService {
|
||||
}
|
||||
state.setActiveStageIndex(stageIndex);
|
||||
task.setStatus("RUNNING");
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(now);
|
||||
persistTaskRequest(task, state);
|
||||
fileTaskMapper.updateById(task);
|
||||
}
|
||||
@@ -350,7 +355,7 @@ public class ShopMatchTaskService {
|
||||
state.setActiveStageIndex(null);
|
||||
task.setStatus("SCHEDULED");
|
||||
task.setScheduledAt(state.getScheduleTimes().get(nextIndex));
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(now());
|
||||
task.setFinishedAt(null);
|
||||
persistTaskRequest(task, state);
|
||||
fileTaskMapper.updateById(task);
|
||||
@@ -499,12 +504,17 @@ public class ShopMatchTaskService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isShopPayloadCompleted(cachedPayload)) {
|
||||
log.warn("[shop-match] stale finalize assembling partial payload taskId={} shop={} rowCount={} fromCompensation={}",
|
||||
taskId, shopKey, countPayloadRows(cachedPayload), fromCompensation);
|
||||
}
|
||||
|
||||
try {
|
||||
assembleShopResult(result, shopKey, cachedPayload, workRoot);
|
||||
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
log.warn("[shop-match] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={}",
|
||||
taskId, shopKey, fromCompensation, isShopPayloadCompleted(cachedPayload));
|
||||
log.warn("[shop-match] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={} rowCount={}",
|
||||
taskId, shopKey, fromCompensation, isShopPayloadCompleted(cachedPayload), countPayloadRows(cachedPayload));
|
||||
} catch (Exception ex) {
|
||||
String message = ex.getMessage() == null ? "结果组装失败" : ex.getMessage();
|
||||
markResultFailed(result, message);
|
||||
@@ -584,7 +594,7 @@ public class ShopMatchTaskService {
|
||||
}
|
||||
task.setSuccessFileCount(ok);
|
||||
task.setFailedFileCount(fail);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(now());
|
||||
if (!allDone) {
|
||||
task.setStatus("RUNNING");
|
||||
task.setErrorMessage(null);
|
||||
@@ -592,15 +602,15 @@ public class ShopMatchTaskService {
|
||||
} else if (ok > 0 && fail == 0) {
|
||||
task.setStatus("SUCCESS");
|
||||
task.setErrorMessage(null);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(now());
|
||||
} else if (ok > 0) {
|
||||
task.setStatus("SUCCESS");
|
||||
task.setErrorMessage(String.join(";", errors.isEmpty() ? batchErrors : errors));
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(now());
|
||||
} else {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(errors.isEmpty() ? "全部店铺处理失败" : String.join(";", errors));
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(now());
|
||||
}
|
||||
try {
|
||||
task.setResultJson(objectMapper.writeValueAsString(buildSnapshotFromDb(task.getId(), task.getStatus())));
|
||||
@@ -815,6 +825,10 @@ public class ShopMatchTaskService {
|
||||
return time == null ? null : time.toString();
|
||||
}
|
||||
|
||||
private LocalDateTime now() {
|
||||
return LocalDateTime.now(BUSINESS_ZONE);
|
||||
}
|
||||
|
||||
private List<ProductRiskShopQueueItemVo> dedupeQueueItems(List<ProductRiskShopQueueItemVo> raw) {
|
||||
Map<String, ProductRiskShopQueueItemVo> byNorm = new LinkedHashMap<>();
|
||||
for (ProductRiskShopQueueItemVo item : raw) {
|
||||
@@ -857,7 +871,7 @@ public class ShopMatchTaskService {
|
||||
return List.of();
|
||||
}
|
||||
List<LocalDateTime> out = new ArrayList<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = now();
|
||||
LocalDateTime previous = null;
|
||||
for (LocalDateTime time : raw) {
|
||||
if (time == null) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.config.ModuleCleanupProperties;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
@@ -13,6 +14,7 @@ import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -24,14 +26,23 @@ import java.util.Set;
|
||||
public class ModuleHistoryCleanupService {
|
||||
|
||||
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
|
||||
private static final Duration CLEANUP_LOCK_TTL = Duration.ofHours(2);
|
||||
|
||||
private final ModuleCleanupProperties moduleCleanupProperties;
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
|
||||
@Transactional
|
||||
@Scheduled(cron = "${aiimage.module-cleanup.cron:0 0 0 * * *}")
|
||||
public void cleanupConfiguredModules() {
|
||||
DistributedJobLockService.LockHandle lockHandle =
|
||||
distributedJobLockService.tryLock("module-history:cleanup", CLEANUP_LOCK_TTL);
|
||||
if (lockHandle == null) {
|
||||
log.info("[module-cleanup] skip because another instance holds the distributed lock");
|
||||
return;
|
||||
}
|
||||
try (lockHandle) {
|
||||
if (!moduleCleanupProperties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
@@ -79,6 +90,7 @@ public class ModuleHistoryCleanupService {
|
||||
log.info("模块历史清理完成: moduleTypes={}, deletedResults={}, resetTasks={}, deletedTasks={}, skippedActiveTaskIds={}",
|
||||
moduleTypes, deletedResults, resetTasks, deletedTasks, skippedActiveTaskIds);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTerminalStatus(String status) {
|
||||
if (status == null || status.isBlank()) {
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
package com.nanri.aiimage.modules.ziniao.service;
|
||||
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ZiniaoShopIndexRefreshService {
|
||||
|
||||
private static final Duration REFRESH_LOCK_TTL = Duration.ofMinutes(30);
|
||||
|
||||
private final ZiniaoShopIndexService ziniaoShopIndexService;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
|
||||
@Scheduled(cron = "${aiimage.ziniao.shop-index-refresh-cron:0 */10 * * * *}")
|
||||
public void refreshShopIndex() {
|
||||
DistributedJobLockService.LockHandle lockHandle = distributedJobLockService.tryLock("ziniao:shop-index-refresh", REFRESH_LOCK_TTL);
|
||||
if (lockHandle == null) {
|
||||
log.info("[ziniao-index] skip refresh because another instance holds the distributed lock");
|
||||
return;
|
||||
}
|
||||
try (lockHandle) {
|
||||
try {
|
||||
ziniaoShopIndexService.refreshShopIndex();
|
||||
} catch (Exception ex) {
|
||||
log.warn("[ziniao-index] refresh failed: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,10 @@
|
||||
<label>栏目标识</label>
|
||||
<input type="text" id="columnKey" placeholder="例如:home_recommend">
|
||||
</div>
|
||||
<div class="form-group" style="min-width:220px;">
|
||||
<label>菜单路由</label>
|
||||
<input type="text" id="columnRoutePath" placeholder="例如:dedupe-total-data">
|
||||
</div>
|
||||
<button class="btn" id="btnAddColumn">新增栏目</button>
|
||||
</div>
|
||||
<p class="msg" id="msgColumn"></p>
|
||||
@@ -210,6 +214,7 @@
|
||||
<th>ID</th>
|
||||
<th>栏目名</th>
|
||||
<th>栏目标识</th>
|
||||
<th>菜单路由</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -522,6 +527,10 @@
|
||||
<label>栏目标识</label>
|
||||
<input type="text" id="editColumnKey" placeholder="栏目标识">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>菜单路由</label>
|
||||
<input type="text" id="editColumnRoutePath" placeholder="菜单路由">
|
||||
</div>
|
||||
<p class="msg" id="msgEditColumn"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<button class="btn" id="btnSaveColumn">保存</button>
|
||||
@@ -771,6 +780,71 @@
|
||||
updateShopManageAccess();
|
||||
});
|
||||
}
|
||||
function applyTabAccess(tabName, canUse) {
|
||||
var tab = document.querySelector('.tab[data-tab="' + tabName + '"]');
|
||||
var panel = document.getElementById('panel-' + tabName);
|
||||
if (tab) tab.style.display = canUse ? '' : 'none';
|
||||
if (panel) panel.style.display = canUse ? '' : 'none';
|
||||
if (!canUse && tab && tab.classList.contains('active')) {
|
||||
tab.classList.remove('active');
|
||||
if (panel) panel.classList.remove('active');
|
||||
var usersTab = document.querySelector('.tab[data-tab="users"]');
|
||||
var usersPanel = document.getElementById('panel-users');
|
||||
if (usersTab) usersTab.classList.add('active');
|
||||
if (usersPanel) usersPanel.classList.add('active');
|
||||
}
|
||||
}
|
||||
function updateAdminOnlyAccess() {
|
||||
var canUse = currentUserRole === 'super_admin';
|
||||
applyTabAccess('columns', canUse);
|
||||
applyTabAccess('shop-keys', canUse);
|
||||
}
|
||||
function updateDedupeTotalDataAccess() {
|
||||
var canUse = currentUserRole === 'super_admin' ||
|
||||
!!currentUserAdminPermissionKeys['admin_dedupe_total_data'] ||
|
||||
!!currentUserAdminPermissionRoutes['dedupe-total-data'];
|
||||
applyTabAccess('dedupe-total-data', canUse);
|
||||
}
|
||||
function updateShopManageAccess() {
|
||||
var canUse = currentUserRole === 'super_admin' ||
|
||||
!!currentUserAdminPermissionKeys['admin_shop_manage'] ||
|
||||
!!currentUserAdminPermissionRoutes['shop-manage'];
|
||||
applyTabAccess('shop-manage', canUse);
|
||||
}
|
||||
function loadCurrentUserAdminPermissions() {
|
||||
currentUserAdminPermissionKeys = {};
|
||||
currentUserAdminPermissionRoutes = {};
|
||||
updateAdminOnlyAccess();
|
||||
if (!currentUserId || currentUserRole === 'super_admin') {
|
||||
updateDedupeTotalDataAccess();
|
||||
updateShopManageAccess();
|
||||
return;
|
||||
}
|
||||
fetch('/api/admin/user/' + currentUserId + '/column-permissions?menu_type=admin')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (!res.success) {
|
||||
updateAdminOnlyAccess();
|
||||
updateDedupeTotalDataAccess();
|
||||
updateShopManageAccess();
|
||||
return;
|
||||
}
|
||||
(res.items || []).forEach(function(item) {
|
||||
var columnKey = (item.column_key || '').trim();
|
||||
var routePath = (item.route_path || '').trim();
|
||||
if (columnKey) currentUserAdminPermissionKeys[columnKey] = true;
|
||||
if (routePath) currentUserAdminPermissionRoutes[routePath] = true;
|
||||
});
|
||||
updateAdminOnlyAccess();
|
||||
updateDedupeTotalDataAccess();
|
||||
updateShopManageAccess();
|
||||
})
|
||||
.catch(function() {
|
||||
updateAdminOnlyAccess();
|
||||
updateDedupeTotalDataAccess();
|
||||
updateShopManageAccess();
|
||||
});
|
||||
}
|
||||
var allColumnsList = [];
|
||||
function loadColumnsForPermission() {
|
||||
fetch('/api/admin/columns')
|
||||
@@ -1994,6 +2068,115 @@
|
||||
document.getElementById('editColumnModal').classList.remove('show');
|
||||
};
|
||||
|
||||
function loadColumns() {
|
||||
fetch('/api/admin/columns')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
var tbody = document.getElementById('columnListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
allColumnsList = res.items || [];
|
||||
if (allColumnsList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无栏目,请先在上方新增</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = allColumnsList.map(function(c) {
|
||||
return '<tr><td>' + c.id + '</td><td>' + (c.name || '') + '</td><td>' + (c.column_key || '') + '</td><td>' + (c.route_path || '') + '</td><td>' + (c.created_at || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm" data-column-edit="' + c.id + '" data-name="' + (c.name || '').replace(/"/g, '"') + '" data-key="' + (c.column_key || '').replace(/"/g, '"') + '" data-route="' + (c.route_path || '').replace(/"/g, '"') + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-column-delete="' + c.id + '" data-name="' + (c.name || '').replace(/"/g, '"') + '">删除</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
bindColumnActions();
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('columnListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function bindColumnActions() {
|
||||
document.querySelectorAll('[data-column-edit]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
document.getElementById('editColumnId').value = btn.dataset.columnEdit || '';
|
||||
document.getElementById('editColumnName').value = (btn.dataset.name || '').replace(/"/g, '"');
|
||||
document.getElementById('editColumnKey').value = (btn.dataset.key || '').replace(/"/g, '"');
|
||||
document.getElementById('editColumnRoutePath').value = (btn.dataset.route || '').replace(/"/g, '"');
|
||||
document.getElementById('msgEditColumn').textContent = '';
|
||||
document.getElementById('editColumnModal').classList.add('show');
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-column-delete]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
if (!confirm('确定删除栏目「' + (btn.dataset.name || '').replace(/"/g, '"') + '」吗?')) return;
|
||||
fetch('/api/admin/column/' + btn.dataset.columnDelete, { method: 'DELETE' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) { loadColumns(); loadColumnsForPermission(); }
|
||||
else { alert(res.error || '删除失败'); }
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
document.getElementById('btnAddColumn').onclick = function() {
|
||||
var name = (document.getElementById('columnName').value || '').trim();
|
||||
var key = (document.getElementById('columnKey').value || '').trim();
|
||||
var routePath = (document.getElementById('columnRoutePath').value || '').trim();
|
||||
var msgEl = document.getElementById('msgColumn');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!name) { msgEl.textContent = '请填写栏目名'; msgEl.classList.add('err'); return; }
|
||||
if (!key) { msgEl.textContent = '请填写栏目标识'; msgEl.classList.add('err'); return; }
|
||||
if (!routePath) { msgEl.textContent = '请填写菜单路由'; msgEl.classList.add('err'); return; }
|
||||
fetch('/api/admin/column', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: 'admin' })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
msgEl.textContent = res.msg || '新增成功';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('columnName').value = '';
|
||||
document.getElementById('columnKey').value = '';
|
||||
document.getElementById('columnRoutePath').value = '';
|
||||
loadColumns();
|
||||
loadColumnsForPermission();
|
||||
} else {
|
||||
msgEl.textContent = res.error || '新增失败';
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
})
|
||||
.catch(function() { msgEl.textContent = '请求失败'; msgEl.classList.add('err'); });
|
||||
};
|
||||
document.getElementById('btnSaveColumn').onclick = function() {
|
||||
var cid = document.getElementById('editColumnId').value;
|
||||
var name = (document.getElementById('editColumnName').value || '').trim();
|
||||
var key = (document.getElementById('editColumnKey').value || '').trim();
|
||||
var routePath = (document.getElementById('editColumnRoutePath').value || '').trim();
|
||||
var msgEl = document.getElementById('msgEditColumn');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!name) { msgEl.textContent = '请填写栏目名'; msgEl.classList.add('err'); return; }
|
||||
if (!key) { msgEl.textContent = '请填写栏目标识'; msgEl.classList.add('err'); return; }
|
||||
if (!routePath) { msgEl.textContent = '请填写菜单路由'; msgEl.classList.add('err'); return; }
|
||||
fetch('/api/admin/column/' + cid, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: 'admin' })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
document.getElementById('editColumnModal').classList.remove('show');
|
||||
loadColumns();
|
||||
loadColumnsForPermission();
|
||||
} else {
|
||||
msgEl.textContent = res.error || '保存失败';
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ========== 分页 ==========
|
||||
function renderPagination(elId, total, page, pageSize, onPage) {
|
||||
var el = document.getElementById(elId);
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
<button type="button" class="btn-run" :disabled="running" @click="submitRun">
|
||||
{{ running ? '解析中...' : '开始解析' }}
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length"
|
||||
@click="pushToPythonQueue">
|
||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||||
</button>
|
||||
<span class="loading-msg">
|
||||
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
|
||||
</span>
|
||||
@@ -133,9 +137,6 @@
|
||||
<span class="status" :class="getTaskStatusInfo(item).className">
|
||||
{{ getTaskStatusInfo(item).text }}
|
||||
</span>
|
||||
<button v-if="item.openStoreUrl" type="button" class="download" @click="triggerItemRun(item)">
|
||||
打开紫鸟
|
||||
</button>
|
||||
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
|
||||
@click="downloadTaskResult(item)">
|
||||
下载
|
||||
@@ -254,9 +255,11 @@ const queuePayloadText = ref('')
|
||||
const taskDetails = ref<Record<number, DeleteBrandTaskDetailVo>>({})
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
const pushing = ref(false)
|
||||
const chainStarted = ref(false)
|
||||
const activeItemKey = ref('')
|
||||
const autoAdvancing = ref(false)
|
||||
const autoRetryTimer = ref<number | null>(null)
|
||||
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
|
||||
|
||||
const currentSectionItems = computed(() => {
|
||||
@@ -375,10 +378,14 @@ const historySectionItems = computed(() =>
|
||||
),
|
||||
);
|
||||
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
||||
const queueRunnableItems = computed(() =>
|
||||
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未入队'),
|
||||
)
|
||||
|
||||
function persistSessionTasks() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
|
||||
if (sessionTasks.value.length === 0) window.localStorage.removeItem(getStorageKey())
|
||||
else window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,7 +497,7 @@ function shouldShowProgress(item: DeleteBrandResultItem) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return { success: false, retryable: true }
|
||||
}
|
||||
|
||||
// 单文件任务:完成/终态后隐藏。
|
||||
@@ -773,7 +780,7 @@ function canDownloadTaskResult(item: DeleteBrandResultItem) {
|
||||
if (item.downloadUrl) return true
|
||||
|
||||
const taskId = item.taskId
|
||||
if (!taskId) return false
|
||||
if (!taskId) return { success: false, retryable: false }
|
||||
const status = taskDetails.value[taskId]?.task?.status
|
||||
return status === 'SUCCESS'
|
||||
}
|
||||
@@ -782,7 +789,7 @@ function mergeTaskDetailItemsIntoSession(detail: DeleteBrandTaskDetailVo) {
|
||||
const taskId = detail?.task?.id
|
||||
if (!taskId) return false
|
||||
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
|
||||
if (!sessionTask) return false
|
||||
if (!sessionTask) return { success: false, retryable: false }
|
||||
|
||||
const detailItems = detail?.items || []
|
||||
let changed = false
|
||||
@@ -953,6 +960,7 @@ function stopPolling() {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
clearAutoRetryTimer()
|
||||
}
|
||||
|
||||
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
|
||||
@@ -1031,7 +1039,9 @@ async function submitRun() {
|
||||
const hasMatchedItems = normalizedItems.some((item) => item.matchStatus === 'MATCHED')
|
||||
const hasPendingItems = normalizedItems.some((item) => item.matchStatus && item.matchStatus !== 'MATCHED')
|
||||
|
||||
queuePushResult.value = '解析完成,等待手动点击打开紫鸟进行单任务处理'
|
||||
queuePushResult.value = hasMatchedItems
|
||||
? '解析完成,可在左侧点击“推送到 Python 队列”开始串行处理'
|
||||
: '解析完成,当前没有可推送的已匹配文件'
|
||||
queuePayloadText.value = ''
|
||||
|
||||
if (normalizedItems.length && normalizedItems[0].taskId) {
|
||||
@@ -1042,7 +1052,7 @@ async function submitRun() {
|
||||
if (hasPendingItems) {
|
||||
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
|
||||
} else if (hasMatchedItems) {
|
||||
ElMessage.success('删除品牌解析完成,请手动推送')
|
||||
ElMessage.success('删除品牌解析完成,请在左侧推送到 Python 队列')
|
||||
} else {
|
||||
ElMessage.warning('当前没有可推送的已匹配文件,请先等待店铺索引刷新。')
|
||||
}
|
||||
@@ -1143,6 +1153,26 @@ function debugAutoAdvance(step: string, payload?: Record<string, unknown>) {
|
||||
console.log(`[DeleteBrandAutoAdvance] ${step}`, payload || {})
|
||||
}
|
||||
|
||||
function clearAutoRetryTimer() {
|
||||
if (autoRetryTimer.value) {
|
||||
window.clearTimeout(autoRetryTimer.value)
|
||||
autoRetryTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoAdvanceRetry(delayMs = 3000) {
|
||||
clearAutoRetryTimer()
|
||||
autoRetryTimer.value = window.setTimeout(() => {
|
||||
autoRetryTimer.value = null
|
||||
maybeAutoAdvance().catch(() => undefined)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
function isQueueBusyError(message?: string) {
|
||||
if (!message) return false
|
||||
return message.includes('当前店铺正在执行中')
|
||||
}
|
||||
|
||||
function isPythonQueueBusy() {
|
||||
for (const task of sessionTasks.value) {
|
||||
for (const item of task.items) {
|
||||
@@ -1160,9 +1190,13 @@ function getItemKey(item: DeleteBrandResultItem) {
|
||||
return `task:${item.taskId || 0}:${item.sourceFilename || ''}`
|
||||
}
|
||||
|
||||
function canPushToQueue(item: DeleteBrandResultItem) {
|
||||
return Boolean(item.taskId && (item.matchStatus === 'MATCHED' || item.matched))
|
||||
}
|
||||
|
||||
function findNextAutoRunnableItem() {
|
||||
return currentSectionItems.value.find((item) => {
|
||||
if (!item.openStoreUrl) return false
|
||||
if (!canPushToQueue(item)) return false
|
||||
if (getItemKey(item) === activeItemKey.value) return false
|
||||
const sessionItem = findSessionItem(item.taskId, item)
|
||||
if (sessionItem?._completed) return false
|
||||
@@ -1177,20 +1211,20 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
|
||||
taskId: item.taskId,
|
||||
sourceFilename: item.sourceFilename,
|
||||
})
|
||||
return false
|
||||
return { success: false, retryable: true }
|
||||
}
|
||||
|
||||
const api = getPywebviewApi()
|
||||
|
||||
const taskId = item.taskId
|
||||
if (!taskId) return false
|
||||
if (!taskId) return { success: false, retryable: false }
|
||||
|
||||
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
|
||||
if (!sessionTask) return false
|
||||
if (!sessionTask) return { success: false, retryable: false }
|
||||
|
||||
if (!api?.enqueue_json) {
|
||||
ElMessage.error('当前环境未启用 pywebview enqueue_json(浏览器环境不会推送)')
|
||||
return false
|
||||
return { success: false, retryable: false }
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -1216,11 +1250,39 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
|
||||
activeItemKey.value = getItemKey(item)
|
||||
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
|
||||
ensurePolling(true)
|
||||
queuePushResult.value = qpr
|
||||
return { success: true, retryable: false }
|
||||
} else {
|
||||
qpr = `推送到 Python 队列失败:${pushResult?.error || '未知错误'}`
|
||||
const message = pushResult?.error || '未知错误'
|
||||
qpr = `推送到 Python 队列失败:${message}`
|
||||
queuePushResult.value = qpr
|
||||
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
|
||||
if (options?.auto && isQueueBusyError(message)) {
|
||||
return { success: false, retryable: true }
|
||||
}
|
||||
}
|
||||
queuePushResult.value = qpr
|
||||
return Boolean(pushResult?.success)
|
||||
return { success: false, retryable: false }
|
||||
}
|
||||
|
||||
async function pushToPythonQueue() {
|
||||
const nextItem = findNextAutoRunnableItem()
|
||||
if (!nextItem) {
|
||||
ElMessage.warning('当前没有可推送的已匹配文件')
|
||||
return
|
||||
}
|
||||
|
||||
pushing.value = true
|
||||
chainStarted.value = true
|
||||
clearAutoRetryTimer()
|
||||
const started = await runItem(nextItem)
|
||||
pushing.value = false
|
||||
if (!started.success) {
|
||||
chainStarted.value = false
|
||||
activeItemKey.value = ''
|
||||
return
|
||||
}
|
||||
ElMessage.success('已开始按顺序推送到 Python 队列')
|
||||
}
|
||||
|
||||
async function maybeAutoAdvance() {
|
||||
@@ -1294,7 +1356,15 @@ async function maybeAutoAdvance() {
|
||||
nextKey: getItemKey(nextItem),
|
||||
})
|
||||
const started = await runItem(nextItem, { auto: true })
|
||||
if (!started) {
|
||||
if (!started.success) {
|
||||
if (started.retryable) {
|
||||
debugAutoAdvance('delay retry next item', {
|
||||
taskId: nextItem.taskId,
|
||||
sourceFilename: nextItem.sourceFilename,
|
||||
})
|
||||
scheduleAutoAdvanceRetry()
|
||||
return
|
||||
}
|
||||
debugAutoAdvance('stop chain: runItem returned false', {
|
||||
taskId: nextItem.taskId,
|
||||
sourceFilename: nextItem.sourceFilename,
|
||||
@@ -1306,15 +1376,6 @@ async function maybeAutoAdvance() {
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerItemRun(item: DeleteBrandResultItem) {
|
||||
chainStarted.value = true
|
||||
const started = await runItem(item)
|
||||
if (!started) {
|
||||
chainStarted.value = false
|
||||
activeItemKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSessionTasksFromStorage()
|
||||
const taskIds = getPollingTaskIds()
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<p class="hint listing-filter-hint">与「推送到 Python 队列」一并下发,供 Python 区分处理场景。</p>
|
||||
<div class="listing-filter-row">
|
||||
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
|
||||
<el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
|
||||
<el-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
|
||||
:value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
@@ -196,6 +196,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||
import {
|
||||
addProductRiskCandidate,
|
||||
createProductRiskTask,
|
||||
@@ -238,17 +239,8 @@ const COUNTRY_OPTIONS = [
|
||||
{ code: 'ES', label: '西班牙' },
|
||||
] as const
|
||||
|
||||
/** 与 Python 约定:队列 payload.data.risk_listing_filter */
|
||||
const PRODUCT_RISK_LISTING_FILTER_OPTIONS = [
|
||||
{ value: 'SearchSuppressed', label: '在搜索结果中禁止显示' },
|
||||
{ value: 'ApprovalRequired', label: '需要批准' },
|
||||
{ value: 'Active', label: '在售' },
|
||||
] as const
|
||||
|
||||
type ProductRiskListingFilter = (typeof PRODUCT_RISK_LISTING_FILTER_OPTIONS)[number]['value']
|
||||
|
||||
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
||||
const productRiskListingFilter = ref<ProductRiskListingFilter>('SearchSuppressed')
|
||||
const productRiskListingFilter = ref<ListingFilterValue>('SearchSuppressed')
|
||||
const dragCountryIndex = ref<number | null>(null)
|
||||
const countryPrefSaving = ref(false)
|
||||
/** 用户已改过顺序/勾选后,忽略晚到的 GET,避免把界面打回全选 */
|
||||
@@ -399,6 +391,19 @@ function matchedItemsStorageKey() {
|
||||
return `product-risk:matched-items:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
if (shouldRemove) {
|
||||
window.localStorage.removeItem(key)
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
function loadTaskDetailsFromStorage() {
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(taskStatusStorageKey()) : null
|
||||
@@ -416,12 +421,7 @@ function loadTaskDetailsFromStorage() {
|
||||
}
|
||||
|
||||
function saveTaskDetailsToStorage() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(taskStatusStorageKey(), JSON.stringify(taskDetails.value))
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
setStorageJson(taskStatusStorageKey(), taskDetails.value, Object.keys(taskDetails.value).length === 0)
|
||||
}
|
||||
|
||||
function loadTaskSnapshotsFromStorage() {
|
||||
@@ -441,12 +441,7 @@ function loadTaskSnapshotsFromStorage() {
|
||||
}
|
||||
|
||||
function saveTaskSnapshotsToStorage() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(taskSnapshotsStorageKey(), JSON.stringify(taskSnapshots.value))
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0)
|
||||
}
|
||||
|
||||
function loadMatchedItemsFromStorage() {
|
||||
@@ -461,21 +456,16 @@ function loadMatchedItemsFromStorage() {
|
||||
}
|
||||
|
||||
function saveMatchedItemsToStorage() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(matchedItemsStorageKey(), JSON.stringify(matchedItems.value))
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
|
||||
}
|
||||
|
||||
function rowKeyForMatch(row: ProductRiskShopQueueItem) {
|
||||
function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | null }) {
|
||||
const name = (row.shopName || '').trim()
|
||||
const id = row.shopId ?? ''
|
||||
return `${name}\u0001${id}`
|
||||
}
|
||||
|
||||
function removeMatchedRowsLocally(rows: ProductRiskShopQueueItem[]) {
|
||||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||||
if (!rows.length) return
|
||||
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
||||
matchedItems.value = matchedItems.value.filter((row) => !keys.has(rowKeyForMatch(row)))
|
||||
@@ -527,6 +517,10 @@ async function deleteTaskRecord(item: ProductRiskHistoryItem) {
|
||||
ElMessage.warning('无法删除:缺少记录标识')
|
||||
return
|
||||
}
|
||||
if (item.taskId != null && item.taskId > 0) {
|
||||
removePollingTask(item.taskId)
|
||||
}
|
||||
removeMatchedRowsLocally([item])
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
@@ -551,9 +545,7 @@ function loadPollingIdsFromStorage() {
|
||||
}
|
||||
|
||||
function savePollingIds() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(sessionKey(), JSON.stringify(pollingTaskIds.value))
|
||||
}
|
||||
setStorageJson(sessionKey(), pollingTaskIds.value, pollingTaskIds.value.length === 0)
|
||||
}
|
||||
|
||||
function addPollingTask(taskId: number) {
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="countryPrefSaving" class="country-pref-status">保存中...</div>
|
||||
<div class="section-title">商品列表筛选</div>
|
||||
<p class="hint listing-filter-hint">与“推送到 Python 队列”一并下发,供 Python 区分处理场景。</p>
|
||||
<div class="listing-filter-row">
|
||||
<el-select v-model="shopMatchListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
|
||||
<el-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="section-title">定时执行</div>
|
||||
<label class="schedule-switch"><input v-model="scheduleEnabled" type="checkbox" :disabled="pushing" @change="onScheduleToggle" /><span>启用多时间点调度</span></label>
|
||||
<div v-if="scheduleEnabled" class="schedule-config">
|
||||
@@ -100,6 +107,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
|
||||
@@ -108,6 +116,7 @@ const shopInput = ref('')
|
||||
const candidates = ref<ShopMatchCandidateVo[]>([])
|
||||
const selectedCandidates = ref<ShopMatchCandidateVo[]>([])
|
||||
const matchedItems = ref<ShopMatchShopQueueItem[]>([])
|
||||
const shopMatchListingFilter = ref<ListingFilterValue>('Active')
|
||||
const adding = ref(false)
|
||||
const matching = ref(false)
|
||||
const pushing = ref(false)
|
||||
@@ -144,6 +153,7 @@ function sessionKey() { return `shop-match:tasks:${uidForStorage()}` }
|
||||
function taskStatusStorageKey() { return `shop-match:task-status:${uidForStorage()}` }
|
||||
function taskSnapshotsStorageKey() { return `shop-match:task-snapshots:${uidForStorage()}` }
|
||||
function matchedItemsStorageKey() { return `shop-match:matched-items:${uidForStorage()}` }
|
||||
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) { if (shouldRemove) window.localStorage.removeItem(key); else window.localStorage.setItem(key, JSON.stringify(value)) }
|
||||
function rowKeyForMatch(row: ShopMatchShopQueueItem) { return `${(row.shopName || '').trim()}::${row.shopId || ''}` }
|
||||
function taskRowKey(item: ShopMatchHistoryItem) { return `${normalizeTaskId(item.taskId)}-${item.resultId || 0}-${item.shopName || ''}` }
|
||||
function countryLabel(code: string) { return COUNTRY_OPTIONS.find((item) => item.code === code)?.label || code }
|
||||
@@ -164,13 +174,13 @@ function formatDateTime(value?: string) { return value ? value.replace('T', ' ')
|
||||
function formatTimeOnly(value?: string) { return value ? value.slice(11, 16) : '-' }
|
||||
function uniqueTaskRows(rows: ShopMatchHistoryItem[], snapshots: Record<number, ShopMatchTaskDetailVo>, section: 'current' | 'history') { const map = new Map<string, ShopMatchHistoryItem>(); for (const row of rows) map.set(taskRowKey(row), row); for (const [taskIdText, snapshot] of Object.entries(snapshots)) { const taskId = normalizeTaskId(taskIdText); const first = snapshot.items?.[0]; if (!first || !taskId) continue; const terminal = isTaskTerminalById(taskId); if ((section === 'current' && terminal) || (section === 'history' && !terminal)) continue; const key = taskRowKey({ ...first, taskId }); if (!map.has(key)) map.set(key, { ...first, taskId }) } return Array.from(map.values()) }
|
||||
function loadPollingIdsFromStorage() { try { const raw = window.localStorage.getItem(sessionKey()); const parsed = raw ? JSON.parse(raw) : []; pollingTaskIds.value = Array.isArray(parsed) ? parsed.filter((item): item is number => typeof item === 'number' && item > 0) : [] } catch { pollingTaskIds.value = [] } }
|
||||
function savePollingIds() { window.localStorage.setItem(sessionKey(), JSON.stringify(pollingTaskIds.value)) }
|
||||
function savePollingIds() { setStorageJson(sessionKey(), pollingTaskIds.value, pollingTaskIds.value.length === 0) }
|
||||
function loadTaskDetailsFromStorage() { try { const raw = window.localStorage.getItem(taskStatusStorageKey()); taskDetails.value = raw ? JSON.parse(raw) : {} } catch { taskDetails.value = {} } }
|
||||
function saveTaskDetailsToStorage() { window.localStorage.setItem(taskStatusStorageKey(), JSON.stringify(taskDetails.value)) }
|
||||
function saveTaskDetailsToStorage() { setStorageJson(taskStatusStorageKey(), taskDetails.value, Object.keys(taskDetails.value).length === 0) }
|
||||
function loadTaskSnapshotsFromStorage() { try { const raw = window.localStorage.getItem(taskSnapshotsStorageKey()); taskSnapshots.value = raw ? JSON.parse(raw) : {} } catch { taskSnapshots.value = {} } }
|
||||
function saveTaskSnapshotsToStorage() { window.localStorage.setItem(taskSnapshotsStorageKey(), JSON.stringify(taskSnapshots.value)) }
|
||||
function saveTaskSnapshotsToStorage() { setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0) }
|
||||
function loadMatchedItemsFromStorage() { try { const raw = window.localStorage.getItem(matchedItemsStorageKey()); matchedItems.value = raw ? JSON.parse(raw) : [] } catch { matchedItems.value = [] } }
|
||||
function saveMatchedItemsToStorage() { window.localStorage.setItem(matchedItemsStorageKey(), JSON.stringify(matchedItems.value)) }
|
||||
function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0) }
|
||||
function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() }
|
||||
function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } }
|
||||
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } }
|
||||
@@ -209,7 +219,7 @@ async function runMatch() { const names = selectedCandidates.value.map((item) =>
|
||||
function removeMatchedRow(row: ShopMatchShopQueueItem) { matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== rowKeyForMatch(row)); saveMatchedItemsToStorage() }
|
||||
function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQueueItem[]) { const keys = new Set(rows.map((row) => rowKeyForMatch(row as ShopMatchShopQueueItem))); matchedItems.value = matchedItems.value.filter((item) => !keys.has(rowKeyForMatch(item))); saveMatchedItemsToStorage() }
|
||||
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const normalized = normalizeScheduleValue(value); const date = resolveScheduleDateTime(normalized); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
|
||||
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], stage_index: stageIndex, final_stage: finalStage } } }
|
||||
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
|
||||
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
|
||||
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await activateShopMatchTask(taskId, stage.stageIndex); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1} 轮`; ensurePolling(true) }
|
||||
function hasRunningTask() { return Object.values(taskDetails.value).some((status) => status === 'RUNNING') || Object.values(taskSnapshots.value).some((detail) => detail.task?.status === 'RUNNING') }
|
||||
@@ -247,6 +257,7 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
|
||||
} else if (resultId > 0) {
|
||||
await deleteShopMatchHistory(resultId)
|
||||
historyItems.value = historyItems.value.filter((row) => Number(row.resultId || 0) !== resultId)
|
||||
removeMatchedRowsLocally([item])
|
||||
} else {
|
||||
throw new Error('缺少可删除的任务标识')
|
||||
}
|
||||
|
||||
@@ -5,22 +5,33 @@
|
||||
<a href="/home" class="btn-home">返回首页</a>
|
||||
</div>
|
||||
|
||||
<nav class="nav-tabs">
|
||||
<div class="nav-tab-group">
|
||||
<div v-for="group in navGroups" :key="group.label" class="nav-dropdown"
|
||||
:class="{ active: group.items.some(item => item.key === active) }">
|
||||
<button type="button" class="nav-dropdown-trigger">
|
||||
<span>{{ group.label }}</span>
|
||||
<span class="nav-dropdown-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-dropdown-menu">
|
||||
<a v-for="item in group.items" :key="item.key" :href="item.href" class="nav-dropdown-item"
|
||||
:class="{ active: active === item.key }">
|
||||
<nav class="nav-sections" aria-label="顶部功能导航">
|
||||
<section
|
||||
v-for="group in navGroups"
|
||||
:key="group.label"
|
||||
class="nav-section"
|
||||
>
|
||||
<div class="nav-section-title">{{ group.label }}</div>
|
||||
<div class="nav-section-items">
|
||||
<template v-for="item in group.items" :key="item.key">
|
||||
<a
|
||||
v-if="item.href"
|
||||
:href="item.href"
|
||||
class="nav-item"
|
||||
:class="{ active: active === item.key }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
class="nav-item disabled"
|
||||
:class="{ active: active === item.key }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<div class="top-right"></div>
|
||||
@@ -28,16 +39,33 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type ActiveNavKey =
|
||||
| 'brand'
|
||||
| 'dedupe'
|
||||
| 'convert'
|
||||
| 'split'
|
||||
| 'delete-brand'
|
||||
| 'product-risk'
|
||||
| 'shop-match'
|
||||
|
||||
type NavItem = {
|
||||
key: string
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
active: 'brand' | 'dedupe' | 'convert' | 'split' | 'delete-brand' | 'product-risk' | 'shop-match'
|
||||
active: ActiveNavKey
|
||||
}>()
|
||||
|
||||
const active = props.active
|
||||
|
||||
const navGroups = [
|
||||
const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }> = [
|
||||
{
|
||||
label: '前端工具',
|
||||
items: [
|
||||
{ key: 'collect', label: '采集数据' },
|
||||
{ key: 'variant', label: '变体分析' },
|
||||
{ key: 'brand', label: '品牌检测', href: '/brand' },
|
||||
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' },
|
||||
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' },
|
||||
@@ -47,22 +75,33 @@ const navGroups = [
|
||||
{
|
||||
label: '运营工具',
|
||||
items: [
|
||||
{ key: 'delete-brand', label: '删除指定ASIN和品牌', href: '/new_web_source/delete-brand.html' },
|
||||
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
|
||||
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
|
||||
{ key: 'shop-match', label: '定时跟价匹配', href: '/new_web_source/shop-match.html' },
|
||||
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
||||
{ key: 'pricing', label: '跟价' },
|
||||
{ key: 'patrol-delete', label: '巡店删除' },
|
||||
{ key: 'withdraw', label: '取款' },
|
||||
{ key: 'shop-status', label: '店铺状态查询' },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
{
|
||||
label: '后勤工具',
|
||||
items: [
|
||||
{ key: 'purchase', label: '采购' },
|
||||
{ key: 'erp', label: 'ERP' },
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top-bar {
|
||||
height: 56px;
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 0 20px;
|
||||
gap: 20px;
|
||||
padding: 10px 24px 12px;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
@@ -72,12 +111,14 @@ const navGroups = [
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -92,129 +133,107 @@ const navGroups = [
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
.nav-sections {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-tab-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.nav-dropdown {
|
||||
.nav-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-dropdown-trigger {
|
||||
.nav-section + .nav-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 14px;
|
||||
bottom: 8px;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
color: #e8dfcf;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.nav-section-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(83, 74, 67, 0.92) 0%, rgba(72, 65, 59, 0.96) 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 112px;
|
||||
height: 38px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
color: #8d8d8d;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-dropdown:hover .nav-dropdown-trigger,
|
||||
.nav-dropdown.active .nav-dropdown-trigger {
|
||||
color: #b9d6ff;
|
||||
background: rgba(77, 126, 189, 0.14);
|
||||
}
|
||||
|
||||
.nav-dropdown.active .nav-dropdown-trigger {
|
||||
background: rgba(77, 126, 189, 0.24);
|
||||
border-color: rgba(91, 148, 219, 0.35);
|
||||
color: #6caeff;
|
||||
}
|
||||
|
||||
.nav-dropdown-arrow {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 220px;
|
||||
padding: 8px;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
background: #171717;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
||||
display: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.nav-dropdown:hover .nav-dropdown-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
color: #b8b8b8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-height: 28px;
|
||||
padding: 0 2px;
|
||||
color: #f3eee4;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.18s ease, border-color 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.nav-dropdown-item:hover {
|
||||
color: #e8f2ff;
|
||||
background: rgba(77, 126, 189, 0.14);
|
||||
.nav-item:hover {
|
||||
color: #fff;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.nav-dropdown-item.active {
|
||||
color: #6caeff;
|
||||
background: rgba(77, 126, 189, 0.24);
|
||||
.nav-item.active {
|
||||
color: #fff;
|
||||
border-bottom-color: #8dc4ff;
|
||||
}
|
||||
|
||||
.nav-item.disabled {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
opacity: 0.82;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
width: 120px;
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1200px) {
|
||||
.top-bar {
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 16px 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
.logo-area {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.nav-tab-group {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
.nav-sections {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu {
|
||||
left: 0;
|
||||
right: auto;
|
||||
.nav-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-section + .nav-section::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const LISTING_FILTER_OPTIONS = [
|
||||
{ value: 'SearchSuppressed', label: '在搜索结果中禁止显示' },
|
||||
{ value: 'ApprovalRequired', label: '需要批准' },
|
||||
{ value: 'Active', label: '在售' },
|
||||
] as const
|
||||
|
||||
export type ListingFilterValue = (typeof LISTING_FILTER_OPTIONS)[number]['value']
|
||||
Reference in New Issue
Block a user