处理修复BUG
This commit is contained in:
@@ -11,5 +11,6 @@ import java.util.List;
|
|||||||
public class ModuleCleanupProperties {
|
public class ModuleCleanupProperties {
|
||||||
private boolean enabled = true;
|
private boolean enabled = true;
|
||||||
private String cron = "0 0 0 * * *";
|
private String cron = "0 0 0 * * *";
|
||||||
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN"));
|
private long retentionDays = 7;
|
||||||
|
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN", "APPEARANCE_PATENT", "SIMILAR_ASIN", "COLLECT_DATA"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo;
|
|||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
||||||
|
import com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackLoopRunService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackLoopRunService;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||||
|
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||||
@@ -58,6 +60,7 @@ public class PriceTrackController {
|
|||||||
private final PriceTrackService priceTrackService;
|
private final PriceTrackService priceTrackService;
|
||||||
private final PriceTrackTaskService priceTrackTaskService;
|
private final PriceTrackTaskService priceTrackTaskService;
|
||||||
private final PriceTrackLoopRunService priceTrackLoopRunService;
|
private final PriceTrackLoopRunService priceTrackLoopRunService;
|
||||||
|
private final SkipPriceAsinService skipPriceAsinService;
|
||||||
|
|
||||||
@GetMapping("/candidates")
|
@GetMapping("/candidates")
|
||||||
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
|
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
|
||||||
@@ -104,6 +107,24 @@ public class PriceTrackController {
|
|||||||
return ApiResponse.success(priceTrackService.matchShops(request));
|
return ApiResponse.success(priceTrackService.matchShops(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tasks/{taskId}/skip-asins/paginated")
|
||||||
|
@Operation(
|
||||||
|
summary = "分页查询任务的跳过 ASIN 数据",
|
||||||
|
description = "根据任务 ID 分页拉取该任务关联的 ASIN 数据。"
|
||||||
|
+ "全量模式:从 biz_skip_price_asin 表查询;"
|
||||||
|
+ "文件模式:从任务 requestJson 中存储的上传文件数据查询。"
|
||||||
|
+ "国家代码从任务的 requestJson.countryCodes 中获取。"
|
||||||
|
)
|
||||||
|
public ApiResponse<SkipPriceAsinPageVo> getTaskSkipAsinsPaginated(
|
||||||
|
@Parameter(description = "任务 ID", required = true, example = "200")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||||
|
@Parameter(description = "每页条数,默认 1000,最大 2000", example = "1000")
|
||||||
|
@RequestParam(value = "page_size", defaultValue = "1000") Integer pageSize) {
|
||||||
|
return ApiResponse.success(priceTrackTaskService.getTaskSkipAsinsPaginated(taskId, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
@Operation(summary = "统计看板", description = "返回备选店铺数、已结束任务数、成功任务数和失败任务数。")
|
@Operation(summary = "统计看板", description = "返回备选店铺数、已结束任务数、成功任务数和失败任务数。")
|
||||||
public ApiResponse<PriceTrackDashboardVo> dashboard(
|
public ApiResponse<PriceTrackDashboardVo> dashboard(
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟价跳过 ASIN 分页返回
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "跟价跳过 ASIN 分页返回")
|
||||||
|
public class SkipPriceAsinPageVo {
|
||||||
|
|
||||||
|
@Schema(description = "当前页码,从 1 开始")
|
||||||
|
private Integer page;
|
||||||
|
|
||||||
|
@Schema(description = "每页条数")
|
||||||
|
private Integer pageSize;
|
||||||
|
|
||||||
|
@Schema(description = "总记录数")
|
||||||
|
private Long total;
|
||||||
|
|
||||||
|
@Schema(description = "总页数")
|
||||||
|
private Integer totalPages;
|
||||||
|
|
||||||
|
@Schema(description = "按国家分组的 ASIN 列表(简化版,仅 ASIN)")
|
||||||
|
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@Schema(description = "按国家分组的 ASIN 详情列表(包含 asin 和 minimumPrice)")
|
||||||
|
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||||
|
}
|
||||||
@@ -145,24 +145,16 @@ public class PriceTrackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean asinMode = request.getAsinFiles() != null && !request.getAsinFiles().isEmpty();
|
boolean asinMode = request.getAsinFiles() != null && !request.getAsinFiles().isEmpty();
|
||||||
Map<String, List<String>> skipAsinsByCountry = asinMode
|
// 不再在 matchShops 时加载全量数据,改由 Python 端分页拉取
|
||||||
? new LinkedHashMap<>()
|
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||||
: skipPriceAsinService.listAllSkipAsinsByCountry();
|
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||||
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = asinMode
|
Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
|
||||||
? new LinkedHashMap<>()
|
|
||||||
: skipPriceAsinService.listAllSkipAsinDetailsByCountry();
|
|
||||||
Map<String, List<Map<String, String>>> asinRowsByCountry =
|
|
||||||
!asinMode
|
|
||||||
? new LinkedHashMap<>()
|
|
||||||
: priceTrackTaskService.parseMatchAsinRowsByCountry(
|
|
||||||
request.getAsinFiles(),
|
|
||||||
request.getCountryCodes());
|
|
||||||
|
|
||||||
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
|
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
|
||||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||||
vo.setSkipAsinDetailsByCountry(skipAsinDetailsByCountry);
|
vo.setSkipAsinDetailsByCountry(skipAsinDetailsByCountry);
|
||||||
vo.setAsinRowsByCountry(asinRowsByCountry);
|
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||||
vo.setMinimumPriceByCountryAndAsin(priceTrackTaskService.buildMinimumPriceLookupForMatch(asinRowsByCountry));
|
vo.setMinimumPriceByCountryAndAsin(new LinkedHashMap<>());
|
||||||
for (String shopName : ordered) {
|
for (String shopName : ordered) {
|
||||||
vo.getItems().add(matchOneShop(shopName, skipAsinsByCountry));
|
vo.getItems().add(matchOneShop(shopName, skipAsinsByCountry));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,10 +428,15 @@ public class PriceTrackTaskService {
|
|||||||
ctx.put("statusMode", request.isStatusMode());
|
ctx.put("statusMode", request.isStatusMode());
|
||||||
ctx.put("asinMode", request.isAsinMode());
|
ctx.put("asinMode", request.isAsinMode());
|
||||||
ctx.put("countryCodes", request.getCountryCodes());
|
ctx.put("countryCodes", request.getCountryCodes());
|
||||||
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
// 不再保存全量 ASIN 数据到 requestJson
|
||||||
ctx.put("skipAsinDetailsByCountry", skipAsinDetailsByCountry);
|
// ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
||||||
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
// ctx.put("skipAsinDetailsByCountry", skipAsinDetailsByCountry);
|
||||||
ctx.put("minimumPriceByCountryAndAsin", minimumPriceByCountryAndAsin);
|
// ctx.put("asinRowsByCountry", asinRowsByCountry);
|
||||||
|
// ctx.put("minimumPriceByCountryAndAsin", minimumPriceByCountryAndAsin);
|
||||||
|
// 文件模式:保存文件路径,供后续分页读取
|
||||||
|
if (request.isAsinMode()) {
|
||||||
|
ctx.put("asinFiles", request.getAsinFiles());
|
||||||
|
}
|
||||||
ctx.put("items", uniqueItems);
|
ctx.put("items", uniqueItems);
|
||||||
ctx.put("loopRunId", request.getLoopRunId());
|
ctx.put("loopRunId", request.getLoopRunId());
|
||||||
ctx.put("roundIndex", request.getRoundIndex());
|
ctx.put("roundIndex", request.getRoundIndex());
|
||||||
@@ -764,6 +769,144 @@ public class PriceTrackTaskService {
|
|||||||
return buildMinimumPriceByCountryAndAsin(asinRowsByCountry);
|
return buildMinimumPriceByCountryAndAsin(asinRowsByCountry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询任务关联的跳过 ASIN 数据
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
* @param page 页码,从 1 开始
|
||||||
|
* @param pageSize 每页条数
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskSkipAsinsPaginated(
|
||||||
|
Long taskId, int page, int pageSize) {
|
||||||
|
if (taskId == null || taskId <= 0) {
|
||||||
|
throw new BusinessException("taskId 不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析任务请求参数
|
||||||
|
PriceTrackCreateTaskRequest request;
|
||||||
|
try {
|
||||||
|
if (task.getRequestJson() == null || task.getRequestJson().isBlank()) {
|
||||||
|
throw new BusinessException("任务请求参数为空");
|
||||||
|
}
|
||||||
|
request = objectMapper.readValue(task.getRequestJson(), PriceTrackCreateTaskRequest.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("任务请求参数解析失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从任务中获取国家代码
|
||||||
|
List<String> countryCodes = request.getCountryCodes();
|
||||||
|
|
||||||
|
// 判断是全量模式还是文件模式
|
||||||
|
boolean isAsinMode = request.isAsinMode();
|
||||||
|
|
||||||
|
if (isAsinMode) {
|
||||||
|
// 文件模式:从任务 requestJson 中的上传文件解析数据并分页返回
|
||||||
|
return getTaskAsinFilePaginated(request, countryCodes, page, pageSize);
|
||||||
|
} else {
|
||||||
|
// 全量模式:从 biz_skip_price_asin 表查询
|
||||||
|
return skipPriceAsinService.listSkipAsinsPaginated(null, countryCodes, page, pageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件模式:从任务上传的文件中分页返回 ASIN 数据
|
||||||
|
*/
|
||||||
|
private com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskAsinFilePaginated(
|
||||||
|
PriceTrackCreateTaskRequest request, List<String> countryCodes, int page, int pageSize) {
|
||||||
|
if (page < 1) {
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
if (pageSize < 1 || pageSize > 2000) {
|
||||||
|
pageSize = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析上传文件中的所有 ASIN 数据
|
||||||
|
Map<String, List<Map<String, String>>> allAsinRows = parseAsinRowsByCountry(
|
||||||
|
request.getAsinFiles(),
|
||||||
|
request.getCountryCodes()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 按国家分组并分页
|
||||||
|
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
|
||||||
|
? new ArrayList<>(allAsinRows.keySet())
|
||||||
|
: countryCodes;
|
||||||
|
|
||||||
|
// 计算总记录数(所有国家的 ASIN 行数总和)
|
||||||
|
long totalRows = 0;
|
||||||
|
for (String country : targetCountries) {
|
||||||
|
List<Map<String, String>> rows = allAsinRows.getOrDefault(country, List.of());
|
||||||
|
totalRows += rows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算分页
|
||||||
|
int totalPages = (int) ((totalRows + pageSize - 1) / pageSize);
|
||||||
|
int startIndex = (page - 1) * pageSize;
|
||||||
|
int endIndex = Math.min(startIndex + pageSize, (int) totalRows);
|
||||||
|
|
||||||
|
// 提取当前页的数据(跨国家顺序提取)
|
||||||
|
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||||
|
Map<String, List<Map<String, String>>> skipDetailsByCountry = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
// 初始化所有目标国家
|
||||||
|
for (String country : targetCountries) {
|
||||||
|
skipAsinsByCountry.put(country, new ArrayList<>());
|
||||||
|
skipDetailsByCountry.put(country, new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
int currentIndex = 0;
|
||||||
|
boolean shouldBreak = false;
|
||||||
|
|
||||||
|
for (String country : targetCountries) {
|
||||||
|
if (shouldBreak) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, String>> countryRows = allAsinRows.getOrDefault(country, List.of());
|
||||||
|
for (Map<String, String> row : countryRows) {
|
||||||
|
// 判断当前行是否在分页范围内
|
||||||
|
if (currentIndex >= startIndex && currentIndex < endIndex) {
|
||||||
|
String asin = row.get("asin");
|
||||||
|
String minimumPrice = row.get("minimumPrice");
|
||||||
|
|
||||||
|
if (asin != null && !asin.isBlank()) {
|
||||||
|
skipAsinsByCountry.get(country).add(asin);
|
||||||
|
|
||||||
|
Map<String, String> detail = new LinkedHashMap<>();
|
||||||
|
detail.put("asin", asin);
|
||||||
|
detail.put("minimumPrice", minimumPrice == null ? "" : minimumPrice);
|
||||||
|
skipDetailsByCountry.get(country).add(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndex++;
|
||||||
|
|
||||||
|
// 已达到本页结束位置
|
||||||
|
if (currentIndex >= endIndex) {
|
||||||
|
shouldBreak = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造返回 VO
|
||||||
|
com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo vo =
|
||||||
|
new com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo();
|
||||||
|
vo.setPage(page);
|
||||||
|
vo.setPageSize(pageSize);
|
||||||
|
vo.setTotal(totalRows);
|
||||||
|
vo.setTotalPages(totalPages);
|
||||||
|
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||||
|
vo.setSkipAsinDetailsByCountry(skipDetailsByCountry);
|
||||||
|
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, List<Map<String, String>>> parseAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
|
private Map<String, List<Map<String, String>>> parseAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
|
||||||
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
|
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
|
||||||
if (asinFiles == null || asinFiles.isEmpty()) {
|
if (asinFiles == null || asinFiles.isEmpty()) {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class ProductRiskExcelAssemblyService {
|
|||||||
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
|
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
|
||||||
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
|
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
|
||||||
createTextCell(row, 2, dto == null ? null : dto.getStatus());
|
createTextCell(row, 2, dto == null ? null : dto.getStatus());
|
||||||
createTextCell(row, 3, dto == null || dto.getDone() == null ? null : (dto.getDone() ? "是" : "否"));
|
createTextCell(row, 3, hasBusinessData(dto) ? "是" : "否");
|
||||||
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
|
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
|
||||||
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
|
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
|
||||||
}
|
}
|
||||||
@@ -111,6 +111,18 @@ public class ProductRiskExcelAssemblyService {
|
|||||||
return fallback == null ? "" : fallback;
|
return fallback == null ? "" : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean hasBusinessData(ProductRiskRowDto dto) {
|
||||||
|
return dto != null
|
||||||
|
&& (hasText(dto.getProductAsinSku())
|
||||||
|
|| hasText(dto.getStatus())
|
||||||
|
|| hasText(dto.getRemoveAsin())
|
||||||
|
|| hasText(dto.getRemoveStatus()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasText(String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
private void applyDefaultColumnWidths(Sheet sheet) {
|
private void applyDefaultColumnWidths(Sheet sheet) {
|
||||||
int[] widths = {20, 22, 18, 10, 18, 18};
|
int[] widths = {20, 22, 18, 10, 18, 18};
|
||||||
for (int i = 0; i < widths.length; i++) {
|
for (int i = 0; i < widths.length; i++) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.nanri.aiimage.modules.shopkey.service;
|
package com.nanri.aiimage.modules.shopkey.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
|
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
|
||||||
@@ -35,6 +36,7 @@ import java.math.BigDecimal;
|
|||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -1055,6 +1057,104 @@ public class SkipPriceAsinService {
|
|||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询跳过 ASIN 数据(按商品状态全量模式使用)
|
||||||
|
* 按 ASIN 个数精确分页(而非表记录数)
|
||||||
|
*
|
||||||
|
* @param shopName 店铺名称(可选,为空则查询所有)
|
||||||
|
* @param countryCodes 国家代码列表
|
||||||
|
* @param page 页码,从 1 开始
|
||||||
|
* @param pageSize 每页条数
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo listSkipAsinsPaginated(
|
||||||
|
String shopName, List<String> countryCodes, int page, int pageSize) {
|
||||||
|
if (page < 1) {
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
if (pageSize < 1 || pageSize > 2000) {
|
||||||
|
pageSize = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
LambdaQueryWrapper<SkipPriceAsinEntity> queryWrapper = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||||
|
.orderByDesc(SkipPriceAsinEntity::getId);
|
||||||
|
|
||||||
|
// 如果指定了店铺名,按店铺过滤
|
||||||
|
if (shopName != null && !shopName.trim().isEmpty()) {
|
||||||
|
String normalizedShopName = normalizeBlank(shopName);
|
||||||
|
queryWrapper.eq(SkipPriceAsinEntity::getShopName, normalizedShopName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询所有记录(用于内存分页)
|
||||||
|
List<SkipPriceAsinEntity> allRecords = skipPriceAsinMapper.selectList(queryWrapper);
|
||||||
|
|
||||||
|
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
|
||||||
|
? List.of("DE", "UK", "FR", "IT", "ES")
|
||||||
|
: countryCodes;
|
||||||
|
|
||||||
|
// 展开所有 ASIN 为扁平列表
|
||||||
|
List<AsinItem> flattenedAsins = new ArrayList<>();
|
||||||
|
for (SkipPriceAsinEntity row : allRecords) {
|
||||||
|
for (String country : targetCountries) {
|
||||||
|
String asin = getCountryAsin(row, country);
|
||||||
|
BigDecimal minimumPrice = getCountryMinimumPrice(row, country);
|
||||||
|
|
||||||
|
if (asin != null && !asin.isBlank()) {
|
||||||
|
AsinItem item = new AsinItem();
|
||||||
|
item.country = country;
|
||||||
|
item.asin = asin;
|
||||||
|
item.minimumPrice = minimumPrice;
|
||||||
|
flattenedAsins.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算总数和分页
|
||||||
|
long totalAsins = flattenedAsins.size();
|
||||||
|
int totalPages = (int) ((totalAsins + pageSize - 1) / pageSize);
|
||||||
|
int startIndex = (page - 1) * pageSize;
|
||||||
|
int endIndex = Math.min(startIndex + pageSize, flattenedAsins.size());
|
||||||
|
|
||||||
|
// 提取当前页的 ASIN
|
||||||
|
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||||
|
Map<String, List<Map<String, String>>> skipDetailsByCountry = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (String country : targetCountries) {
|
||||||
|
skipAsinsByCountry.put(country, new ArrayList<>());
|
||||||
|
skipDetailsByCountry.put(country, new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = startIndex; i < endIndex; i++) {
|
||||||
|
AsinItem item = flattenedAsins.get(i);
|
||||||
|
skipAsinsByCountry.get(item.country).add(item.asin);
|
||||||
|
|
||||||
|
Map<String, String> detail = new LinkedHashMap<>();
|
||||||
|
detail.put("asin", item.asin);
|
||||||
|
detail.put("minimumPrice", item.minimumPrice == null ? "" : item.minimumPrice.toPlainString());
|
||||||
|
skipDetailsByCountry.get(item.country).add(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造返回 VO
|
||||||
|
com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo vo =
|
||||||
|
new com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo();
|
||||||
|
vo.setPage(page);
|
||||||
|
vo.setPageSize(pageSize);
|
||||||
|
vo.setTotal(totalAsins);
|
||||||
|
vo.setTotalPages(totalPages);
|
||||||
|
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||||
|
vo.setSkipAsinDetailsByCountry(skipDetailsByCountry);
|
||||||
|
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部辅助类
|
||||||
|
private static class AsinItem {
|
||||||
|
String country;
|
||||||
|
String asin;
|
||||||
|
BigDecimal minimumPrice;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public boolean removeByShopCountryAndAsin(String shopName, String country, String asin) {
|
public boolean removeByShopCountryAndAsin(String shopName, String country, String asin) {
|
||||||
String normalizedShopName = normalizeBlank(shopName);
|
String normalizedShopName = normalizeBlank(shopName);
|
||||||
|
|||||||
@@ -4,10 +4,24 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||||
import com.nanri.aiimage.config.ModuleCleanupProperties;
|
import com.nanri.aiimage.config.ModuleCleanupProperties;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
|
||||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskResultPayloadMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskResultPayloadEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
@@ -15,6 +29,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -27,10 +42,18 @@ public class ModuleHistoryCleanupService {
|
|||||||
|
|
||||||
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
|
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
|
||||||
private static final Duration CLEANUP_LOCK_TTL = Duration.ofHours(2);
|
private static final Duration CLEANUP_LOCK_TTL = Duration.ofHours(2);
|
||||||
|
private static final String COLLECT_DATA_MODULE_TYPE = "COLLECT_DATA";
|
||||||
|
|
||||||
private final ModuleCleanupProperties moduleCleanupProperties;
|
private final ModuleCleanupProperties moduleCleanupProperties;
|
||||||
private final FileTaskMapper fileTaskMapper;
|
private final FileTaskMapper fileTaskMapper;
|
||||||
private final FileResultMapper fileResultMapper;
|
private final FileResultMapper fileResultMapper;
|
||||||
|
private final TaskFileJobMapper taskFileJobMapper;
|
||||||
|
private final TaskResultItemMapper taskResultItemMapper;
|
||||||
|
private final TaskProgressSnapshotMapper taskProgressSnapshotMapper;
|
||||||
|
private final TaskResultPayloadMapper taskResultPayloadMapper;
|
||||||
|
private final TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
private final TaskChunkMapper taskChunkMapper;
|
||||||
|
private final CollectDataItemMapper collectDataItemMapper;
|
||||||
private final DistributedJobLockService distributedJobLockService;
|
private final DistributedJobLockService distributedJobLockService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -50,30 +73,71 @@ public class ModuleHistoryCleanupService {
|
|||||||
if (moduleTypes == null || moduleTypes.isEmpty()) {
|
if (moduleTypes == null || moduleTypes.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
LocalDateTime cutoff = LocalDateTime.now().minusDays(Math.max(0, moduleCleanupProperties.getRetentionDays()));
|
||||||
|
|
||||||
List<FileTaskEntity> moduleTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
List<FileTaskEntity> moduleTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.in(FileTaskEntity::getModuleType, moduleTypes)
|
.in(FileTaskEntity::getModuleType, moduleTypes)
|
||||||
.select(FileTaskEntity::getId, FileTaskEntity::getModuleType, FileTaskEntity::getStatus));
|
.select(FileTaskEntity::getId, FileTaskEntity::getModuleType, FileTaskEntity::getStatus,
|
||||||
|
FileTaskEntity::getUpdatedAt, FileTaskEntity::getFinishedAt));
|
||||||
|
|
||||||
List<Long> cleanupTaskIds = new ArrayList<>();
|
List<Long> cleanupTaskIds = new ArrayList<>();
|
||||||
|
List<Long> cleanupCollectDataTaskIds = new ArrayList<>();
|
||||||
List<Long> skippedActiveTaskIds = new ArrayList<>();
|
List<Long> skippedActiveTaskIds = new ArrayList<>();
|
||||||
|
List<Long> skippedRetainedTaskIds = new ArrayList<>();
|
||||||
for (FileTaskEntity task : moduleTasks) {
|
for (FileTaskEntity task : moduleTasks) {
|
||||||
if (task == null || task.getId() == null) {
|
if (task == null || task.getId() == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isTerminalStatus(task.getStatus())) {
|
if (!isTerminalStatus(task.getStatus())) {
|
||||||
cleanupTaskIds.add(task.getId());
|
|
||||||
} else {
|
|
||||||
skippedActiveTaskIds.add(task.getId());
|
skippedActiveTaskIds.add(task.getId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isExpired(task, cutoff)) {
|
||||||
|
cleanupTaskIds.add(task.getId());
|
||||||
|
if (COLLECT_DATA_MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
cleanupCollectDataTaskIds.add(task.getId());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skippedRetainedTaskIds.add(task.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanupTaskIds.isEmpty()) {
|
if (cleanupTaskIds.isEmpty()) {
|
||||||
log.info("模块历史清理跳过: moduleTypes={}, activeTaskIds={}, reason=no-terminal-tasks",
|
log.info("[module-cleanup] skipped: moduleTypes={}, retentionDays={}, cutoff={}, activeTaskIds={}, retainedTaskIds={}, reason=no-expired-terminal-tasks",
|
||||||
moduleTypes, skippedActiveTaskIds);
|
moduleTypes, moduleCleanupProperties.getRetentionDays(), cutoff, skippedActiveTaskIds, skippedRetainedTaskIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int deletedFileJobs = taskFileJobMapper.delete(new LambdaQueryWrapper<TaskFileJobEntity>()
|
||||||
|
.in(TaskFileJobEntity::getModuleType, moduleTypes)
|
||||||
|
.in(TaskFileJobEntity::getTaskId, cleanupTaskIds));
|
||||||
|
|
||||||
|
int deletedResultItems = taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
|
||||||
|
.in(TaskResultItemEntity::getModuleType, moduleTypes)
|
||||||
|
.in(TaskResultItemEntity::getTaskId, cleanupTaskIds));
|
||||||
|
|
||||||
|
int deletedProgressSnapshots = taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
|
||||||
|
.in(TaskProgressSnapshotEntity::getModuleType, moduleTypes)
|
||||||
|
.in(TaskProgressSnapshotEntity::getTaskId, cleanupTaskIds));
|
||||||
|
|
||||||
|
int deletedResultPayloads = taskResultPayloadMapper.delete(new LambdaQueryWrapper<TaskResultPayloadEntity>()
|
||||||
|
.in(TaskResultPayloadEntity::getModuleType, moduleTypes)
|
||||||
|
.in(TaskResultPayloadEntity::getTaskId, cleanupTaskIds));
|
||||||
|
|
||||||
|
int deletedScopeStates = taskScopeStateMapper.delete(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||||
|
.in(TaskScopeStateEntity::getModuleType, moduleTypes)
|
||||||
|
.in(TaskScopeStateEntity::getTaskId, cleanupTaskIds));
|
||||||
|
|
||||||
|
int deletedChunks = taskChunkMapper.delete(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.in(TaskChunkEntity::getModuleType, moduleTypes)
|
||||||
|
.in(TaskChunkEntity::getTaskId, cleanupTaskIds));
|
||||||
|
|
||||||
|
int deletedCollectDataItems = 0;
|
||||||
|
if (!cleanupCollectDataTaskIds.isEmpty()) {
|
||||||
|
deletedCollectDataItems = collectDataItemMapper.delete(new LambdaQueryWrapper<CollectDataItemEntity>()
|
||||||
|
.in(CollectDataItemEntity::getTaskId, cleanupCollectDataTaskIds));
|
||||||
|
}
|
||||||
|
|
||||||
int deletedResults = fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
|
int deletedResults = fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.in(FileResultEntity::getModuleType, moduleTypes)
|
.in(FileResultEntity::getModuleType, moduleTypes)
|
||||||
.in(FileResultEntity::getTaskId, cleanupTaskIds));
|
.in(FileResultEntity::getTaskId, cleanupTaskIds));
|
||||||
@@ -87,11 +151,19 @@ public class ModuleHistoryCleanupService {
|
|||||||
int deletedTasks = fileTaskMapper.delete(new LambdaQueryWrapper<FileTaskEntity>()
|
int deletedTasks = fileTaskMapper.delete(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.in(FileTaskEntity::getId, cleanupTaskIds));
|
.in(FileTaskEntity::getId, cleanupTaskIds));
|
||||||
|
|
||||||
log.info("模块历史清理完成: moduleTypes={}, deletedResults={}, resetTasks={}, deletedTasks={}, skippedActiveTaskIds={}",
|
log.info("[module-cleanup] completed: moduleTypes={}, retentionDays={}, cutoff={}, deletedFileJobs={}, deletedResultItems={}, deletedProgressSnapshots={}, deletedResultPayloads={}, deletedScopeStates={}, deletedChunks={}, deletedCollectDataItems={}, deletedResults={}, resetTasks={}, deletedTasks={}, skippedActiveTaskIds={}, retainedTaskIds={}",
|
||||||
moduleTypes, deletedResults, resetTasks, deletedTasks, skippedActiveTaskIds);
|
moduleTypes, moduleCleanupProperties.getRetentionDays(), cutoff,
|
||||||
|
deletedFileJobs, deletedResultItems, deletedProgressSnapshots, deletedResultPayloads,
|
||||||
|
deletedScopeStates, deletedChunks, deletedCollectDataItems, deletedResults, resetTasks, deletedTasks,
|
||||||
|
skippedActiveTaskIds, skippedRetainedTaskIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isExpired(FileTaskEntity task, LocalDateTime cutoff) {
|
||||||
|
LocalDateTime completedAt = task.getFinishedAt() != null ? task.getFinishedAt() : task.getUpdatedAt();
|
||||||
|
return completedAt != null && !completedAt.isAfter(cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isTerminalStatus(String status) {
|
private boolean isTerminalStatus(String status) {
|
||||||
if (status == null || status.isBlank()) {
|
if (status == null || status.isBlank()) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ aiimage:
|
|||||||
module-cleanup:
|
module-cleanup:
|
||||||
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
||||||
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
|
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
|
||||||
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH,PATROL_DELETE}
|
retention-days: ${AIIMAGE_MODULE_CLEANUP_RETENTION_DAYS:7}
|
||||||
|
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH,PATROL_DELETE,QUERY_ASIN,APPEARANCE_PATENT,SIMILAR_ASIN,COLLECT_DATA}
|
||||||
permission-schema-init:
|
permission-schema-init:
|
||||||
enabled: ${AIIMAGE_PERMISSION_SCHEMA_INIT_ENABLED:false}
|
enabled: ${AIIMAGE_PERMISSION_SCHEMA_INIT_ENABLED:false}
|
||||||
task-pressure:
|
task-pressure:
|
||||||
|
|||||||
@@ -799,7 +799,9 @@ async function runMatch() {
|
|||||||
countryCodes: resolveCountryCodesForRequest(),
|
countryCodes: resolveCountryCodesForRequest(),
|
||||||
})
|
})
|
||||||
const batch = res.items || []
|
const batch = res.items || []
|
||||||
matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
|
// 移除:不再使用后端返回的 ASIN 数据
|
||||||
|
// matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
|
||||||
|
matchAsinRowsByCountry.value = {}
|
||||||
const nextByShop = new Map(
|
const nextByShop = new Map(
|
||||||
matchedItems.value.map((item) => [((item.shopName || '').trim()), item] as const),
|
matchedItems.value.map((item) => [((item.shopName || '').trim()), item] as const),
|
||||||
)
|
)
|
||||||
@@ -989,9 +991,6 @@ async function pushToPythonQueueLegacy() {
|
|||||||
function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
|
function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
|
||||||
const taskItem = taskVo.items?.[0]
|
const taskItem = taskVo.items?.[0]
|
||||||
const shopName = (row.shopName || '').trim()
|
const shopName = (row.shopName || '').trim()
|
||||||
const skipAsinsByCountry = row.skipAsins || taskVo.skipAsinsByCountry || {}
|
|
||||||
const skipAsinDetailsByCountry = taskVo.skipAsinDetailsByCountry || {}
|
|
||||||
const minimumPriceByCountryAndAsin = taskVo.minimumPriceByCountryAndAsin || {}
|
|
||||||
const skipAsinDeletePolicy = buildSkipAsinDeletePolicy()
|
const skipAsinDeletePolicy = buildSkipAsinDeletePolicy()
|
||||||
const resultId = taskItem?.resultId ?? null
|
const resultId = taskItem?.resultId ?? null
|
||||||
const loopRunId = taskItem?.loopRunId ?? null
|
const loopRunId = taskItem?.loopRunId ?? null
|
||||||
@@ -1001,6 +1000,7 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
|
|||||||
const error = taskItem?.error || null
|
const error = taskItem?.error || null
|
||||||
const outputFilename = taskItem?.outputFilename || null
|
const outputFilename = taskItem?.outputFilename || null
|
||||||
const downloadUrl = taskItem?.downloadUrl || null
|
const downloadUrl = taskItem?.downloadUrl || null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'price-track-run',
|
type: 'price-track-run',
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
@@ -1024,16 +1024,14 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
|
|||||||
round_index: roundIndex,
|
round_index: roundIndex,
|
||||||
country_codes: resolveCountryCodesForRequest(),
|
country_codes: resolveCountryCodesForRequest(),
|
||||||
mode: statusModeEnabled.value ? 'status' : 'asin',
|
mode: statusModeEnabled.value ? 'status' : 'asin',
|
||||||
skip_asins: skipAsinsByCountry,
|
// 分页拉取配置(Python 端统一处理)
|
||||||
skip_asins_by_country: skipAsinsByCountry,
|
use_paginated_skip_asins: true,
|
||||||
skip_asin_details_by_country: skipAsinDetailsByCountry,
|
skip_asin_page_size: 1000,
|
||||||
minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin,
|
// 移除:不再传递任何 ASIN 数据,Python 端统一分页拉取
|
||||||
skip_asin_delete_policy: skipAsinDeletePolicy,
|
skip_asin_delete_policy: skipAsinDeletePolicy,
|
||||||
delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
|
delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
|
||||||
deleteSkipAsinWhenPriceBelowMinimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
|
deleteSkipAsinWhenPriceBelowMinimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
|
||||||
asin_rows_by_country: Object.keys(matchAsinRowsByCountry.value).length
|
// 兼容字段
|
||||||
? matchAsinRowsByCountry.value
|
|
||||||
: (taskVo.asinRowsByCountry || {}),
|
|
||||||
taskId: taskVo.taskId,
|
taskId: taskVo.taskId,
|
||||||
resultId: resultId,
|
resultId: resultId,
|
||||||
shopName,
|
shopName,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
<p class="hint listing-filter-hint">与「推送到 Python 队列」一并下发,供 Python 区分处理场景。</p>
|
<p class="hint listing-filter-hint">与「推送到 Python 队列」一并下发,供 Python 区分处理场景。</p>
|
||||||
<div class="listing-filter-row">
|
<div class="listing-filter-row">
|
||||||
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
|
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
|
||||||
<el-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
|
<el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
|
||||||
:value="opt.value" />
|
:value="opt.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,6 +235,12 @@ const autoQueueEnabled = ref(false)
|
|||||||
const queuePushResult = ref('')
|
const queuePushResult = ref('')
|
||||||
const queuePayloadText = ref('')
|
const queuePayloadText = ref('')
|
||||||
|
|
||||||
|
const PRODUCT_RISK_LISTING_FILTER_OPTIONS = [
|
||||||
|
...LISTING_FILTER_OPTIONS,
|
||||||
|
{ value: 'AccountStatus', label: '账户状态处理' },
|
||||||
|
] as const
|
||||||
|
type ProductRiskListingFilterValue = ListingFilterValue | 'AccountStatus'
|
||||||
|
|
||||||
/** 与后端 ProductRiskCountryCode / 默认顺序 DE→UK→FR→IT→ES 一致 */
|
/** 与后端 ProductRiskCountryCode / 默认顺序 DE→UK→FR→IT→ES 一致 */
|
||||||
const COUNTRY_OPTIONS = [
|
const COUNTRY_OPTIONS = [
|
||||||
{ code: 'DE', label: '德国' },
|
{ code: 'DE', label: '德国' },
|
||||||
@@ -245,7 +251,7 @@ const COUNTRY_OPTIONS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
||||||
const productRiskListingFilter = ref<ListingFilterValue>('SearchSuppressed')
|
const productRiskListingFilter = ref<ProductRiskListingFilterValue>('SearchSuppressed')
|
||||||
const dragCountryIndex = ref<number | null>(null)
|
const dragCountryIndex = ref<number | null>(null)
|
||||||
const countryPrefSaving = ref(false)
|
const countryPrefSaving = ref(false)
|
||||||
/** 用户已改过顺序/勾选后,忽略晚到的 GET,避免把界面打回全选 */
|
/** 用户已改过顺序/勾选后,忽略晚到的 GET,避免把界面打回全选 */
|
||||||
@@ -527,8 +533,8 @@ async function removeMatchedRow(row: ProductRiskShopQueueItem) {
|
|||||||
/** 历史列表中已无该 taskId 时,停止轮询并清本地快照 */
|
/** 历史列表中已无该 taskId 时,停止轮询并清本地快照 */
|
||||||
function syncPollingIdsWithHistory() {
|
function syncPollingIdsWithHistory() {
|
||||||
for (const taskId of [...pollingTaskIds.value]) {
|
for (const taskId of [...pollingTaskIds.value]) {
|
||||||
const any = historyItems.value.some((r) => r.taskId === taskId)
|
const rows = historyItems.value.filter((r) => r.taskId === taskId)
|
||||||
if (!any) {
|
if (!rows.length || rows.some((row) => isHistoryItemTerminal(row))) {
|
||||||
removePollingTask(taskId)
|
removePollingTask(taskId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -640,9 +646,22 @@ function taskStatusOf(taskId?: number) {
|
|||||||
return taskDetails.value[taskId] || ''
|
return taskDetails.value[taskId] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTerminalStatus(status?: string) {
|
||||||
|
return status === 'SUCCESS' || status === 'FAILED'
|
||||||
|
}
|
||||||
|
|
||||||
function isTaskTerminalById(taskId?: number) {
|
function isTaskTerminalById(taskId?: number) {
|
||||||
const s = taskStatusOf(taskId)
|
const s = taskStatusOf(taskId)
|
||||||
return s === 'SUCCESS' || s === 'FAILED'
|
return isTerminalStatus(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHistoryItemTerminal(item: ProductRiskHistoryItem) {
|
||||||
|
if (isTerminalStatus(item.taskStatus)) return true
|
||||||
|
if (item.success === true) return true
|
||||||
|
if (item.fileStatus === 'SUCCESS' && item.fileReady) return true
|
||||||
|
if (item.fileStatus === 'FAILED') return true
|
||||||
|
if (item.success === false && !!item.error) return true
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSectionItems = computed(() => {
|
const currentSectionItems = computed(() => {
|
||||||
@@ -650,7 +669,8 @@ const currentSectionItems = computed(() => {
|
|||||||
(row) =>
|
(row) =>
|
||||||
row.taskId &&
|
row.taskId &&
|
||||||
pollingTaskIds.value.includes(row.taskId) &&
|
pollingTaskIds.value.includes(row.taskId) &&
|
||||||
!isTaskTerminalById(row.taskId),
|
!isTaskTerminalById(row.taskId) &&
|
||||||
|
!isHistoryItemTerminal(row),
|
||||||
)
|
)
|
||||||
|
|
||||||
const existingTaskIds = new Set(out.map((row) => row.taskId).filter((id): id is number => typeof id === 'number'))
|
const existingTaskIds = new Set(out.map((row) => row.taskId).filter((id): id is number => typeof id === 'number'))
|
||||||
@@ -669,7 +689,8 @@ const historySectionItems = computed(() =>
|
|||||||
(row) =>
|
(row) =>
|
||||||
!row.taskId ||
|
!row.taskId ||
|
||||||
!pollingTaskIds.value.includes(row.taskId) ||
|
!pollingTaskIds.value.includes(row.taskId) ||
|
||||||
isTaskTerminalById(row.taskId),
|
isTaskTerminalById(row.taskId) ||
|
||||||
|
isHistoryItemTerminal(row),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -953,6 +974,9 @@ async function runMatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvedTaskStatus(item: ProductRiskHistoryItem) {
|
function resolvedTaskStatus(item: ProductRiskHistoryItem) {
|
||||||
|
if (isTerminalStatus(item.taskStatus)) return item.taskStatus || ''
|
||||||
|
if (item.success === true || (item.fileStatus === 'SUCCESS' && item.fileReady)) return 'SUCCESS'
|
||||||
|
if (item.fileStatus === 'FAILED' || (item.success === false && !!item.error)) return 'FAILED'
|
||||||
const tid = item.taskId
|
const tid = item.taskId
|
||||||
if (!tid) return item.taskStatus || ''
|
if (!tid) return item.taskStatus || ''
|
||||||
return taskStatusOf(tid) || item.taskStatus || ''
|
return taskStatusOf(tid) || item.taskStatus || ''
|
||||||
|
|||||||
@@ -25,6 +25,19 @@
|
|||||||
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
||||||
{{ statusText }}
|
{{ statusText }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="launching || launchProgress.stage" class="launch-progress" :class="{ error: launchProgress.stage === 'failed' }">
|
||||||
|
<div class="launch-progress__head">
|
||||||
|
<span>{{ launchProgress.message || '正在准备数字人程序...' }}</span>
|
||||||
|
<strong>{{ launchProgress.percent }}%</strong>
|
||||||
|
</div>
|
||||||
|
<div class="launch-progress__track">
|
||||||
|
<div class="launch-progress__bar" :style="{ width: `${launchProgress.percent}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="launchProgress.total > 0" class="launch-progress__meta">
|
||||||
|
{{ formatBytes(launchProgress.downloaded) }} / {{ formatBytes(launchProgress.total) }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PageShell v-else theme="dark">
|
<PageShell v-else theme="dark">
|
||||||
@@ -50,7 +63,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
import PageShell from '@/components/layout/PageShell.vue'
|
import PageShell from '@/components/layout/PageShell.vue'
|
||||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||||
@@ -63,7 +76,15 @@ const currentView = ref<ViewMode>('menu')
|
|||||||
const launching = ref(false)
|
const launching = ref(false)
|
||||||
const statusText = ref('')
|
const statusText = ref('')
|
||||||
const statusType = ref<'normal' | 'error'>('normal')
|
const statusType = ref<'normal' | 'error'>('normal')
|
||||||
|
const launchProgress = ref({
|
||||||
|
stage: '',
|
||||||
|
message: '',
|
||||||
|
percent: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
let statusTimer: number | undefined
|
let statusTimer: number | undefined
|
||||||
|
let launchProgressTimer: number | undefined
|
||||||
|
|
||||||
function showStatus(message: string, type: 'normal' | 'error' = 'normal') {
|
function showStatus(message: string, type: 'normal' | 'error' = 'normal') {
|
||||||
statusText.value = message
|
statusText.value = message
|
||||||
@@ -84,6 +105,59 @@ function openDeliveryWorkspace() {
|
|||||||
currentView.value = 'delivery'
|
currentView.value = 'delivery'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
const size = Number(value || 0)
|
||||||
|
if (!Number.isFinite(size) || size <= 0) return '未知大小'
|
||||||
|
if (size < 1024) return `${size} B`
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLaunchProgress() {
|
||||||
|
launchProgress.value = {
|
||||||
|
stage: '',
|
||||||
|
message: '',
|
||||||
|
percent: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLaunchProgress(event: Event) {
|
||||||
|
const detail = (event as CustomEvent<{
|
||||||
|
stage?: string
|
||||||
|
message?: string
|
||||||
|
percent?: number
|
||||||
|
downloaded?: number
|
||||||
|
total?: number
|
||||||
|
}>).detail
|
||||||
|
if (!detail) return
|
||||||
|
launchProgress.value = {
|
||||||
|
stage: detail.stage || '',
|
||||||
|
message: detail.message || '',
|
||||||
|
percent: Math.max(0, Math.min(100, Math.round(Number(detail.percent || 0)))),
|
||||||
|
downloaded: Number(detail.downloaded || 0),
|
||||||
|
total: Number(detail.total || 0),
|
||||||
|
}
|
||||||
|
if (launchProgressTimer) {
|
||||||
|
window.clearTimeout(launchProgressTimer)
|
||||||
|
}
|
||||||
|
if (detail.stage === 'success') {
|
||||||
|
launchProgressTimer = window.setTimeout(resetLaunchProgress, 1800)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('pywebview-yaoayanui-progress', handleLaunchProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (statusTimer) window.clearTimeout(statusTimer)
|
||||||
|
if (launchProgressTimer) window.clearTimeout(launchProgressTimer)
|
||||||
|
window.removeEventListener('pywebview-yaoayanui-progress', handleLaunchProgress)
|
||||||
|
})
|
||||||
|
|
||||||
async function launchDesktop() {
|
async function launchDesktop() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.launch_yaoayanui) {
|
if (!api?.launch_yaoayanui) {
|
||||||
@@ -92,15 +166,45 @@ async function launchDesktop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
launching.value = true
|
launching.value = true
|
||||||
|
launchProgress.value = {
|
||||||
|
stage: 'checking',
|
||||||
|
message: '正在检查本地数字人程序...',
|
||||||
|
percent: 0,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 0,
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await api.launch_yaoayanui()
|
const result = await api.launch_yaoayanui()
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
showStatus('已发送启动请求')
|
showStatus('已发送启动请求')
|
||||||
|
if (!launchProgress.value.stage || launchProgress.value.stage === 'checking') {
|
||||||
|
launchProgress.value = {
|
||||||
|
stage: 'success',
|
||||||
|
message: '数字人程序已启动',
|
||||||
|
percent: 100,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 0,
|
||||||
|
}
|
||||||
|
launchProgressTimer = window.setTimeout(resetLaunchProgress, 1800)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
launchProgress.value = {
|
||||||
|
...launchProgress.value,
|
||||||
|
stage: 'failed',
|
||||||
|
message: result?.error || '启动失败',
|
||||||
|
percent: 0,
|
||||||
|
}
|
||||||
showStatus(result?.error || '启动失败', 'error')
|
showStatus(result?.error || '启动失败', 'error')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showStatus(error instanceof Error ? error.message : String(error), 'error')
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
launchProgress.value = {
|
||||||
|
...launchProgress.value,
|
||||||
|
stage: 'failed',
|
||||||
|
message,
|
||||||
|
percent: 0,
|
||||||
|
}
|
||||||
|
showStatus(message, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
launching.value = false
|
launching.value = false
|
||||||
}
|
}
|
||||||
@@ -212,6 +316,74 @@ async function launchDesktop() {
|
|||||||
background: rgba(176, 42, 55, 0.92);
|
background: rgba(176, 42, 55, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launch-progress {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 96px;
|
||||||
|
width: min(520px, calc(100vw - 48px));
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 10px 28px rgba(20, 32, 55, 0.16);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress.error {
|
||||||
|
border-color: rgba(176, 42, 55, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: #2d3748;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress__head span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress__head strong {
|
||||||
|
color: #4456c9;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress.error .launch-progress__head strong {
|
||||||
|
color: #b02a37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress__track {
|
||||||
|
height: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e6ebf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress__bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #667eea, #52c2ff);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress.error .launch-progress__bar {
|
||||||
|
background: #b02a37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-progress__meta {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: #68758a;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.delivery-page {
|
.delivery-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0f0f0f;
|
background: #0f0f0f;
|
||||||
|
|||||||
@@ -23,7 +23,10 @@
|
|||||||
:file-name="currentWorkspace.primaryAsset.fileName"
|
:file-name="currentWorkspace.primaryAsset.fileName"
|
||||||
:preview-url="currentWorkspace.primaryAsset.previewUrl"
|
:preview-url="currentWorkspace.primaryAsset.previewUrl"
|
||||||
:preview-kind="currentWorkspace.primaryAsset.previewKind"
|
:preview-kind="currentWorkspace.primaryAsset.previewKind"
|
||||||
|
:source-url="currentWorkspace.primaryAsset.sourceUrl"
|
||||||
|
allow-url
|
||||||
@select="(file) => setAsset(activeTab, 'primaryAsset', file)"
|
@select="(file) => setAsset(activeTab, 'primaryAsset', file)"
|
||||||
|
@url-change="(url) => setAssetUrl(activeTab, 'primaryAsset', url, currentTabCopy.primaryAssetAccept)"
|
||||||
@clear="clearAsset(activeTab, 'primaryAsset')"
|
@clear="clearAsset(activeTab, 'primaryAsset')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -36,7 +39,10 @@
|
|||||||
:file-name="currentWorkspace.secondaryAsset.fileName"
|
:file-name="currentWorkspace.secondaryAsset.fileName"
|
||||||
:preview-url="currentWorkspace.secondaryAsset.previewUrl"
|
:preview-url="currentWorkspace.secondaryAsset.previewUrl"
|
||||||
:preview-kind="currentWorkspace.secondaryAsset.previewKind"
|
:preview-kind="currentWorkspace.secondaryAsset.previewKind"
|
||||||
|
:source-url="currentWorkspace.secondaryAsset.sourceUrl"
|
||||||
|
allow-url
|
||||||
@select="(file) => setAsset(activeTab, 'secondaryAsset', file)"
|
@select="(file) => setAsset(activeTab, 'secondaryAsset', file)"
|
||||||
|
@url-change="(url) => setAssetUrl(activeTab, 'secondaryAsset', url, currentTabCopy.secondaryAssetAccept)"
|
||||||
@clear="clearAsset(activeTab, 'secondaryAsset')"
|
@clear="clearAsset(activeTab, 'secondaryAsset')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -51,9 +57,36 @@
|
|||||||
|
|
||||||
<AiSectionCard
|
<AiSectionCard
|
||||||
step="02"
|
step="02"
|
||||||
|
title="标题标签关键词"
|
||||||
|
description="类目和卖点一起配置,方便后续拆投放版本。"
|
||||||
|
class="delivery-card delivery-card--step-02"
|
||||||
|
>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">细分类目</span>
|
||||||
|
<AiChoicePills
|
||||||
|
v-model="currentWorkspace.categories"
|
||||||
|
:options="categoryOptions"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">核心卖点</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.productFocus"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
resize="none"
|
||||||
|
placeholder="例如:上脚显瘦、脚感轻、百搭、直播间主推款。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="03"
|
||||||
title="音视频生成"
|
title="音视频生成"
|
||||||
description="先锁定投放规格,再决定整体风格、时长和画面清晰度。"
|
description="先锁定投放规格,再决定整体风格、时长和画面清晰度。"
|
||||||
class="delivery-card delivery-card--step-02"
|
class="delivery-card delivery-card--step-03"
|
||||||
>
|
>
|
||||||
<div class="delivery-form-grid delivery-form-grid--two">
|
<div class="delivery-form-grid delivery-form-grid--two">
|
||||||
<div class="delivery-field">
|
<div class="delivery-field">
|
||||||
@@ -88,10 +121,174 @@
|
|||||||
</AiSectionCard>
|
</AiSectionCard>
|
||||||
|
|
||||||
<AiSectionCard
|
<AiSectionCard
|
||||||
step="03"
|
step="04"
|
||||||
|
title="人脸和口播"
|
||||||
|
description="控制性别、人脸图、语气和语音来源。"
|
||||||
|
class="delivery-card delivery-card--step-04"
|
||||||
|
>
|
||||||
|
<div class="delivery-form-grid delivery-form-grid--two">
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">性别</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.gender" :options="genderOptions" />
|
||||||
|
</div>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">语气</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.voiceTone" :options="voiceToneOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">语音来源</span>
|
||||||
|
<AiChoicePills
|
||||||
|
v-model="currentWorkspace.speechSource"
|
||||||
|
:options="speechSourceOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AiAssetDropzone
|
||||||
|
title="模特图 / 人脸图"
|
||||||
|
description="上传清晰正脸图,适合用于数字人口播。"
|
||||||
|
placeholder="上传模特图"
|
||||||
|
helper="建议 1080px 以上,单人正面"
|
||||||
|
accept="image/*"
|
||||||
|
:file-name="currentWorkspace.avatarAsset.fileName"
|
||||||
|
:preview-url="currentWorkspace.avatarAsset.previewUrl"
|
||||||
|
:preview-kind="currentWorkspace.avatarAsset.previewKind"
|
||||||
|
:source-url="currentWorkspace.avatarAsset.sourceUrl"
|
||||||
|
allow-url
|
||||||
|
@select="(file) => setAsset(activeTab, 'avatarAsset', file)"
|
||||||
|
@url-change="(url) => setAssetUrl(activeTab, 'avatarAsset', url, 'image/*')"
|
||||||
|
@clear="clearAsset(activeTab, 'avatarAsset')"
|
||||||
|
/>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="05"
|
||||||
|
title="话术和音乐"
|
||||||
|
description="支持直接口播、原话术微调,或者转成纯音乐版本。"
|
||||||
|
class="delivery-card delivery-card--step-05"
|
||||||
|
>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">话术模式</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.scriptMode" :options="scriptModeOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-form-grid delivery-form-grid--two">
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">话术内容</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.scriptText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
resize="none"
|
||||||
|
placeholder="输入要口播的话术,或粘贴需要整体微调的原始话术。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">微调约束</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.scriptRules"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
resize="none"
|
||||||
|
placeholder="例如:保留原来的成交语气,不改卖点顺序,强调防滑、透气、现货。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AiAssetDropzone
|
||||||
|
title="纯音乐素材"
|
||||||
|
description="需要做纯音乐版本时可上传,也可以后续从原视频中抽取。"
|
||||||
|
placeholder="上传背景音乐"
|
||||||
|
helper="支持 MP3 / WAV"
|
||||||
|
accept="audio/*"
|
||||||
|
:file-name="currentWorkspace.musicAsset.fileName"
|
||||||
|
:preview-url="currentWorkspace.musicAsset.previewUrl"
|
||||||
|
:preview-kind="currentWorkspace.musicAsset.previewKind"
|
||||||
|
:source-url="currentWorkspace.musicAsset.sourceUrl"
|
||||||
|
allow-url
|
||||||
|
@select="(file) => setAsset(activeTab, 'musicAsset', file)"
|
||||||
|
@url-change="(url) => setAssetUrl(activeTab, 'musicAsset', url, 'audio/*')"
|
||||||
|
@clear="clearAsset(activeTab, 'musicAsset')"
|
||||||
|
/>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="06"
|
||||||
|
title="场景和镜头"
|
||||||
|
description="支持用提示词微调,也支持上传场景图直接替换原场景。"
|
||||||
|
class="delivery-card delivery-card--step-06"
|
||||||
|
>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">场景处理方式</span>
|
||||||
|
<AiChoicePills v-model="currentWorkspace.sceneMode" :options="sceneModeOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-form-grid delivery-form-grid--two">
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">场景提示词</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.scenePrompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
resize="none"
|
||||||
|
placeholder="例如:保留产品特写,换成室内直播间,镜头推进更快,背景偏暖光。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AiAssetDropzone
|
||||||
|
title="场景图替换"
|
||||||
|
description="有明确参考图时直接上传,优先保证场景结构和光线。"
|
||||||
|
placeholder="上传场景参考图"
|
||||||
|
helper="支持 JPG / PNG"
|
||||||
|
accept="image/*"
|
||||||
|
:file-name="currentWorkspace.sceneAsset.fileName"
|
||||||
|
:preview-url="currentWorkspace.sceneAsset.previewUrl"
|
||||||
|
:preview-kind="currentWorkspace.sceneAsset.previewKind"
|
||||||
|
:source-url="currentWorkspace.sceneAsset.sourceUrl"
|
||||||
|
allow-url
|
||||||
|
@select="(file) => setAsset(activeTab, 'sceneAsset', file)"
|
||||||
|
@url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')"
|
||||||
|
@clear="clearAsset(activeTab, 'sceneAsset')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">镜头节奏</span>
|
||||||
|
<el-slider v-model="currentWorkspace.cameraRhythm" :min="1" :max="10" />
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="07"
|
||||||
|
title="执行说明"
|
||||||
|
description="补充收尾逻辑,例如保留人物动作,只替换场景和话术重点。"
|
||||||
|
class="delivery-card delivery-card--step-07"
|
||||||
|
>
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">收口动作</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.cta"
|
||||||
|
placeholder="例如:结尾引导领券下单,强调今晚库存。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delivery-field">
|
||||||
|
<span class="delivery-field__label">执行说明</span>
|
||||||
|
<el-input
|
||||||
|
v-model="currentWorkspace.notes"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
resize="none"
|
||||||
|
placeholder="补充执行说明,例如:第二版转成纯音乐短视频。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AiSectionCard>
|
||||||
|
|
||||||
|
<AiSectionCard
|
||||||
|
step="08"
|
||||||
title="任务和预览"
|
title="任务和预览"
|
||||||
description="先保存草稿,再开始组装带货视频。"
|
description="所有配置完成后,开始组装带货视频。"
|
||||||
class="delivery-card delivery-card--preview delivery-card--step-03"
|
class="delivery-card delivery-card--preview delivery-card--step-08"
|
||||||
>
|
>
|
||||||
<div class="task-actions">
|
<div class="task-actions">
|
||||||
<el-button class="delivery-primary-btn" type="primary" @click="startAssembly">
|
<el-button class="delivery-primary-btn" type="primary" @click="startAssembly">
|
||||||
@@ -149,197 +346,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AiSectionCard>
|
</AiSectionCard>
|
||||||
|
|
||||||
<AiSectionCard
|
|
||||||
step="04"
|
|
||||||
title="场景和镜头"
|
|
||||||
description="支持用提示词微调,也支持上传场景图直接替换原场景。"
|
|
||||||
class="delivery-card delivery-card--step-04"
|
|
||||||
>
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">场景处理方式</span>
|
|
||||||
<AiChoicePills v-model="currentWorkspace.sceneMode" :options="sceneModeOptions" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="delivery-form-grid delivery-form-grid--two">
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">场景提示词</span>
|
|
||||||
<el-input
|
|
||||||
v-model="currentWorkspace.scenePrompt"
|
|
||||||
type="textarea"
|
|
||||||
:rows="4"
|
|
||||||
resize="none"
|
|
||||||
placeholder="例如:保留产品特写,换成室内直播间,镜头推进更快,背景偏暖光。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<AiAssetDropzone
|
|
||||||
title="场景图替换"
|
|
||||||
description="有明确参考图时直接上传,优先保证场景结构和光线。"
|
|
||||||
placeholder="上传场景参考图"
|
|
||||||
helper="支持 JPG / PNG"
|
|
||||||
accept="image/*"
|
|
||||||
:file-name="currentWorkspace.sceneAsset.fileName"
|
|
||||||
:preview-url="currentWorkspace.sceneAsset.previewUrl"
|
|
||||||
:preview-kind="currentWorkspace.sceneAsset.previewKind"
|
|
||||||
@select="(file) => setAsset(activeTab, 'sceneAsset', file)"
|
|
||||||
@clear="clearAsset(activeTab, 'sceneAsset')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">镜头节奏</span>
|
|
||||||
<el-slider v-model="currentWorkspace.cameraRhythm" :min="1" :max="10" />
|
|
||||||
</div>
|
|
||||||
</AiSectionCard>
|
|
||||||
|
|
||||||
<AiSectionCard
|
|
||||||
step="05"
|
|
||||||
title="人脸和口播"
|
|
||||||
description="控制性别、人脸图、语气和语音来源。"
|
|
||||||
class="delivery-card delivery-card--step-05"
|
|
||||||
>
|
|
||||||
<div class="delivery-form-grid delivery-form-grid--two">
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">性别</span>
|
|
||||||
<AiChoicePills v-model="currentWorkspace.gender" :options="genderOptions" />
|
|
||||||
</div>
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">语气</span>
|
|
||||||
<AiChoicePills v-model="currentWorkspace.voiceTone" :options="voiceToneOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">语音来源</span>
|
|
||||||
<AiChoicePills
|
|
||||||
v-model="currentWorkspace.speechSource"
|
|
||||||
:options="speechSourceOptions"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AiAssetDropzone
|
|
||||||
title="模特图 / 人脸图"
|
|
||||||
description="上传清晰正脸图,适合用于数字人口播。"
|
|
||||||
placeholder="上传模特图"
|
|
||||||
helper="建议 1080px 以上,单人正面"
|
|
||||||
accept="image/*"
|
|
||||||
:file-name="currentWorkspace.avatarAsset.fileName"
|
|
||||||
:preview-url="currentWorkspace.avatarAsset.previewUrl"
|
|
||||||
:preview-kind="currentWorkspace.avatarAsset.previewKind"
|
|
||||||
@select="(file) => setAsset(activeTab, 'avatarAsset', file)"
|
|
||||||
@clear="clearAsset(activeTab, 'avatarAsset')"
|
|
||||||
/>
|
|
||||||
</AiSectionCard>
|
|
||||||
|
|
||||||
<AiSectionCard
|
|
||||||
step="06"
|
|
||||||
title="话术和音乐"
|
|
||||||
description="支持直接口播、原话术微调,或者转成纯音乐版本。"
|
|
||||||
class="delivery-card delivery-card--step-06"
|
|
||||||
>
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">话术模式</span>
|
|
||||||
<AiChoicePills v-model="currentWorkspace.scriptMode" :options="scriptModeOptions" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="delivery-form-grid delivery-form-grid--two">
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">话术内容</span>
|
|
||||||
<el-input
|
|
||||||
v-model="currentWorkspace.scriptText"
|
|
||||||
type="textarea"
|
|
||||||
:rows="4"
|
|
||||||
resize="none"
|
|
||||||
placeholder="输入要口播的话术,或粘贴需要整体微调的原始话术。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">微调约束</span>
|
|
||||||
<el-input
|
|
||||||
v-model="currentWorkspace.scriptRules"
|
|
||||||
type="textarea"
|
|
||||||
:rows="4"
|
|
||||||
resize="none"
|
|
||||||
placeholder="例如:保留原来的成交语气,不改卖点顺序,强调防滑、透气、现货。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AiAssetDropzone
|
|
||||||
title="纯音乐素材"
|
|
||||||
description="需要做纯音乐版本时可上传,也可以后续从原视频中抽取。"
|
|
||||||
placeholder="上传背景音乐"
|
|
||||||
helper="支持 MP3 / WAV"
|
|
||||||
accept="audio/*"
|
|
||||||
:file-name="currentWorkspace.musicAsset.fileName"
|
|
||||||
:preview-url="currentWorkspace.musicAsset.previewUrl"
|
|
||||||
:preview-kind="currentWorkspace.musicAsset.previewKind"
|
|
||||||
@select="(file) => setAsset(activeTab, 'musicAsset', file)"
|
|
||||||
@clear="clearAsset(activeTab, 'musicAsset')"
|
|
||||||
/>
|
|
||||||
</AiSectionCard>
|
|
||||||
|
|
||||||
<AiSectionCard
|
|
||||||
step="07"
|
|
||||||
title="标题标签关键词"
|
|
||||||
description="类目、平台和卖点一起配置,方便后续拆投放版本。"
|
|
||||||
class="delivery-card delivery-card--step-07"
|
|
||||||
>
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">细分类目</span>
|
|
||||||
<AiChoicePills
|
|
||||||
v-model="currentWorkspace.categories"
|
|
||||||
:options="categoryOptions"
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">投放平台</span>
|
|
||||||
<AiChoicePills
|
|
||||||
v-model="currentWorkspace.platforms"
|
|
||||||
:options="platformOptions"
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">核心卖点</span>
|
|
||||||
<el-input
|
|
||||||
v-model="currentWorkspace.productFocus"
|
|
||||||
type="textarea"
|
|
||||||
:rows="2"
|
|
||||||
resize="none"
|
|
||||||
placeholder="例如:上脚显瘦、脚感轻、百搭、直播间主推款。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AiSectionCard>
|
|
||||||
|
|
||||||
<AiSectionCard
|
|
||||||
step="08"
|
|
||||||
title="执行说明"
|
|
||||||
description="补充收尾逻辑,例如保留人物动作,只替换场景和话术重点。"
|
|
||||||
class="delivery-card delivery-card--step-08"
|
|
||||||
>
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">收口动作</span>
|
|
||||||
<el-input
|
|
||||||
v-model="currentWorkspace.cta"
|
|
||||||
placeholder="例如:结尾引导领券下单,强调今晚库存。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="delivery-field">
|
|
||||||
<span class="delivery-field__label">执行说明</span>
|
|
||||||
<el-input
|
|
||||||
v-model="currentWorkspace.notes"
|
|
||||||
type="textarea"
|
|
||||||
:rows="3"
|
|
||||||
resize="none"
|
|
||||||
placeholder="补充执行说明,例如:第二版转成纯音乐短视频。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AiSectionCard>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -359,6 +365,7 @@ type AssetState = {
|
|||||||
fileName: string
|
fileName: string
|
||||||
previewUrl: string
|
previewUrl: string
|
||||||
previewKind: PreviewKind
|
previewKind: PreviewKind
|
||||||
|
sourceUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssetKey =
|
type AssetKey =
|
||||||
@@ -390,7 +397,6 @@ type WorkspaceState = {
|
|||||||
scriptText: string
|
scriptText: string
|
||||||
scriptRules: string
|
scriptRules: string
|
||||||
categories: string[]
|
categories: string[]
|
||||||
platforms: string[]
|
|
||||||
productFocus: string
|
productFocus: string
|
||||||
cta: string
|
cta: string
|
||||||
notes: string
|
notes: string
|
||||||
@@ -425,7 +431,6 @@ const languageOptions = ['国语', '英语', '粤语', '西班牙语']
|
|||||||
const speechSourceOptions = ['文本直出', '从原视频提取语气', '从音频样本提取']
|
const speechSourceOptions = ['文本直出', '从原视频提取语气', '从音频样本提取']
|
||||||
const scriptModeOptions = ['直接口播', '原话术微调', '纯音乐版本']
|
const scriptModeOptions = ['直接口播', '原话术微调', '纯音乐版本']
|
||||||
const categoryOptions = ['鞋子', '男装', '女装', '美妆', '家居', '零食', '数码']
|
const categoryOptions = ['鞋子', '男装', '女装', '美妆', '家居', '零食', '数码']
|
||||||
const platformOptions = ['抖音', '快手', '视频号', '小红书']
|
|
||||||
|
|
||||||
const tabCopies: Record<WorkspaceTab, TabCopy> = {
|
const tabCopies: Record<WorkspaceTab, TabCopy> = {
|
||||||
remake: {
|
remake: {
|
||||||
@@ -465,6 +470,7 @@ function createAssetState(): AssetState {
|
|||||||
fileName: '',
|
fileName: '',
|
||||||
previewUrl: '',
|
previewUrl: '',
|
||||||
previewKind: 'file',
|
previewKind: 'file',
|
||||||
|
sourceUrl: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +497,6 @@ function createWorkspaceState(): WorkspaceState {
|
|||||||
scriptText: '',
|
scriptText: '',
|
||||||
scriptRules: '',
|
scriptRules: '',
|
||||||
categories: ['鞋子'],
|
categories: ['鞋子'],
|
||||||
platforms: ['抖音'],
|
|
||||||
productFocus: '',
|
productFocus: '',
|
||||||
cta: '',
|
cta: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
@@ -521,6 +526,23 @@ function resolvePreviewKind(file: File): PreviewKind {
|
|||||||
return 'file'
|
return 'file'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePreviewKindFromUrl(url: string, accept = ''): PreviewKind {
|
||||||
|
const cleanUrl = url.split('?')[0]?.toLowerCase() || ''
|
||||||
|
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(cleanUrl)) {
|
||||||
|
return 'image'
|
||||||
|
}
|
||||||
|
if (/\.(mp4|mov|webm|m4v|ogg)$/.test(cleanUrl)) {
|
||||||
|
return 'video'
|
||||||
|
}
|
||||||
|
if (accept.includes('image/*')) {
|
||||||
|
return 'image'
|
||||||
|
}
|
||||||
|
if (accept.includes('video/*')) {
|
||||||
|
return 'video'
|
||||||
|
}
|
||||||
|
return 'file'
|
||||||
|
}
|
||||||
|
|
||||||
function releasePreviewUrl(url: string) {
|
function releasePreviewUrl(url: string) {
|
||||||
if (!url.startsWith('blob:')) {
|
if (!url.startsWith('blob:')) {
|
||||||
return
|
return
|
||||||
@@ -536,6 +558,7 @@ function setAsset(tab: WorkspaceTab, assetKey: AssetKey, file: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target.fileName = file.name
|
target.fileName = file.name
|
||||||
|
target.sourceUrl = ''
|
||||||
target.previewKind = resolvePreviewKind(file)
|
target.previewKind = resolvePreviewKind(file)
|
||||||
target.previewUrl = target.previewKind === 'file' ? '' : URL.createObjectURL(file)
|
target.previewUrl = target.previewKind === 'file' ? '' : URL.createObjectURL(file)
|
||||||
if (target.previewUrl) {
|
if (target.previewUrl) {
|
||||||
@@ -543,6 +566,19 @@ function setAsset(tab: WorkspaceTab, assetKey: AssetKey, file: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAssetUrl(tab: WorkspaceTab, assetKey: AssetKey, url: string, accept = '') {
|
||||||
|
const target = workspaces[tab][assetKey]
|
||||||
|
if (target.previewUrl) {
|
||||||
|
releasePreviewUrl(target.previewUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUrl = url.trim()
|
||||||
|
target.sourceUrl = normalizedUrl
|
||||||
|
target.fileName = normalizedUrl ? normalizedUrl : ''
|
||||||
|
target.previewKind = normalizedUrl ? resolvePreviewKindFromUrl(normalizedUrl, accept) : 'file'
|
||||||
|
target.previewUrl = target.previewKind === 'file' ? '' : normalizedUrl
|
||||||
|
}
|
||||||
|
|
||||||
function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
|
function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
|
||||||
const target = workspaces[tab][assetKey]
|
const target = workspaces[tab][assetKey]
|
||||||
if (target.previewUrl) {
|
if (target.previewUrl) {
|
||||||
@@ -551,6 +587,7 @@ function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
|
|||||||
target.fileName = ''
|
target.fileName = ''
|
||||||
target.previewUrl = ''
|
target.previewUrl = ''
|
||||||
target.previewKind = 'file'
|
target.previewKind = 'file'
|
||||||
|
target.sourceUrl = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDraft() {
|
function saveDraft() {
|
||||||
|
|||||||
@@ -2217,6 +2217,33 @@ export function getPriceTrackResultDownloadUrl(resultId: number) {
|
|||||||
return getJavaDownloadUrl(`/price-track/results/${resultId}/download`);
|
return getJavaDownloadUrl(`/price-track/results/${resultId}/download`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTaskSkipPriceAsinsPaginated(
|
||||||
|
taskId: number,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 1000,
|
||||||
|
) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
get<JavaApiResponse<SkipPriceAsinPageVo>>(
|
||||||
|
`${JAVA_API_PREFIX}/price-track/tasks/${taskId}/skip-asins/paginated`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkipPriceAsinPageVo {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
skipAsinsByCountry: Record<string, string[]>;
|
||||||
|
skipAsinDetailsByCountry: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||||
|
}
|
||||||
|
|
||||||
export function deletePendingPriceTrackShopResult(shopName: string) {
|
export function deletePendingPriceTrackShopResult(shopName: string) {
|
||||||
return unwrapJavaResponse(
|
return unwrapJavaResponse(
|
||||||
del<JavaApiResponse<PriceTrackPendingDeleteVo>>(
|
del<JavaApiResponse<PriceTrackPendingDeleteVo>>(
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface PywebviewApi {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
alreadyRunning?: boolean;
|
||||||
}>;
|
}>;
|
||||||
enqueue_json?: (
|
enqueue_json?: (
|
||||||
data: unknown,
|
data: unknown,
|
||||||
|
|||||||
@@ -50,6 +50,16 @@
|
|||||||
<div v-if="fileName" class="ai-asset-dropzone__footer">
|
<div v-if="fileName" class="ai-asset-dropzone__footer">
|
||||||
<span class="ai-asset-dropzone__filename">{{ fileName }}</span>
|
<span class="ai-asset-dropzone__filename">{{ fileName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-if="allowUrl"
|
||||||
|
:model-value="sourceUrl"
|
||||||
|
class="ai-asset-dropzone__url"
|
||||||
|
clearable
|
||||||
|
:placeholder="urlPlaceholder"
|
||||||
|
@change="(value) => $emit('url-change', value)"
|
||||||
|
@clear="$emit('url-change', '')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -66,6 +76,9 @@ withDefaults(
|
|||||||
fileName?: string
|
fileName?: string
|
||||||
previewUrl?: string
|
previewUrl?: string
|
||||||
previewKind?: 'image' | 'video' | 'file'
|
previewKind?: 'image' | 'video' | 'file'
|
||||||
|
allowUrl?: boolean
|
||||||
|
sourceUrl?: string
|
||||||
|
urlPlaceholder?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
description: '',
|
description: '',
|
||||||
@@ -75,12 +88,16 @@ withDefaults(
|
|||||||
fileName: '',
|
fileName: '',
|
||||||
previewUrl: '',
|
previewUrl: '',
|
||||||
previewKind: 'file',
|
previewKind: 'file',
|
||||||
|
allowUrl: false,
|
||||||
|
sourceUrl: '',
|
||||||
|
urlPlaceholder: '也可以粘贴视频在线链接',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'select', file: File): void
|
(event: 'select', file: File): void
|
||||||
(event: 'clear'): void
|
(event: 'clear'): void
|
||||||
|
(event: 'url-change', value: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
||||||
@@ -189,4 +206,13 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-asset-dropzone__url {
|
||||||
|
--el-input-bg-color: #171717;
|
||||||
|
--el-input-border-color: #383838;
|
||||||
|
--el-input-hover-border-color: #5a5a5a;
|
||||||
|
--el-input-focus-border-color: #8c63ff;
|
||||||
|
--el-input-text-color: #f2f2f2;
|
||||||
|
--el-input-placeholder-color: #777;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
[2m16:09:19[22m [31m[1m[vite][22m[39m [31m[2m(client)[22m[39m Pre-transform error: Failed to load url /src/main.ts (resolved id: /src/main.ts). Does the file exist?
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
|
|
||||||
> crawler-plugin-frontend-vue@0.0.1 dev
|
|
||||||
> vite --host --port 5173
|
|
||||||
|
|
||||||
|
|
||||||
[32m[1mVITE[22m v7.3.1[39m [2mready in [0m[1m861[22m[2m[0m ms[22m
|
|
||||||
|
|
||||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5173[22m/[39m
|
|
||||||
[32m➜[39m [1mNetwork[22m: [36mhttp://192.168.31.112:[1m5173[22m/[39m
|
|
||||||
[2m14:18:30[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32m✨ new dependencies optimized: [33melement-plus/es, element-plus/es/components/base/style/css, element-plus/es/components/select/style/css, element-plus/es/components/option/style/css, element-plus/es/components/slider/style/css, element-plus/es/components/input/style/css, element-plus/es/components/button/style/css, element-plus/es/components/tabs/style/css, element-plus/es/components/tab-pane/style/css, element-plus/es/components/upload/style/css[32m[39m
|
|
||||||
[2m14:18:30[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32m✨ optimized dependencies changed. reloading[39m
|
|
||||||
[2m14:47:00[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
|
||||||
[2m15:05:11[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
|
||||||
[2m15:16:30[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/shared/components/ai-workflow/AiSectionCard.vue?vue&type=style&index=0&scoped=0c43ba21&lang.css[22m
|
|
||||||
[2m15:16:55[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/shared/components/ai-workflow/AiChoicePills.vue?vue&type=style&index=0&scoped=74573572&lang.css[22m
|
|
||||||
[2m15:17:34[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/shared/components/ai-workflow/AiAssetDropzone.vue?vue&type=style&index=0&scoped=1625dec6&lang.css[22m
|
|
||||||
[2m15:22:09[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
[2m15:23:01[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
|
||||||
[2m15:23:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32m✨ new dependencies optimized: [33melement-plus/es/components/dialog/style/css[32m[39m
|
|
||||||
[2m15:23:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32m✨ optimized dependencies changed. reloading[39m
|
|
||||||
[2m15:31:28[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandTopBar.vue[22m
|
|
||||||
[2m15:31:55[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
|
||||||
[2m15:52:41[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
[2m16:02:40[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandTopBar.vue, /src/pages/brand/components/BrandTopBar.vue?vue&type=style&index=0&scoped=12cdd860&lang.css[22m
|
|
||||||
[2m16:03:03[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
|
||||||
[2m16:05:24[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
[2m16:11:14[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
[2m16:11:14[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/shared/components/ai-workflow/AiSectionCard.vue?vue&type=style&index=0&scoped=0c43ba21&lang.css[22m
|
|
||||||
[2m16:16:44[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandTopBar.vue, /src/pages/brand/components/BrandTopBar.vue?vue&type=style&index=0&scoped=12cdd860&lang.css[22m
|
|
||||||
[2m16:18:13[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue[22m
|
|
||||||
[2m16:19:34[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
[2m16:19:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css[22m
|
|
||||||
[2m16:33:19[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
[2m16:34:33[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
[2m16:34:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
[2m16:49:01[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css[22m
|
|
||||||
Reference in New Issue
Block a user