feat(web): 前端 SPA 化 + 工具页任务面板统一与历史批量删除

- SPA 化:22 个 MPA html 入口与 *-main.ts 合并为 index.html + vue-router(URL 无 .html 后缀),
  页面跳转全部 router-link,/new_web_source/xxx.html 旧路径归一为 /xxx
- 任务面板统一:共享 TaskCenterPanel/TaskItemCard/TaskStatCards/HistoryTaskLayer,
  16 个工具页右侧统一为统计卡 + 当前任务 + 历史任务弹层(任务ID/开始/结束/状态必展示)
- 历史记录支持单条删除 + 批量勾选删除(确认框/全选/失败提示)
- Java 7 模块(dedupe/convert/split/productrisk/shopmatch/pricetrack/deletebrand)
  history 接口补齐任务时间字段(VO+Service,复用 biz_file_task 列)
- 图片工作台/API 层(brand/permission/user)既有未提交改动一并提交
This commit is contained in:
2026-09-08 10:02:55 +08:00
parent abcfa5bef7
commit e248b6e43a
125 changed files with 7482 additions and 4304 deletions
File diff suppressed because one or more lines are too long
@@ -24,4 +24,19 @@ public class ConvertResultItemVo {
@Schema(description = "下载地址") @Schema(description = "下载地址")
private String downloadUrl; private String downloadUrl;
@Schema(description = "所属任务IDbiz_file_task.id")
private Long taskId;
@Schema(description = "任务状态:PENDING / RUNNING / SUCCESS / FAILED")
private String taskStatus;
@Schema(description = "任务创建时间(biz_file_task.created_at,格式化字符串)")
private String createdAt;
@Schema(description = "任务开始时间(biz_file_task.created_at")
private String startedAt;
@Schema(description = "任务结束时间(biz_file_task.finished_at,未结束为 null")
private String finishedAt;
} }
@@ -201,12 +201,14 @@ public class ConvertRunService {
} }
public List<ConvertResultItemVo> listHistory(Long userId) { public List<ConvertResultItemVo> listHistory(Long userId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>() List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId) .eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt) .orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 200")) .last("limit 200"));
.stream() // 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
return entities.stream()
.map(entity -> { .map(entity -> {
ConvertResultItemVo vo = new ConvertResultItemVo(); ConvertResultItemVo vo = new ConvertResultItemVo();
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
@@ -215,11 +217,46 @@ public class ConvertRunService {
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
FileTaskEntity task = entity.getTaskId() == null ? null : taskMap.get(entity.getTaskId());
vo.setTaskId(entity.getTaskId());
vo.setTaskStatus(task == null ? null : task.getStatus());
vo.setCreatedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
// 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底
vo.setStartedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
return vo; return vo;
}) })
.toList(); .toList();
} }
/** 批量加载转换结果关联的任务信息(列裁剪,只取状态与时间) */
private Map<Long, FileTaskEntity> loadHistoryTaskMap(List<FileResultEntity> entities) {
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (taskIds.isEmpty()) {
return taskMap;
}
for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId,
FileTaskEntity::getStatus,
FileTaskEntity::getCreatedAt,
FileTaskEntity::getFinishedAt)
.in(FileTaskEntity::getId, taskIds))) {
if (task != null) {
taskMap.put(task.getId(), task);
}
}
return taskMap;
}
private static String fmt(LocalDateTime t) {
return t == null ? null : t.toString();
}
public void deleteHistory(Long resultId, Long userId) { public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId); FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
@@ -27,4 +27,19 @@ public class DedupeResultItemVo {
@Schema(description = "结果文件大小(字节)") @Schema(description = "结果文件大小(字节)")
private Long resultFileSize; private Long resultFileSize;
@Schema(description = "所属任务IDbiz_file_task.id")
private Long taskId;
@Schema(description = "任务状态:PENDING / RUNNING / SUCCESS / FAILED")
private String taskStatus;
@Schema(description = "结果记录创建时间(格式化字符串)")
private String createdAt;
@Schema(description = "任务开始时间(biz_file_task.created_at")
private String startedAt;
@Schema(description = "任务结束时间(biz_file_task.finished_at,未结束为 null")
private String finishedAt;
} }
@@ -389,12 +389,14 @@ public class DedupeRunService {
} }
public List<DedupeResultItemVo> listHistory(Long userId) { public List<DedupeResultItemVo> listHistory(Long userId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>() List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, "DEDUPE") .eq(FileResultEntity::getModuleType, "DEDUPE")
.eq(FileResultEntity::getUserId, userId) .eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt) .orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 50")) .last("limit 50"));
.stream() // 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
return entities.stream()
.map(entity -> { .map(entity -> {
DedupeResultItemVo vo = new DedupeResultItemVo(); DedupeResultItemVo vo = new DedupeResultItemVo();
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
@@ -403,11 +405,46 @@ public class DedupeRunService {
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
FileTaskEntity task = entity.getTaskId() == null ? null : taskMap.get(entity.getTaskId());
vo.setTaskId(entity.getTaskId());
vo.setTaskStatus(task == null ? null : task.getStatus());
vo.setCreatedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
// 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底
vo.setStartedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
return vo; return vo;
}) })
.toList(); .toList();
} }
/** 批量加载去重结果关联的任务信息(复用 appearancepatent 的列裁剪写法) */
private Map<Long, FileTaskEntity> loadHistoryTaskMap(List<FileResultEntity> entities) {
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (taskIds.isEmpty()) {
return taskMap;
}
for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId,
FileTaskEntity::getStatus,
FileTaskEntity::getCreatedAt,
FileTaskEntity::getFinishedAt)
.in(FileTaskEntity::getId, taskIds))) {
if (task != null) {
taskMap.put(task.getId(), task);
}
}
return taskMap;
}
private static String fmt(LocalDateTime t) {
return t == null ? null : t.toString();
}
public void deleteHistory(Long resultId, Long userId) { public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId); FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
@@ -78,4 +78,14 @@ public class DeleteBrandResultItemVo {
@Schema(description = "由于列表来自 history 接口,增加所属任务的状态(RUNNING/SUCCESS/FAILED),协助前端准确定位执行中任务") @Schema(description = "由于列表来自 history 接口,增加所属任务的状态(RUNNING/SUCCESS/FAILED),协助前端准确定位执行中任务")
private String taskStatus; private String taskStatus;
/**
* 任务创建时间(biz_file_task.created_at,格式化字符串),列表接口返回。
*/
private String createdAt;
/**
* 任务结束时间(biz_file_task.finished_at,未结束为 null),列表接口返回。
*/
private String finishedAt;
} }
@@ -293,6 +293,7 @@ public class DeleteBrandRunService {
List<FileTaskEntity> recentTouchedTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() List<FileTaskEntity> recentTouchedTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, .select(FileTaskEntity::getId,
FileTaskEntity::getStatus, FileTaskEntity::getStatus,
FileTaskEntity::getCreatedAt,
FileTaskEntity::getUpdatedAt, FileTaskEntity::getUpdatedAt,
FileTaskEntity::getFinishedAt) FileTaskEntity::getFinishedAt)
.eq(FileTaskEntity::getModuleType, MODULE_TYPE) .eq(FileTaskEntity::getModuleType, MODULE_TYPE)
@@ -375,6 +376,9 @@ public class DeleteBrandRunService {
// 真实补充 // 真实补充
if (entity.getTaskId() != null) { if (entity.getTaskId() != null) {
item.setTaskStatus(statusByTaskId.get(entity.getTaskId())); item.setTaskStatus(statusByTaskId.get(entity.getTaskId()));
FileTaskEntity taskEntity = taskById.get(entity.getTaskId());
item.setCreatedAt(fmt(taskEntity == null ? null : taskEntity.getCreatedAt()));
item.setFinishedAt(fmt(taskEntity == null ? null : taskEntity.getFinishedAt()));
List<DeleteBrandResultItemVo> taskItems = null; List<DeleteBrandResultItemVo> taskItems = null;
if (taskItems != null && entity.getSourceFilename() != null && !entity.getSourceFilename().isBlank()) { if (taskItems != null && entity.getSourceFilename() != null && !entity.getSourceFilename().isBlank()) {
for (DeleteBrandResultItemVo candidate : taskItems) { for (DeleteBrandResultItemVo candidate : taskItems) {
@@ -1234,6 +1238,7 @@ public class DeleteBrandRunService {
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, .select(FileTaskEntity::getId,
FileTaskEntity::getStatus, FileTaskEntity::getStatus,
FileTaskEntity::getCreatedAt,
FileTaskEntity::getUpdatedAt, FileTaskEntity::getUpdatedAt,
FileTaskEntity::getFinishedAt) FileTaskEntity::getFinishedAt)
.in(FileTaskEntity::getId, taskIds.subList(start, end)))); .in(FileTaskEntity::getId, taskIds.subList(start, end))));
@@ -1282,6 +1287,10 @@ public class DeleteBrandRunService {
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L); return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
} }
private static String fmt(LocalDateTime t) {
return t == null ? null : t.toString();
}
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.NEVER) @Transactional(propagation = org.springframework.transaction.annotation.Propagation.NEVER)
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) { public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
@@ -62,4 +62,14 @@ public class PriceTrackResultItemVo {
@Schema(description = "所属轮次,1-based", example = "2") @Schema(description = "所属轮次,1-based", example = "2")
private Integer roundIndex; private Integer roundIndex;
/**
* 任务创建时间(biz_file_task.created_at,格式化字符串),列表接口返回。
*/
private String createdAt;
/**
* 任务结束时间(biz_file_task.finished_at,未结束为 null),列表接口返回。
*/
private String finishedAt;
} }
@@ -142,7 +142,7 @@ public class PriceTrackTaskService {
return tasks; return tasks;
} }
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) { private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>(); List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) { if (taskIds == null || taskIds.isEmpty()) {
return tasks; return tasks;
@@ -151,7 +151,8 @@ public class PriceTrackTaskService {
for (int start = 0; start < taskIds.size(); start += batchSize) { for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size()); int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, FileTaskEntity::getStatus) .select(FileTaskEntity::getId, FileTaskEntity::getStatus,
FileTaskEntity::getCreatedAt, FileTaskEntity::getFinishedAt)
.in(FileTaskEntity::getId, taskIds.subList(start, end)))); .in(FileTaskEntity::getId, taskIds.subList(start, end))));
} }
return tasks; return tasks;
@@ -208,16 +209,16 @@ public class PriceTrackTaskService {
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt)); userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
return vo; return vo;
} }
Map<Long, String> statusByTaskId = new LinkedHashMap<>(); Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
List<Long> taskIds = entities.stream() List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId) .map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0) .filter(id -> id != null && id > 0)
.distinct() .distinct()
.toList(); .toList();
if (!taskIds.isEmpty()) { if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds); List<FileTaskEntity> tasks = selectTaskHistoryFieldsByIdsInBatches(taskIds);
for (FileTaskEntity t : tasks) { for (FileTaskEntity t : tasks) {
if (t != null) statusByTaskId.put(t.getId(), t.getStatus()); if (t != null) taskById.put(t.getId(), t);
} }
} }
long tasksLoadedAt = System.nanoTime(); long tasksLoadedAt = System.nanoTime();
@@ -229,14 +230,14 @@ public class PriceTrackTaskService {
long jobsLoadedAt = System.nanoTime(); long jobsLoadedAt = System.nanoTime();
List<PriceTrackResultItemVo> items = new ArrayList<>(); List<PriceTrackResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) { for (FileResultEntity entity : entities) {
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false)); items.add(toHistoryItemVo(entity, taskById.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
} }
vo.setItems(items); vo.setItems(items);
long finishedAt = System.nanoTime(); long finishedAt = System.nanoTime();
log.info("[price-track] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}", log.info("[price-track] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
userId, userId,
entities.size(), entities.size(),
statusByTaskId.size(), taskById.size(),
jobMap.size(), jobMap.size(),
elapsedMs(startedAt, finishedAt), elapsedMs(startedAt, finishedAt),
elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt),
@@ -1720,16 +1721,32 @@ public class PriceTrackTaskService {
} }
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) { private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
return toHistoryItemVo(entity, taskStatus, null, true); // 快照路径:仅有任务状态字符串,无任务实体可提供时间字段
return buildHistoryItemVo(entity, taskStatus, null, null, true);
}
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task) {
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, null, true);
} }
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) { private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
return buildHistoryItemVo(entity, taskStatus, null, job, mergeRequestFields);
}
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task, TaskFileJobEntity job, boolean mergeRequestFields) {
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, job, mergeRequestFields);
}
private PriceTrackResultItemVo buildHistoryItemVo(FileResultEntity entity, String taskStatus, FileTaskEntity task,
TaskFileJobEntity job, boolean mergeRequestFields) {
PriceTrackResultItemVo vo = new PriceTrackResultItemVo(); PriceTrackResultItemVo vo = new PriceTrackResultItemVo();
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
vo.setTaskId(entity.getTaskId()); vo.setTaskId(entity.getTaskId());
vo.setShopName(entity.getSourceFilename()); vo.setShopName(entity.getSourceFilename());
vo.setShopId(entity.getSourceFileUrl()); vo.setShopId(entity.getSourceFileUrl());
vo.setTaskStatus(taskStatus); vo.setTaskStatus(taskStatus);
vo.setCreatedAt(fmt(task == null ? null : task.getCreatedAt()));
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1; boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1;
vo.setSuccess(ok); vo.setSuccess(ok);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
@@ -1767,7 +1784,7 @@ public class PriceTrackTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId)); .orderByAsc(FileResultEntity::getId));
for (FileResultEntity fr : rows) { for (FileResultEntity fr : rows) {
detail.getItems().add(toHistoryItemVo(fr, task.getStatus())); detail.getItems().add(toHistoryItemVo(fr, task));
} }
return detail; return detail;
} }
@@ -79,4 +79,12 @@ public class ProductRiskResultItemVo {
@JsonAlias("skip_asin_details_by_country") @JsonAlias("skip_asin_details_by_country")
@JsonProperty("skipAsinDetailsByCountry") @JsonProperty("skipAsinDetailsByCountry")
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>(); private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
@JsonProperty("createdAt")
@Schema(description = "任务创建时间(biz_file_task.created_at,格式化字符串)")
private String createdAt;
@JsonProperty("finishedAt")
@Schema(description = "任务结束时间(biz_file_task.finished_at,未结束为 null")
private String finishedAt;
} }
@@ -133,7 +133,7 @@ public class ProductRiskTaskService {
return tasks; return tasks;
} }
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) { private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>(); List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) { if (taskIds == null || taskIds.isEmpty()) {
return tasks; return tasks;
@@ -142,7 +142,8 @@ public class ProductRiskTaskService {
for (int start = 0; start < taskIds.size(); start += batchSize) { for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size()); int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, FileTaskEntity::getStatus) .select(FileTaskEntity::getId, FileTaskEntity::getStatus,
FileTaskEntity::getCreatedAt, FileTaskEntity::getFinishedAt)
.in(FileTaskEntity::getId, taskIds.subList(start, end)))); .in(FileTaskEntity::getId, taskIds.subList(start, end))));
} }
return tasks; return tasks;
@@ -202,17 +203,17 @@ public class ProductRiskTaskService {
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt)); userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
return vo; return vo;
} }
Map<Long, String> statusByTaskId = new LinkedHashMap<>(); Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
List<Long> taskIds = entities.stream() List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId) .map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0) .filter(id -> id != null && id > 0)
.distinct() .distinct()
.toList(); .toList();
if (!taskIds.isEmpty()) { if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds); List<FileTaskEntity> tasks = selectTaskHistoryFieldsByIdsInBatches(taskIds);
for (FileTaskEntity t : tasks) { for (FileTaskEntity t : tasks) {
if (t != null) { if (t != null) {
statusByTaskId.put(t.getId(), t.getStatus()); taskById.put(t.getId(), t);
} }
} }
} }
@@ -225,14 +226,14 @@ public class ProductRiskTaskService {
long jobsLoadedAt = System.nanoTime(); long jobsLoadedAt = System.nanoTime();
List<ProductRiskResultItemVo> items = new ArrayList<>(); List<ProductRiskResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) { for (FileResultEntity entity : entities) {
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false)); items.add(toHistoryItemVo(entity, taskById.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
} }
vo.setItems(items); vo.setItems(items);
long finishedAt = System.nanoTime(); long finishedAt = System.nanoTime();
log.info("[product-risk] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}", log.info("[product-risk] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
userId, userId,
entities.size(), entities.size(),
statusByTaskId.size(), taskById.size(),
jobMap.size(), jobMap.size(),
elapsedMs(startedAt, finishedAt), elapsedMs(startedAt, finishedAt),
elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt),
@@ -999,7 +1000,7 @@ public class ProductRiskTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId)); .orderByAsc(FileResultEntity::getId));
for (FileResultEntity fr : rows) { for (FileResultEntity fr : rows) {
detail.getItems().add(toHistoryItemVo(fr, task.getStatus())); detail.getItems().add(toHistoryItemVo(fr, task));
} }
return detail; return detail;
} }
@@ -1048,16 +1049,32 @@ public class ProductRiskTaskService {
} }
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) { private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
return toHistoryItemVo(entity, taskStatus, null, true); // 快照路径:仅有任务状态字符串,无任务实体可提供时间字段
return buildHistoryItemVo(entity, taskStatus, null, null, true);
}
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task) {
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, null, true);
} }
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) { private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
return buildHistoryItemVo(entity, taskStatus, null, job, mergeRequestFields);
}
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task, TaskFileJobEntity job, boolean mergeRequestFields) {
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, job, mergeRequestFields);
}
private ProductRiskResultItemVo buildHistoryItemVo(FileResultEntity entity, String taskStatus, FileTaskEntity task,
TaskFileJobEntity job, boolean mergeRequestFields) {
ProductRiskResultItemVo vo = new ProductRiskResultItemVo(); ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
vo.setTaskId(entity.getTaskId()); vo.setTaskId(entity.getTaskId());
vo.setShopName(entity.getSourceFilename()); vo.setShopName(entity.getSourceFilename());
vo.setShopId(entity.getSourceFileUrl()); vo.setShopId(entity.getSourceFileUrl());
vo.setTaskStatus(taskStatus); vo.setTaskStatus(taskStatus);
vo.setCreatedAt(fmt(task == null ? null : task.getCreatedAt()));
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1; boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1;
vo.setSuccess(ok); vo.setSuccess(ok);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
@@ -168,7 +168,7 @@ public class ShopMatchTaskService {
return tasks; return tasks;
} }
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) { private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>(); List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) { if (taskIds == null || taskIds.isEmpty()) {
return tasks; return tasks;
@@ -177,7 +177,8 @@ public class ShopMatchTaskService {
for (int start = 0; start < taskIds.size(); start += batchSize) { for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size()); int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>() tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId, FileTaskEntity::getStatus) .select(FileTaskEntity::getId, FileTaskEntity::getStatus,
FileTaskEntity::getCreatedAt, FileTaskEntity::getFinishedAt)
.in(FileTaskEntity::getId, taskIds.subList(start, end)))); .in(FileTaskEntity::getId, taskIds.subList(start, end))));
} }
return tasks; return tasks;
@@ -237,16 +238,16 @@ public class ShopMatchTaskService {
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt)); userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
return vo; return vo;
} }
Map<Long, String> statusByTaskId = new LinkedHashMap<>(); Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
List<Long> taskIds = entities.stream() List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId) .map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0) .filter(id -> id != null && id > 0)
.distinct() .distinct()
.toList(); .toList();
if (!taskIds.isEmpty()) { if (!taskIds.isEmpty()) {
for (FileTaskEntity task : selectTaskStatusesByIdsInBatches(taskIds)) { for (FileTaskEntity task : selectTaskHistoryFieldsByIdsInBatches(taskIds)) {
if (task != null) { if (task != null) {
statusByTaskId.put(task.getId(), task.getStatus()); taskById.put(task.getId(), task);
} }
} }
} }
@@ -259,14 +260,14 @@ public class ShopMatchTaskService {
long jobsLoadedAt = System.nanoTime(); long jobsLoadedAt = System.nanoTime();
List<ProductRiskResultItemVo> items = new ArrayList<>(); List<ProductRiskResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) { for (FileResultEntity entity : entities) {
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false)); items.add(toHistoryItemVo(entity, taskById.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
} }
vo.setItems(items); vo.setItems(items);
long finishedAt = System.nanoTime(); long finishedAt = System.nanoTime();
log.info("[shop-match] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}", log.info("[shop-match] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
userId, userId,
entities.size(), entities.size(),
statusByTaskId.size(), taskById.size(),
jobMap.size(), jobMap.size(),
elapsedMs(startedAt, finishedAt), elapsedMs(startedAt, finishedAt),
elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt),
@@ -963,7 +964,7 @@ public class ShopMatchTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId)); .orderByAsc(FileResultEntity::getId));
for (FileResultEntity row : rows) { for (FileResultEntity row : rows) {
detail.getItems().add(toHistoryItemVo(row, task.getStatus())); detail.getItems().add(toHistoryItemVo(row, task));
} }
return detail; return detail;
} }
@@ -1031,16 +1032,32 @@ public class ShopMatchTaskService {
} }
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) { private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
return toHistoryItemVo(entity, taskStatus, null, true); // 快照路径:仅有任务状态字符串,无任务实体可提供时间字段
return buildHistoryItemVo(entity, taskStatus, null, null, true);
}
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task) {
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, null, true);
} }
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) { private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
return buildHistoryItemVo(entity, taskStatus, null, job, mergeRequestFields);
}
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, FileTaskEntity task, TaskFileJobEntity job, boolean mergeRequestFields) {
return buildHistoryItemVo(entity, task == null ? null : task.getStatus(), task, job, mergeRequestFields);
}
private ProductRiskResultItemVo buildHistoryItemVo(FileResultEntity entity, String taskStatus, FileTaskEntity task,
TaskFileJobEntity job, boolean mergeRequestFields) {
ProductRiskResultItemVo vo = new ProductRiskResultItemVo(); ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
vo.setTaskId(entity.getTaskId()); vo.setTaskId(entity.getTaskId());
vo.setShopName(entity.getSourceFilename()); vo.setShopName(entity.getSourceFilename());
vo.setShopId(entity.getSourceFileUrl()); vo.setShopId(entity.getSourceFileUrl());
vo.setTaskStatus(taskStatus); vo.setTaskStatus(taskStatus);
vo.setCreatedAt(fmt(task == null ? null : task.getCreatedAt()));
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
boolean success = entity.getSuccess() != null && entity.getSuccess() == 1; boolean success = entity.getSuccess() != null && entity.getSuccess() == 1;
vo.setSuccess(success); vo.setSuccess(success);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
@@ -35,4 +35,19 @@ public class SplitResultItemVo {
@Schema(description = "压缩包内文件列表") @Schema(description = "压缩包内文件列表")
private List<SplitArchiveEntryVo> entries; private List<SplitArchiveEntryVo> entries;
@Schema(description = "所属任务IDbiz_file_task.id")
private Long taskId;
@Schema(description = "任务状态:PENDING / RUNNING / SUCCESS / FAILED")
private String taskStatus;
@Schema(description = "任务创建时间(biz_file_task.created_at,格式化字符串)")
private String createdAt;
@Schema(description = "任务开始时间(biz_file_task.created_at")
private String startedAt;
@Schema(description = "任务结束时间(biz_file_task.finished_at,未结束为 null")
private String finishedAt;
} }
@@ -29,6 +29,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -161,12 +162,14 @@ public class SplitRunService {
} }
public List<SplitResultItemVo> listHistory(Long userId) { public List<SplitResultItemVo> listHistory(Long userId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>() List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId) .eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt) .orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 100")) .last("limit 100"));
.stream() // 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
return entities.stream()
.map(entity -> { .map(entity -> {
SplitResultItemVo vo = new SplitResultItemVo(); SplitResultItemVo vo = new SplitResultItemVo();
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
@@ -178,11 +181,46 @@ public class SplitRunService {
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
vo.setEntryCount(0); vo.setEntryCount(0);
vo.setEntries(new ArrayList<>()); vo.setEntries(new ArrayList<>());
FileTaskEntity task = entity.getTaskId() == null ? null : taskMap.get(entity.getTaskId());
vo.setTaskId(entity.getTaskId());
vo.setTaskStatus(task == null ? null : task.getStatus());
vo.setCreatedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
// 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底
vo.setStartedAt(fmt(task == null ? entity.getCreatedAt() : task.getCreatedAt()));
vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt()));
return vo; return vo;
}) })
.toList(); .toList();
} }
/** 批量加载拆分结果关联的任务信息(列裁剪,只取状态与时间) */
private Map<Long, FileTaskEntity> loadHistoryTaskMap(List<FileResultEntity> entities) {
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (taskIds.isEmpty()) {
return taskMap;
}
for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.select(FileTaskEntity::getId,
FileTaskEntity::getStatus,
FileTaskEntity::getCreatedAt,
FileTaskEntity::getFinishedAt)
.in(FileTaskEntity::getId, taskIds))) {
if (task != null) {
taskMap.put(task.getId(), task);
}
}
return taskMap;
}
private static String fmt(LocalDateTime t) {
return t == null ? null : t.toString();
}
public void deleteHistory(Long resultId, Long userId) { public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId); FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>亚马逊 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/amazon-console-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>外观专利检测</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/appearance-patent-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>品牌检测 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/brand-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>采集数据</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/collect-data-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/convert-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/dedupe-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/delete-brand-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/home-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>图生视频</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/image-video-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>登录 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/login-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡店删除 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/patrol-delete-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>跟价 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/price-track-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>商品风险解决 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/product-risk-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>上架 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/publish-main.ts"></script>
</body>
</html>
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>查询ASIN - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/query-asin-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>店铺数据抓取 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/shop-data-crawl-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>匹配店铺 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/shop-match-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>货源查询</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/similar-asin-main.ts"></script>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/split-main.ts"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// 数富AI SPA 根组件:页面均由路由渲染(URL 无 .html 后缀)
</script>
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import AmazonConsolePage from '@/pages/amazon/AmazonConsolePage.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(AmazonConsolePage).use(ElementPlus).mount('#app')
}
})
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandAppearancePatentTab from '@/pages/brand/components/BrandAppearancePatentTab.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandAppearancePatentTab).use(ElementPlus).mount('#app')
}
})
-17
View File
@@ -1,17 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandBrandTab from '@/pages/brand/components/BrandBrandTab.vue'
dayjs.locale('zh-cn')
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandBrandTab).use(ElementPlus, { locale: zhCn }).mount('#app')
}
})
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandCollectDataTab from '@/pages/brand/components/BrandCollectDataTab.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandCollectDataTab).use(ElementPlus).mount('#app')
}
})
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandConvertTab from '@/pages/brand/components/BrandConvertTab.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandConvertTab).use(ElementPlus).mount('#app')
}
})
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandDedupeTab from '@/pages/brand/components/BrandDedupeTab.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandDedupeTab).use(ElementPlus).mount('#app')
}
})
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandDeleteBrandTab from '@/pages/brand/components/BrandDeleteBrandTab.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandDeleteBrandTab).use(ElementPlus).mount('#app')
}
})
-13
View File
@@ -1,13 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import DesktopHomePage from '@/pages/home/DesktopHomePage.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(DesktopHomePage).use(ElementPlus, { locale: zhCn }).mount('#app')
}
})
-9
View File
@@ -1,9 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ImageWorkbenchPage from '@/pages/image/ImageWorkbenchPage.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(ImageWorkbenchPage).mount('#app')
}
})
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import ImageVideoPage from '@/pages/image-video/ImageVideoPage.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(ImageVideoPage).use(ElementPlus).mount('#app')
}
})
-8
View File
@@ -1,8 +0,0 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import DesktopLoginPage from '@/pages/login/DesktopLoginPage.vue'
createApp(DesktopLoginPage).use(ElementPlus, { locale: zhCn }).mount('#app')
+28 -2
View File
@@ -1,7 +1,33 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import '@/styles/main.css' import '@/styles/main.css'
import ImageVideoPage from '@/pages/image-video/ImageVideoPage.vue'
createApp(ImageVideoPage).use(ElementPlus).mount('#app') import App from '@/App.vue'
import router from '@/router'
import { ensureAuth } from '@/shared/auth/ensure-auth'
/**
* 数富AI 前端统一入口(SPAURL 无 .html 后缀)
*
* 原 MPA 的 22 个 html 入口 + 22 个 *-main.ts 已合并:
* 页面路由见 src/router/index.ts;登录态由路由守卫统一引导。
*/
router.beforeEach(async (to) => {
if (to.name === 'login') return true
const ok = await ensureAuth()
return ok ? true : { name: 'login' }
})
router.beforeEach((to) => {
// 已登录访问登录页 → 回首页(与 MPA 时代 login.html 展示"请先退出"一致:直接回首页)
if (to.name === 'login' && window.localStorage.getItem('uid')) {
const logout = String(window.location.search).includes('logout=1')
if (!logout) return { name: 'home' }
}
return true
})
createApp(App).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
@@ -1,100 +1,107 @@
<template> <template>
<div class="page-shell module-page amazon-console"> <div
<AmazonTopBar /> class="page-shell module-page amazon-console"
:style="{ '--scroll-color': currentGroup?.color ?? '#2dd4bf' }"
>
<AmazonTopBar :active-cat="activeCat" @change="onCatChange" />
<main class="console-body"> <main class="console-body">
<!-- Hero 宣传区 --> <!-- Hero仅前端工具分类显示对齐 gemini-code 主页 -->
<section class="hero"> <section v-if="currentGroup && currentGroup.key === 'front'" class="hero">
<div class="hero-inner"> <div class="hero-left">
<div class="hero-left"> <span class="hero-pill">运营辅助 · 数据安全 · 高效跟价</span>
<span class="hero-pill">运营辅助 · 数据安全 · 高效跟价</span> <h1 class="hero-title">让亚马逊运营更清晰高效</h1>
<h1 class="hero-title">让亚马逊运营更清晰高效</h1> <p class="hero-sub">
<p class="hero-sub"> 从采集去重查品到上架跟价20 个工具按前端 · 运营 · 后勤三线组织每条工具一句话讲清用途标准流程一步不缺
从采集去重查品到上架跟价20 个工具按前端 · 运营 · 后勤三线组织每条工具一句话讲清用途标准流程一步不缺 </p>
</p> <div class="hero-stats">
<div class="hero-stats"> <div class="stat">
<div class="stat"> <strong style="color: #2dd4bf">20</strong>
<strong style="color: #2dd4bf">20</strong> <span>个工具</span>
<span>个工具</span> </div>
</div> <div class="stat">
<div class="stat"> <strong style="color: #60a5fa">3</strong>
<strong style="color: #60a5fa">3</strong> <span>大分类</span>
<span>大分类</span> </div>
</div> <div class="stat">
<div class="stat"> <strong style="color: #f59e0b">8</strong>
<strong style="color: #f59e0b">8</strong> <span>前端标准流程步骤</span>
<span>前端标准流程步骤</span>
</div>
</div> </div>
</div> </div>
</div>
<div class="hero-card"> <div class="hero-card">
<div>
<div class="hero-card-head"> <div class="hero-card-head">
<div class="hero-card-dots"> <div class="hero-card-dots">
<i class="dot dot--teal"></i> <i class="dot dot--teal"></i>
<i class="dot dot--blue"></i> <i class="dot dot--blue"></i>
<i class="dot dot--brand"></i> <i class="dot dot--brand"></i>
<span class="dots-line"></span>
</div> </div>
<span class="hero-card-caption">数富AI · 运营工作台</span> <span class="hero-card-caption">数富AI · 运营工作台</span>
</div> </div>
<h2 class="hero-card-title">让每一次运营动作<br />都有清晰的工作流</h2> <h2 class="hero-card-title">让每一次运营动作<br />都有清晰的工作流</h2>
<div class="hero-card-divider"></div> <div class="hero-card-divider"></div>
<p class="hero-card-sub">内置完整使用教程从软件配置到运营上架每一步流程清晰可查</p> <p class="hero-card-sub">内置完整使用教程从软件配置到运营上架</p>
<button type="button" class="hero-card-btn" @click="scrollToTools">查看工具</button> </div>
<div class="hero-card-btn-row">
<span class="hero-card-sub">每一步流程清晰可查</span>
<button class="hero-dl" type="button" @click="onDownloadClick">
<span>📥</span>
<span>立即下载教程</span>
</button>
</div> </div>
</div> </div>
</section> </section>
<!-- 三组工具标题 + 标准工作流 + 卡片墙 --> <!-- 当前分类标准工作流 + 卡片墙分类切换式 gemini-code -->
<section v-for="group in visibleGroups" :key="group.key" class="tool-group" :id="`group-${group.key}`"> <section v-if="currentGroup" class="tool-group" :id="`group-${currentGroup.key}`">
<header class="group-head"> <section v-if="visibleWorkflow(currentGroup)?.length" class="workflow-panel">
<i class="group-dot" :style="{ background: group.color }"></i>
<span class="group-name">{{ group.name }}</span>
<span class="group-desc">{{ group.desc }}</span>
<span class="group-count">{{ group.tools.length }} 个工具</span>
</header>
<!-- 标准工作流时间线 -->
<section v-if="visibleWorkflow(group)?.length" class="workflow-panel">
<div class="workflow-head"> <div class="workflow-head">
<span class="workflow-title">{{ group.workflowTitle }}</span> <span class="workflow-title">{{ currentGroup.workflowTitle }}</span>
<span class="workflow-badge" :style="{ color: group.color }">{{ group.workflowBadge }}</span> <span class="workflow-badge">{{ currentGroup.workflowBadge }}</span>
<span class="workflow-hint">{{ group.workflowHint }}</span> <span class="workflow-hint">{{ currentGroup.workflowHint }}</span>
</div> </div>
<ol class="workflow-steps"> <ol class="workflow-steps">
<li <template v-for="(step, index) in visibleWorkflow(currentGroup)" :key="`${currentGroup.key}-${index}`">
v-for="(step, index) in visibleWorkflow(group)" <li
:key="`${group.key}-${index}`" class="workflow-step"
class="workflow-step" :class="{ 'workflow-step--disabled': !step.toolId || !stepHref(step.toolId) }"
:class="{ 'workflow-step--disabled': !step.toolId || !stepHref(step.toolId) }" :style="{ '--gc': currentGroup.color }"
@click="onWorkflowStepClick(step)" @click="onWorkflowStepClick(step)"
> >
<span class="step-circle" :style="{ background: group.weak, color: group.color }">{{ index + 1 }}</span> <span class="step-circle" :style="circleStyle(currentGroup)">{{ index + 1 }}</span>
<span class="step-name">{{ step.name }}</span> <span class="step-name">{{ step.name }}</span>
<span class="step-desc">{{ step.desc }}</span> <span class="step-desc">{{ step.desc }}</span>
<span v-if="index < visibleWorkflow(group).length - 1" class="step-line" :style="{ background: group.weak }"></span> </li>
</li> <span v-if="index < visibleWorkflow(currentGroup).length - 1" class="step-line" :style="lineStyle()"></span>
</template>
</ol> </ol>
</section> </section>
<!-- 卡片墙 --> <header class="group-head">
<i class="group-dot" :style="{ background: currentGroup.color }"></i>
<span class="group-name">{{ currentGroup.name }}</span>
<span class="group-desc">{{ currentGroup.desc }}</span>
<span class="group-count">{{ currentGroup.tools.length }} 个工具</span>
</header>
<div class="card-grid"> <div class="card-grid">
<template v-for="tool in group.tools" :key="tool.id"> <template v-for="tool in currentGroup.tools" :key="tool.id">
<a v-if="resolveHref(tool.href)" :href="resolveHref(tool.href)!" class="tool-card"> <router-link v-if="resolveHref(tool.href)" :to="resolveHref(tool.href)!" class="tool-card" :style="cardStyle(currentGroup)">
<div class="tool-card-top"> <div class="tool-card-top">
<ToolIcon :name="tool.id" /> <ToolIcon :name="tool.id" />
<span v-if="tool.tag" class="tool-tag" :style="{ color: group.color }">{{ tool.tag }}</span> <span v-if="tool.tag" class="tool-tag" :style="tagStyle(currentGroup)">{{ tool.tag }}</span>
</div> </div>
<div class="tool-card-name">{{ tool.name }}</div> <div class="tool-card-name">{{ tool.name }}</div>
<div class="tool-card-desc">{{ tool.desc }}</div> <div class="tool-card-desc">{{ tool.desc }}</div>
<div class="tool-card-go">打开工具 </div> <div class="tool-card-go">打开工具 </div>
</a> </router-link>
<button v-else type="button" class="tool-card tool-card--disabled" @click="onPendingClick(tool)"> <button v-else type="button" class="tool-card tool-card--disabled" :style="cardStyle(currentGroup)" @click="onPendingClick(tool)">
<div class="tool-card-top"> <div class="tool-card-top">
<ToolIcon :name="tool.id" /> <ToolIcon :name="tool.id" />
<span v-if="tool.tag" class="tool-tag" :style="{ color: group.color }">{{ tool.tag }}</span> <span v-if="tool.tag" class="tool-tag" :style="tagStyle(currentGroup)">{{ tool.tag }}</span>
</div> </div>
<div class="tool-card-name">{{ tool.name }}</div> <div class="tool-card-name">{{ tool.name }}</div>
<div class="tool-card-desc">{{ tool.desc }}</div> <div class="tool-card-desc">{{ tool.desc }}</div>
@@ -112,25 +119,33 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue' import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue'
import AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.vue' import AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.vue'
import ToolIcon from '@/pages/amazon/components/ToolIcon.vue' import ToolIcon from '@/pages/amazon/components/ToolIcon.vue'
import { TUTORIAL_DOWNLOAD_URL } from '@/pages/amazon/download-btn'
import { filterGroupsByPermission, filterWorkflowByPermission, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog' import { filterGroupsByPermission, filterWorkflowByPermission, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog'
import type { ToolInfo, VisibleGroup, WorkflowStep } from '@/pages/amazon/tool-catalog' import type { ToolGroup, ToolInfo, VisibleGroup, WorkflowStep } from '@/pages/amazon/tool-catalog'
import { resolvePageHref } from '@/shared/page-prefix' import { resolvePageHref } from '@/shared/page-prefix'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission' import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
const router = useRouter()
const allowedKeys = ref<string[]>([]) const allowedKeys = ref<string[]>([])
const visibleGroups = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] }))) const visibleGroups = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
/** 权限接口异常时为 true:展示全部工具与时间线,避免页面空白 */ /** 权限接口异常时为 true:展示全部工具与时间线,避免页面空白 */
const permissionUnavailable = ref(false) const permissionUnavailable = ref(false)
const statusText = ref('') const statusText = ref('')
const statusType = ref<'normal' | 'error'>('normal') const statusType = ref<'normal' | 'error'>('normal')
const activeCat = ref<'front' | 'ops' | 'logi'>('front')
let statusTimer: number | undefined let statusTimer: number | undefined
/** 链接本地化:dev 预览去掉 /new_web_source 前缀;/brand、/web_source/* 等旧静态页仅桌面端存在 */ const currentGroup = computed(() => visibleGroups.value.find((g) => g.key === activeCat.value) ?? visibleGroups.value[0])
/** 链接归一:/new_web_source/xxx.html → /xxxSPA 路径,见 shared/page-prefix */
function resolveHref(rawHref?: string): string { function resolveHref(rawHref?: string): string {
return resolvePageHref(rawHref) return resolvePageHref(rawHref)
} }
@@ -145,6 +160,20 @@ function stepHref(toolId?: string) {
return resolveHref(TOOL_MAP.get(toolId)?.href ?? '') return resolveHref(TOOL_MAP.get(toolId)?.href ?? '')
} }
/* 卡片/tag:weak 弱底 + 分类色文字(同 gemini-code,无核心特判) */
function tagStyle(group: ToolGroup) {
return { background: group.weak, color: group.color }
}
function circleStyle(group: ToolGroup) {
return { background: group.weak, color: group.color }
}
function lineStyle() {
return { background: '#4C5A75' }
}
function cardStyle(group: ToolGroup) {
return { '--gc': group.color } as Record<string, string>
}
function onWorkflowStepClick(step: WorkflowStep) { function onWorkflowStepClick(step: WorkflowStep) {
if (!step.toolId) { if (!step.toolId) {
return return
@@ -152,7 +181,7 @@ function onWorkflowStepClick(step: WorkflowStep) {
const target = TOOL_MAP.get(step.toolId) const target = TOOL_MAP.get(step.toolId)
const href = resolveHref(target?.href ?? '') const href = resolveHref(target?.href ?? '')
if (href) { if (href) {
window.location.href = href router.push(href)
return return
} }
showSoon(target?.href ? `${step.name}仅桌面客户端可用` : `${step.name}暂未开通,敬请期待`) showSoon(target?.href ? `${step.name}仅桌面客户端可用` : `${step.name}暂未开通,敬请期待`)
@@ -167,31 +196,46 @@ function onPendingClick(tool: ToolInfo) {
} }
function showSoon(name: string) { function showSoon(name: string) {
statusText.value = `${name}暂未开通,敬请期待` toast(`${name}暂未开通,敬请期待`, 'normal')
statusType.value = 'normal' }
function toast(msg: string, type: 'normal' | 'error' = 'normal') {
statusText.value = msg
statusType.value = type
if (statusTimer) { if (statusTimer) {
window.clearTimeout(statusTimer) window.clearTimeout(statusTimer)
} }
statusTimer = window.setTimeout(() => { statusTimer = window.setTimeout(() => {
statusText.value = '' statusText.value = ''
}, 2200) }, 2500)
} }
function scrollToTools() { async function onDownloadClick() {
void nextTick(() => { // 教程客户端压缩包取自 MinIO 公开直链(client/tutorial/,见 download-btn.ts
document.querySelector('.card-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) const api = getPywebviewApi()
}) if (api?.open_external_url) {
} // 桌面端:交系统默认浏览器下载,可看到下载进度
try {
function scrollToHashGroup() { const res = await api.open_external_url(TUTORIAL_DOWNLOAD_URL)
const hash = window.location.hash.replace('#', '') if (!res?.success) toast(`下载失败:${res?.error ?? '请重试'}`, 'error')
if (!hash) return } catch (_error) {
const target = document.getElementById(hash) toast('下载失败,请重试', 'error')
if (target) { }
void nextTick(() => { return
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
} }
// 网页端:新窗口打开直链下载
window.open(TUTORIAL_DOWNLOAD_URL, '_blank', 'noopener')
}
function onCatChange(key: 'front' | 'ops' | 'logi') {
activeCat.value = key
history.replaceState(null, '', `#group-${key}`)
}
function parseHashGroup() {
const hash = window.location.hash.replace('#', '')
const m = /^group-(front|ops|logi)$/.exec(hash)
if (m) activeCat.value = m[1] as 'front' | 'ops' | 'logi'
} }
onMounted(async () => { onMounted(async () => {
@@ -204,7 +248,7 @@ onMounted(async () => {
visibleGroups.value = permissionUnavailable.value visibleGroups.value = permissionUnavailable.value
? TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })) ? TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] }))
: filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value) : filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value)
scrollToHashGroup() parseHashGroup()
}) })
</script> </script>
@@ -214,7 +258,11 @@ onMounted(async () => {
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
background: #151a25; background-color: #1a1f2e;
/* 深色底 + 青色网点纹理(对齐 gemini-code body */
background-image: radial-gradient(rgba(45, 212, 191, 0.08) 1px, transparent 1px);
background-size: 48px 48px;
font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
} }
.console-body { .console-body {
@@ -222,23 +270,46 @@ onMounted(async () => {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 22px 34px 44px; padding: 14px 28px 28px;
background: #151a25; background: transparent;
/* 滚动条滑块随分类色(保持各分类有独立主色) */
scrollbar-color: var(--scroll-color, #2dd4bf) #1a1f2e;
} }
/* ===== Hero ===== */ .console-body::-webkit-scrollbar {
width: 10px;
}
.console-body::-webkit-scrollbar-track {
background: #1a1f2e;
}
.console-body::-webkit-scrollbar-thumb {
min-height: 40px;
background: var(--scroll-color, #2dd4bf);
border-radius: 5px;
}
.console-body::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.console-body::-webkit-scrollbar-button {
display: none;
}
/* ===== Hero(仅 front 显示;PANEL 底 + LINE 边框 + 12px 圆角,对齐 gemini-code ===== */
.hero { .hero {
max-width: 1180px; max-width: 1440px;
margin: 0 auto 26px; margin: 0 auto 20px;
border: 1px solid #2e3a52;
border-radius: 16px;
background: linear-gradient(135deg, #1b2233 0%, #151a24 100%);
}
.hero-inner {
display: flex; display: flex;
justify-content: space-between;
align-items: center;
gap: 24px; gap: 24px;
padding: 26px 28px; padding: 26px 30px;
border: 1px solid #3e4a62;
border-radius: 12px;
background: #232a3b;
} }
.hero-left { .hero-left {
@@ -248,75 +319,78 @@ onMounted(async () => {
.hero-pill { .hero-pill {
display: inline-block; display: inline-block;
padding: 5px 12px; margin-bottom: 12px;
border-radius: 999px; padding: 4px 12px;
background: rgba(45, 212, 191, 0.14); border: 1px solid rgba(45, 212, 191, 0.2);
border-radius: 20px;
background: #0f2e28;
color: #2dd4bf; color: #2dd4bf;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em;
} }
.hero-title { .hero-title {
margin: 14px 0 0; margin: 0 0 8px;
color: #f5f8fc; color: #f5f8fc;
font-size: 28px; font-size: 26px;
font-weight: 800; font-weight: 700;
line-height: 1.3;
} }
.hero-sub { .hero-sub {
max-width: 620px; max-width: 650px;
margin: 10px 0 0; margin: 0 0 20px;
color: #a0acbe; color: #c8d2e2;
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.6;
} }
.hero-stats { .hero-stats {
display: flex; display: flex;
gap: 44px; gap: 40px;
margin-top: 22px;
} }
.stat strong { .stat strong {
display: block; display: block;
font-size: 22px; font-size: 22px;
font-weight: 800; font-weight: 700;
line-height: 1.2; line-height: 1.1;
} }
.stat span { .stat span {
display: block; display: block;
margin-top: 3px; margin-top: 4px;
color: #5e6878; color: #a0acbe;
font-size: 10px; font-size: 11px;
} }
/* Hero 右侧工作台卡 */ /* Hero 右侧工作台卡350 宽 / 10px 圆角,对齐 gemini-code hero-right-card */
.hero-card { .hero-card {
width: 300px; width: 350px;
flex-shrink: 0; flex-shrink: 0;
padding: 18px 20px; display: flex;
border: 1px solid #2e3a52; flex-direction: column;
border-radius: 12px; justify-content: space-between;
background: #202a3e; padding: 20px;
border: 1px solid #3e4a62;
border-radius: 10px;
background: #2b3447;
} }
.hero-card-head { .hero-card-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
margin-bottom: 14px;
} }
.hero-card-dots { .hero-card-dots {
display: flex; display: flex;
align-items: center; gap: 5px;
} }
.dot { .dot {
width: 10px; width: 8px;
height: 10px; height: 8px;
border-radius: 50%; border-radius: 50%;
} }
@@ -326,70 +400,189 @@ onMounted(async () => {
.dot--blue { .dot--blue {
background: #60a5fa; background: #60a5fa;
margin-left: 6px;
} }
.dot--brand { .dot--brand {
background: #f59e0b; background: #f59e0b;
margin-left: 6px;
}
.dots-line {
display: block;
width: 42px;
height: 2px;
margin-top: 14px;
background: #3e4a62;
} }
.hero-card-caption { .hero-card-caption {
color: #5e6878; color: #a0acbe;
font-size: 10px; font-size: 11px;
} }
.hero-card-title { .hero-card-title {
margin: 16px 0 0; margin: 0 0 12px;
color: #f5f8fc; color: #f5f8fc;
font-size: 17px; font-size: 17px;
font-weight: 800; font-weight: 700;
line-height: 1.6; line-height: 1.4;
} }
.hero-card-divider { .hero-card-divider {
height: 1px; height: 1px;
margin: 14px 0; margin-bottom: 12px;
background: #3e4a62; background: #3e4a62;
} }
.hero-card-sub { .hero-card-sub {
margin: 0; margin: 0;
color: #c8d2e2;
font-size: 11px;
line-height: 1.5;
}
/* 下载按钮(渐变文字按钮,对齐 gemini-code download-btn;下载教学客户端 zip 直链) */
.hero-card-btn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hero-card-btn-row .hero-card-sub {
margin: 0;
}
.hero-dl {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border: none;
border-radius: 6px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #ffffff;
font-size: 12px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 10px rgba(245, 158, 11, 0.4);
}
.hero-dl:hover {
filter: brightness(1.15);
transform: translateY(-1px);
}
/* ===== 标准工作流时间线(PANEL 底 + LINE 边框 + 圆形编号,对齐 gemini-code ===== */
.workflow-panel {
max-width: 1440px;
margin: 0 auto 22px;
padding: 18px 24px;
border: 1px solid #3e4a62;
border-radius: 10px;
background: #232a3b;
}
.workflow-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.workflow-title {
color: #f5f8fc;
font-size: 14px;
font-weight: 700;
}
.workflow-badge {
padding: 2px 8px;
border-radius: 4px;
background: #3a2a10;
color: #f59e0b;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.workflow-hint {
margin-left: auto;
color: #a0acbe; color: #a0acbe;
font-size: 11px; font-size: 11px;
line-height: 1.7; white-space: nowrap;
} }
.hero-card-btn { .workflow-steps {
margin-top: 14px; display: flex;
padding: 9px 22px; align-items: center;
border: 0; justify-content: space-between;
border-radius: 8px; margin: 0;
background: #f59e0b; padding: 0;
color: #fff; list-style: none;
font: inherit; }
font-size: 13px;
font-weight: 700; .workflow-step {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
cursor: pointer; cursor: pointer;
transition: background 0.18s ease; transition: transform 0.15s;
} }
.hero-card-btn:hover { .workflow-step:not(.workflow-step--disabled):hover {
background: #f7b13c; transform: translateY(-2px);
} }
/* ===== 工具分组 ===== */ .workflow-step--disabled {
cursor: default;
opacity: 0.7;
}
/* 悬停:编号圆填充分类色白字、名称变亮(已确认保留的高亮反馈) */
.workflow-step:not(.workflow-step--disabled):hover .step-circle {
background: var(--gc, #2dd4bf) !important;
color: #ffffff !important;
}
.workflow-step:not(.workflow-step--disabled):hover .step-name {
color: #f5f8fc;
}
.step-circle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
margin-bottom: 6px;
font-size: 12px;
font-weight: 700;
}
.step-name {
margin-bottom: 2px;
color: #c8d2e2;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.step-desc {
color: #a0acbe;
font-size: 9px;
text-align: center;
}
/* 步骤连接线:圆编号列之间贯穿的横线(垂直居中于编号行,对齐 gemini-code timeline-line */
.step-line {
flex: 1 1 auto;
min-width: 14px;
height: 2px;
margin: 0 4px;
transform: translateY(-16px);
}
/* ===== 分类标题 + 卡片墙 ===== */
.tool-group { .tool-group {
max-width: 1180px; max-width: 1440px;
margin: 0 auto 34px; margin: 0 auto;
} }
.group-head { .group-head {
@@ -407,177 +600,101 @@ onMounted(async () => {
.group-name { .group-name {
color: #f5f8fc; color: #f5f8fc;
font-size: 17px; font-size: 16px;
font-weight: 800; font-weight: 700;
} }
.group-desc { .group-desc {
color: #5e6878; color: #a0acbe;
font-size: 11px; font-size: 11px;
} }
.group-count { .group-count {
margin-left: auto; margin-left: auto;
color: #5e6878; color: #a0acbe;
font-size: 11px; font-size: 11px;
} }
/* ===== 工作流时间线 ===== */ /* 卡片墙(CARD 底 + 2px #2A3344 边框 + 10px 圆角 min-height 165,对齐 gemini-code tool-card */
.workflow-panel {
margin-bottom: 18px;
padding: 16px 22px 20px;
border: 1px solid #2e3a52;
border-radius: 12px;
background: #1c2333;
}
.workflow-head {
display: flex;
align-items: center;
gap: 12px;
}
.workflow-title {
color: #f5f8fc;
font-size: 14px;
font-weight: 700;
}
.workflow-badge {
padding: 3px 10px;
border-radius: 999px;
background: rgba(245, 158, 11, 0.14);
font-size: 10px;
font-weight: 700;
}
.workflow-hint {
margin-left: auto;
color: #5e6878;
font-size: 10px;
}
.workflow-steps {
display: flex;
align-items: flex-start;
margin: 16px 0 0;
padding: 0;
list-style: none;
}
.workflow-step {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
cursor: pointer;
}
.workflow-step--disabled {
cursor: default;
opacity: 0.7;
}
.step-circle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 12px;
font-weight: 800;
}
.step-name {
margin-top: 8px;
color: #c8d2e2;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.step-desc {
margin-top: 2px;
color: #5e6878;
font-size: 9px;
text-align: center;
}
.step-line {
flex: 1 1 auto;
min-width: 12px;
height: 2px;
margin-top: 15px;
align-self: flex-start;
}
/* ===== 卡片墙 ===== */
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px; gap: 14px;
margin-bottom: 28px;
} }
.tool-card { .tool-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
min-width: 0; min-width: 0;
padding: 16px 18px 14px; min-height: 165px;
border: 2px solid #2e3a52; padding: 16px;
border-radius: 12px; border: 2px solid #2a3344;
background: #1c2333; border-radius: 10px;
background: #2b3447;
text-align: left; text-align: left;
text-decoration: none; text-decoration: none;
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease; transition: all 0.2s;
} }
/* 悬浮:边框高亮为分类色 + 背景变亮 + 上浮阴影(对齐 gemini-code hover 并按要求加边框高亮) */
.tool-card:hover { .tool-card:hover {
border-color: #2dd4bf; background: #323d52;
background: #262f42; border-color: var(--gc, #2dd4bf);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
} }
.tool-card-top { .tool-card-top {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; gap: 12px;
gap: 8px; margin-bottom: 12px;
} }
.tool-tag { .tool-tag {
padding: 3px 9px; padding: 2px 8px;
border-radius: 999px; border-radius: 4px;
background: rgba(255, 255, 255, 0.06); font-size: 10px;
font-size: 9px;
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
} }
/* 悬浮:角标变分类色实底白字(保留的亮反馈) */
.tool-card:hover .tool-tag {
background: var(--gc, #2dd4bf) !important;
color: #ffffff !important;
}
.tool-card-name { .tool-card-name {
margin-top: 12px; margin-bottom: 6px;
color: #f5f8fc; color: #f5f8fc;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
} }
.tool-card-desc { .tool-card-desc {
margin-top: 6px; flex: 1;
color: #5e6878; color: #a0acbe;
font-size: 11px; font-size: 11px;
line-height: 1.6; line-height: 1.4;
} }
.tool-card-go { .tool-card-go {
margin-top: 14px; display: flex;
color: #5e6878; align-items: center;
justify-content: space-between;
margin-top: 12px;
color: #a0acbe;
font-size: 11px; font-size: 11px;
transition: color 0.18s ease; transition: color 0.2s;
} }
.tool-card:hover .tool-card-go { .tool-card:hover .tool-card-go {
color: #2dd4bf; color: var(--gc, #2dd4bf);
font-weight: 700; font-weight: 700;
} }
@@ -587,31 +704,33 @@ onMounted(async () => {
} }
.tool-card--disabled:hover { .tool-card--disabled:hover {
border-color: #2e3a52; border-color: #2a3344;
background: #1c2333; background: #2b3447;
transform: none;
box-shadow: none;
} }
.tool-card--disabled:hover .tool-card-go { .tool-card--disabled:hover .tool-card-go {
color: #5e6878; color: #a0acbe;
font-weight: 400; font-weight: 400;
} }
/* ===== toast ===== */ /* ===== toast(右下角 + 品牌色左边条,对齐 gemini-code toast ===== */
.toast { .toast {
position: fixed; position: fixed;
bottom: 40px; bottom: 60px;
left: 50%; right: 30px;
z-index: 1000; z-index: 1000;
padding: 12px 24px; max-width: min(420px, calc(100vw - 60px));
max-width: min(420px, calc(100vw - 48px)); padding: 12px 20px;
border-radius: 8px; border-radius: 6px;
background: rgba(0, 0, 0, 0.75); border-left: 4px solid #f59e0b;
color: #fff; background: #3d4a63;
font-size: 14px; color: #f5f8fc;
text-align: center; font-size: 13px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transform: translateX(-50%);
transition: opacity 0.3s; transition: opacity 0.3s;
} }
@@ -620,6 +739,7 @@ onMounted(async () => {
} }
.toast.error { .toast.error {
border-left-color: #f87171;
background: rgba(176, 42, 55, 0.92); background: rgba(176, 42, 55, 0.92);
} }
@@ -628,8 +748,9 @@ onMounted(async () => {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.hero-inner { .hero {
flex-direction: column; flex-direction: column;
align-items: stretch;
} }
.hero-card { .hero-card {
@@ -637,7 +758,7 @@ onMounted(async () => {
} }
.console-body { .console-body {
padding: 18px 20px 36px; padding: 14px 16px 24px;
} }
} }
</style> </style>
@@ -11,23 +11,29 @@
</footer> </footer>
</template> </template>
<script setup lang="ts">
// 渐变装饰条固定 青→蓝→紫→橙(对齐 gemini-code footer-gradient-bar
</script>
<style scoped> <style scoped>
.amazon-footer { .amazon-footer {
flex: 0 0 auto; flex: 0 0 auto;
background: #151a25; background: #1a1f2e;
border-top: 1px solid #3e4a62;
} }
.amazon-footer__gradient { .amazon-footer__gradient {
height: 4px; height: 4px;
background: linear-gradient(90deg, #2dd4bf 0%, #60a5fa 55%, #a78bfa 72%, #f59e0b 87%, #f59e0b 100%); width: 100%;
background: linear-gradient(90deg, #2dd4bf 0%, #60a5fa 60%, #a78bfa 85%, #f59e0b 100%);
} }
.amazon-footer__inner { .amazon-footer__inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 20px;
padding: 8px 12px; padding: 10px 28px;
} }
.bug-banner { .bug-banner {
@@ -35,24 +41,26 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 48px; gap: 40px;
padding: 9px 24px; padding: 10px 24px;
border: 1px solid #2dd4bf; border: 1px solid #2dd4bf;
border-radius: 8px; border-radius: 8px;
background: #1e293b; background: #1e293b;
text-decoration: none; text-decoration: none;
transition: background 0.18s ease, border-color 0.18s ease; transition: all 0.2s;
} }
.bug-banner:hover { .bug-banner:hover {
background: #0f172a; background: #0f172a;
border-color: #60a5fa; border-color: #60a5fa;
box-shadow: 0 0 12px rgba(45, 212, 191, 0.2);
} }
.bug-banner__title { .bug-banner__title {
flex-shrink: 0;
color: #2dd4bf; color: #2dd4bf;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 700;
white-space: nowrap; white-space: nowrap;
} }
@@ -3,12 +3,20 @@
<AmazonTopBar :active-cat="groupKey" /> <AmazonTopBar :active-cat="groupKey" />
<div class="tool-head"> <div class="tool-head">
<a class="tool-head__back" :href="backHref"> 返回{{ group ? group.name : '工具' }}列表</a> <router-link class="tool-head__back" :to="backHref"> 返回{{ group ? group.name : '工具' }}列表</router-link>
<span class="tool-head__sep"></span>
<span class="tool-head__title">{{ tool?.name ?? '' }}</span> <span class="tool-head__title">{{ tool?.name ?? '' }}</span>
<span class="tool-head__desc">{{ detailText }}</span> <span class="tool-head__desc">{{ detailText }}</span>
<a
v-if="tool?.id === 'source'"
class="tool-head__aliprice"
:href="ALIPRICE_URL"
target="_blank"
rel="noopener"
>Aliprice点击注册</a>
</div> </div>
<!-- 工具说明提示卡已按需求移除原棕色底 note 卡区域 -->
<div class="tool-shell-body"> <div class="tool-shell-body">
<slot /> <slot />
</div> </div>
@@ -25,6 +33,10 @@ import AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.vue'
import { TOOL_DETAILS, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog' import { TOOL_DETAILS, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog'
import { resolvePageHref } from '@/shared/page-prefix' import { resolvePageHref } from '@/shared/page-prefix'
/** Aliprice 注册推广链接(对齐 gemini-code 工具页头 Aliprice 按钮) */
const ALIPRICE_URL =
'https://www.aiprice.com/?ext_id=10100&channel=chrome_offline&platform=1688&version=4.0.5&browser=chrome&mv=3'
const props = defineProps<{ const props = defineProps<{
/** 工具 id(见 tool-catalog TOOL_MAP */ /** 工具 id(见 tool-catalog TOOL_MAP */
toolId: string toolId: string
@@ -47,53 +59,79 @@ const backHref = computed(() => `${resolvePageHref('/new_web_source/amazon-conso
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
background-color: #1a1f2e;
/* 深色底 + 青色网点纹理(对齐 gemini-code body */
background-image: radial-gradient(rgba(45, 212, 191, 0.08) 1px, transparent 1px);
background-size: 48px 48px;
font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
} }
.tool-head { .tool-head {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; flex-wrap: wrap;
gap: 20px;
min-height: 62px; min-height: 62px;
padding: 10px 34px; padding: 10px 34px;
background: #151a25; background: #1a1f2e;
border-bottom: 1px solid #2a3344;
} }
.tool-head__back { .tool-head__back {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid #3e4a62;
border-radius: 6px;
color: #c8d2e2; color: #c8d2e2;
font-size: 13px; font-size: 13px;
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
transition: all 0.2s;
} }
.tool-head__back:hover { .tool-head__back:hover {
color: #fff; background: #2b3447;
} color: #f5f8fc;
.tool-head__sep {
flex-shrink: 0;
width: 1px;
height: 18px;
background: #3e4a62;
} }
.tool-head__title { .tool-head__title {
flex-shrink: 0; flex-shrink: 0;
color: #f5f8fc; color: #f5f8fc;
font-size: 20px; font-size: 22px;
font-weight: 800; font-weight: 700;
white-space: nowrap; white-space: nowrap;
} }
.tool-head__desc { .tool-head__desc {
min-width: 0; min-width: 0;
overflow: hidden; max-width: 650px;
color: #a0acbe; color: #a0acbe;
font-size: 12px; font-size: 12px;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
}
/* Aliprice 注册按钮(仅货源查询显示,TEAL 渐变,对齐 gemini-code aliprice-btn */
.tool-head__aliprice {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 6px 14px;
background: linear-gradient(135deg, #14b8a6, #0d9488);
color: #ffffff;
font-size: 11px;
font-weight: 700;
text-decoration: none;
white-space: nowrap; white-space: nowrap;
transition: all 0.2s;
}
.tool-head__aliprice:hover {
filter: brightness(1.15);
} }
.tool-shell-body { .tool-shell-body {
@@ -105,8 +143,7 @@ const backHref = computed(() => `${resolvePageHref('/new_web_source/amazon-conso
@media (max-width: 1100px) { @media (max-width: 1100px) {
.tool-head { .tool-head {
padding: 10px 16px; padding: 10px 16px;
flex-wrap: wrap; gap: 10px;
gap: 8px;
} }
} }
</style> </style>
@@ -1,32 +1,36 @@
<template> <template>
<header class="amazon-top"> <header class="amazon-top">
<div class="brand"> <router-link class="brand" :to="toolbarHref" title="返回工具台首页">
<span class="brand-logo"></span> <span class="brand-logo"></span>
<div class="brand-text"> <span class="brand-text">
<span class="brand-name">数富AI</span> <span class="brand-name">数富AI</span>
<span class="brand-sub">亚马逊运营工具台</span> <span class="brand-sub">亚马逊运营工具台</span>
</div> </span>
</div> </router-link>
<nav class="cat-tabs" aria-label="分类导航"> <nav class="cat-tabs" aria-label="分类导航">
<template v-for="cat in visibleCats" :key="cat.key"> <template v-for="cat in visibleCats" :key="cat.key">
<a <router-link
class="cat-tab" class="cat-tab"
:class="{ 'cat-tab--active': cat.key === activeCat }" :class="{ 'cat-tab--active': cat.key === activeCat }"
:href="`${toolbarHref}#group-${cat.key}`" :to="`${toolbarHref}#group-${cat.key}`"
@click="onTabClick(cat.key)"
> >
<span class="cat-dot" :style="{ background: cat.color }"></span> <span class="cat-tab-content">
<span class="cat-name">{{ cat.name }}</span> <span class="cat-dot" :style="{ background: cat.color }"></span>
<span class="cat-badge" :style="badgeStyle(cat)">{{ cat.tools.length }}</span> <span class="cat-name">{{ cat.name }}</span>
<span class="cat-underline" :style="{ background: cat.color }"></span> <span class="cat-badge" :style="badgeStyle(cat)">{{ cat.tools.length }}</span>
</a> </span>
<span class="cat-underline" :style="underlineStyle(cat)"></span>
</router-link>
</template> </template>
</nav> </nav>
<div class="top-right"> <div class="top-right">
<a href="/home" class="btn-home">返回首页</a> <BrandApiSecretSettingsButton variant="topbar" />
<BrandApiSecretSettingsButton /> <router-link class="help-btn" :to="workHref" title="采集工作台"></router-link>
<span class="help-mark" title="帮助">?</span> <span class="help-btn" title="如需支持请点击底部BUG登记系统">?</span>
<span class="admin-chip"><span class="admin-badge">AD</span>{{ username }}</span>
</div> </div>
</header> </header>
</template> </template>
@@ -50,17 +54,34 @@ const props = withDefaults(
}, },
) )
const emit = defineEmits<{ change: [key: 'front' | 'ops' | 'logi'] }>()
const activeCat = computed(() => props.activeCat) const activeCat = computed(() => props.activeCat)
const visibleCats = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] }))) const visibleCats = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
const username = ref('admin')
/** 分类 tab 点击回到工具台对应分组 */ /** 分类 tab 点击回到工具台对应分组 */
const toolbarHref = resolvePageHref('/new_web_source/amazon-console.html') const toolbarHref = resolvePageHref('/new_web_source/amazon-console.html')
/** ⚙ 采集工作台:直达现有真实功能页(对齐 gemini-code 顶栏 ⚙ 入口) */
const workHref = resolvePageHref('/new_web_source/collect-data.html')
function onTabClick(key: 'front' | 'ops' | 'logi') {
emit('change', key)
}
/** 激活分类角标:分类色实底白字(对齐 gemini-code tab-badge 激活态) */
function badgeStyle(cat: VisibleGroup) { function badgeStyle(cat: VisibleGroup) {
return activeCat.value === cat.key ? { background: cat.color, color: '#FFFFFF' } : {}
}
/** 激活分类下划线(对齐 gemini-code tab-underline:底部 3px 分类色圆角条) */
function underlineStyle(cat: VisibleGroup) {
return activeCat.value === cat.key ? { background: cat.color } : {} return activeCat.value === cat.key ? { background: cat.color } : {}
} }
onMounted(async () => { onMounted(async () => {
const localName = typeof window === 'undefined' ? '' : (window.localStorage.getItem('username') || '').trim()
if (localName) username.value = localName
try { try {
const keys = await getCurrentUserAppColumnKeys() const keys = await getCurrentUserAppColumnKeys()
visibleCats.value = filterGroupsByPermission(TOOL_GROUPS, keys) visibleCats.value = filterGroupsByPermission(TOOL_GROUPS, keys)
@@ -79,9 +100,10 @@ onMounted(async () => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 20px;
padding: 0 24px; padding: 0 28px;
background: #151a25; background: #1a1f2e;
border-bottom: 1px solid #2a3344; border-bottom: 1px solid #3e4a62;
font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
} }
.brand { .brand {
@@ -89,6 +111,7 @@ onMounted(async () => {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex-shrink: 0; flex-shrink: 0;
text-decoration: none;
} }
.brand-logo { .brand-logo {
@@ -97,11 +120,12 @@ onMounted(async () => {
justify-content: center; justify-content: center;
width: 38px; width: 38px;
height: 38px; height: 38px;
border-radius: 9px; border-radius: 8px;
background: #f59e0b; background: #f59e0b;
color: #fff; color: #fff;
font-size: 15px; font-size: 20px;
font-weight: 800; font-weight: 700;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
} }
.brand-text { .brand-text {
@@ -112,14 +136,13 @@ onMounted(async () => {
.brand-name { .brand-name {
color: #f5f8fc; color: #f5f8fc;
font-size: 16px; font-size: 17px;
font-weight: 800; font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap; white-space: nowrap;
} }
.brand-sub { .brand-sub {
color: #5e6878; color: #a0acbe;
font-size: 10px; font-size: 10px;
white-space: nowrap; white-space: nowrap;
} }
@@ -130,36 +153,46 @@ onMounted(async () => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 8px;
} }
.cat-tab { .cat-tab {
position: relative; position: relative;
display: inline-flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 8px; width: 140px;
min-width: 132px; height: 42px;
height: 38px;
padding: 0 14px;
border-radius: 10px;
background: transparent; background: transparent;
color: #c8d2e2; color: #c8d2e2;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
transition: background 0.18s ease, color 0.18s ease; transition: all 0.2s;
} }
.cat-tab:hover { .cat-tab-content {
background: #232a3b; display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 8px;
transition: background 0.2s;
}
.cat-tab:hover .cat-tab-content {
background: #3d4a63;
color: #f5f8fc; color: #f5f8fc;
} }
.cat-tab--active { .cat-tab--active {
background: #3d4a63;
color: #f5f8fc; color: #f5f8fc;
} }
.cat-tab--active .cat-tab-content {
background: #3d4a63;
}
.cat-dot { .cat-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -171,33 +204,23 @@ onMounted(async () => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 22px; padding: 1px 7px;
height: 18px; border-radius: 12px;
padding: 0 4px; background: #343f55;
border-radius: 999px; color: #a0acbe;
background: #2b3447;
color: #5e6878;
font-size: 10px; font-size: 10px;
font-weight: 800; font-weight: 700;
}
.cat-tab--active .cat-badge {
color: #fff;
} }
.cat-underline { .cat-underline {
position: absolute; position: absolute;
left: 10px; left: 0;
right: 10px; right: 0;
bottom: -4px; bottom: 0;
height: 3px; height: 3px;
border-radius: 2px; border-radius: 3px 3px 0 0;
background: transparent; background: transparent;
opacity: 0; transition: background 0.2s;
}
.cat-tab--active .cat-underline {
opacity: 1;
} }
.top-right { .top-right {
@@ -207,29 +230,45 @@ onMounted(async () => {
flex-shrink: 0; flex-shrink: 0;
} }
.btn-home { .help-btn {
color: #a0acbe;
font-size: 13px;
text-decoration: none;
white-space: nowrap;
}
.btn-home:hover {
color: #fff;
}
.help-mark {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 22px; min-width: 22px;
height: 22px; height: 22px;
border-radius: 50%; color: #c8d2e2;
background: #232a3b; font-size: 16px;
color: #a0acbe; font-weight: 700;
font-size: 13px; text-decoration: none;
font-weight: 800; cursor: pointer;
cursor: help; padding: 4px 8px;
}
.help-btn:hover {
color: #f5f8fc;
}
.admin-chip {
display: inline-flex;
align-items: center;
gap: 8px;
color: #c8d2e2;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.admin-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 4px;
background: #2a6b4f;
color: #ffffff;
font-size: 10px;
font-weight: 700;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
@@ -13,13 +13,16 @@ const props = withDefaults(
defineProps<{ defineProps<{
name: string name: string
size?: number size?: number
/** 分类主色:传入时图标用该纯色底(对齐主程序 _get_icon 按分类色) */
color?: string
}>(), }>(),
{ {
size: 48, size: 48,
color: undefined,
}, },
) )
const gradient = computed(() => TOOL_ICON_GRADIENTS[props.name] ?? TOOL_ICON_GRADIENTS.collect) const gradient = computed(() => props.color ?? TOOL_ICON_GRADIENTS[props.name] ?? TOOL_ICON_GRADIENTS.collect)
const shape = computed(() => TOOL_ICON_SHAPES[props.name] ?? TOOL_ICON_SHAPES.collect) const shape = computed(() => TOOL_ICON_SHAPES[props.name] ?? TOOL_ICON_SHAPES.collect)
</script> </script>
@@ -0,0 +1,7 @@
/**
* 工具台首页「立即下载教程」:数富AI 教学客户端压缩包公开直链。
*
* 对象位于 MinIO 公开 client 桶(host A minio 容器,openresty 反代 oss.aishufu.top),
* 由 scripts/upload_tutorial_zip.py 上传(同 key 可覆盖更新)。
*/
export const TUTORIAL_DOWNLOAD_URL = 'https://oss.aishufu.top/client/tutorial/数富AI-教学客户端.zip'
+19 -22
View File
@@ -1,7 +1,7 @@
/** /**
* 亚马逊运营工具台 · 工具目录 * 亚马逊运营工具台 · 工具目录
* *
* 来源:reference/数富AI工具台_含源代码/数富AI工具台_主程序.py 的 CATS 数据, * 来源:原 tkinter 版工具台(reference/数富AI工具台_含源代码,已废弃删除)的 CATS 数据,
* 页面链接按现有 BrandTopBar 导航映射到既有功能页(表单 + 右侧任务全部保留现有)。 * 页面链接按现有 BrandTopBar 导航映射到既有功能页(表单 + 右侧任务全部保留现有)。
*/ */
@@ -47,9 +47,6 @@ export type ToolGroup = {
workflow?: WorkflowStep[] workflow?: WorkflowStep[]
} }
/** 现有功能页地址(全部为 new_web_source 的 Vue 构建页) */
const VM = '/new_web_source'
export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
{ {
key: 'front', key: 'front',
@@ -77,7 +74,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
name: '采集数据', name: '采集数据',
desc: '批量采集站点商品数据,按照自己要求数据', desc: '批量采集站点商品数据,按照自己要求数据',
tag: '流程·1', tag: '流程·1',
href: `${VM}/collect-data.html`, href: '/collect-data',
permissionKeys: ['collect-data'], permissionKeys: ['collect-data'],
}, },
{ {
@@ -85,7 +82,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
name: 'ASIN变体采集', name: 'ASIN变体采集',
desc: '支持粘贴和文档,按照采集国家选择导出', desc: '支持粘贴和文档,按照采集国家选择导出',
tag: '流程·2', tag: '流程·2',
href: `${VM}/variant-collection.html`, href: '/variant-collection',
permissionKeys: ['variant-collection'], permissionKeys: ['variant-collection'],
}, },
{ {
@@ -94,7 +91,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
desc: '全站唯一 ASIN 归属:谁先采集归谁,自动过滤撞款', desc: '全站唯一 ASIN 归属:谁先采集归谁,自动过滤撞款',
tag: '重点', tag: '重点',
core: true, core: true,
href: `${VM}/dedupe.html`, href: '/dedupe',
permissionKeys: ['dedupe'], permissionKeys: ['dedupe'],
}, },
{ {
@@ -102,7 +99,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
name: '货源查询', name: '货源查询',
desc: '关闭图片检查和类目检测不扣费,打开需要算力费用', desc: '关闭图片检查和类目检测不扣费,打开需要算力费用',
tag: '流程·4', tag: '流程·4',
href: `${VM}/similar-asin.html`, href: '/similar-asin',
permissionKeys: ['similar-asin'], permissionKeys: ['similar-asin'],
}, },
{ {
@@ -110,7 +107,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
name: '品牌检测', name: '品牌检测',
desc: '核查上架数据品牌风险,精确 / 嵌入两种策略', desc: '核查上架数据品牌风险,精确 / 嵌入两种策略',
tag: '流程·5', tag: '流程·5',
href: `${VM}/brand.html`, href: '/brand',
permissionKeys: ['brand'], permissionKeys: ['brand'],
}, },
{ {
@@ -118,21 +115,21 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
name: '外观专利检测', name: '外观专利检测',
desc: '检测商品外观专利,版权,标题,图片。详细页风险,规避下架', desc: '检测商品外观专利,版权,标题,图片。详细页风险,规避下架',
tag: '流程·6', tag: '流程·6',
href: `${VM}/appearance-patent.html`, href: '/appearance-patent',
permissionKeys: ['appearance-patent'], permissionKeys: ['appearance-patent'],
}, },
{ {
id: 'split', id: 'split',
name: '数据拆分', name: '数据拆分',
desc: '大文件按规则拆分,便于分批处理与上传', desc: '大文件按规则拆分,便于分批处理与上传',
href: `${VM}/split.html`, href: '/split',
permissionKeys: ['split'], permissionKeys: ['split'],
}, },
{ {
id: 'convert', id: 'convert',
name: '格式转换', name: '格式转换',
desc: '数据格式一键转换,适配各工具导入', desc: '数据格式一键转换,适配各工具导入',
href: `${VM}/convert.html`, href: '/convert',
permissionKeys: ['convert'], permissionKeys: ['convert'],
}, },
], ],
@@ -164,14 +161,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
id: 'list', id: 'list',
name: '上架', name: '上架',
desc: '把安全数据批量上架到指定店铺', desc: '把安全数据批量上架到指定店铺',
href: `${VM}/publish.html`, href: '/publish',
permissionKeys: ['publish'], permissionKeys: ['publish'],
}, },
{ {
id: 'delasin', id: 'delasin',
name: '指定删除ASIN', name: '指定删除ASIN',
desc: '批量删除需清理的 ASIN(风险 / 优化 / 邮箱汇总)', desc: '批量删除需清理的 ASIN(风险 / 优化 / 邮箱汇总)',
href: `${VM}/delete-brand.html`, href: '/delete-brand',
permissionKeys: ['delete-brand'], permissionKeys: ['delete-brand'],
}, },
{ {
@@ -180,14 +177,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
desc: '4 类风险一键处理:禁止显示 / 需要批准 / 详情已删 / 账户状态', desc: '4 类风险一键处理:禁止显示 / 需要批准 / 详情已删 / 账户状态',
tag: '高频', tag: '高频',
core: true, core: true,
href: `${VM}/product-risk.html`, href: '/product-risk',
permissionKeys: ['product-risk'], permissionKeys: ['product-risk'],
}, },
{ {
id: 'time', id: 'time',
name: '定时匹配', name: '定时匹配',
desc: '定时价格匹配,更快更及时抢回购物车', desc: '定时价格匹配,更快更及时抢回购物车',
href: `${VM}/shop-match.html`, href: '/shop-match',
permissionKeys: ['shop-match'], permissionKeys: ['shop-match'],
}, },
{ {
@@ -196,21 +193,21 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
desc: '核心工具 · 时时跟价抢购物车,支持指定 ASIN 快速跟价', desc: '核心工具 · 时时跟价抢购物车,支持指定 ASIN 快速跟价',
tag: '核心', tag: '核心',
core: true, core: true,
href: `${VM}/price-track.html`, href: '/price-track',
permissionKeys: ['pricing', 'price-track'], permissionKeys: ['pricing', 'price-track'],
}, },
{ {
id: 'patrol', id: 'patrol',
name: '巡店删除', name: '巡店删除',
desc: '按商品状态巡店清理,摸清店铺商品全貌', desc: '按商品状态巡店清理,摸清店铺商品全貌',
href: `${VM}/patrol-delete.html`, href: '/patrol-delete',
permissionKeys: ['patrol-delete'], permissionKeys: ['patrol-delete'],
}, },
{ {
id: 'qasin', id: 'qasin',
name: '查询ASIN', name: '查询ASIN',
desc: '排查出单 ASIN 是否仍在售,保住优质数据', desc: '排查出单 ASIN 是否仍在售,保住优质数据',
href: `${VM}/query-asin.html`, href: '/query-asin',
permissionKeys: ['query-asin'], permissionKeys: ['query-asin'],
}, },
{ {
@@ -219,14 +216,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
desc: '核心 · 抓取店铺排名 / 品牌 / 销售,支撑复查与优化', desc: '核心 · 抓取店铺排名 / 品牌 / 销售,支撑复查与优化',
tag: '核心', tag: '核心',
core: true, core: true,
href: `${VM}/shop-data-crawl.html`, href: '/shop-data-crawl',
permissionKeys: ['shop-data-crawl', 'shop_data_crawl'], permissionKeys: ['shop-data-crawl', 'shop_data_crawl'],
}, },
{ {
id: 'cash', id: 'cash',
name: '取款', name: '取款',
desc: '留足平台扣款费用,其余全部提现', desc: '留足平台扣款费用,其余全部提现',
href: `${VM}/withdraw.html`, href: '/withdraw',
permissionKeys: ['withdraw'], permissionKeys: ['withdraw'],
}, },
{ {
@@ -345,7 +342,7 @@ export const TOOL_DETAILS: Readonly<Record<string, string>> = {
brand: '核查上架数据品牌风险,先跑精确匹配,后跑嵌入式', brand: '核查上架数据品牌风险,先跑精确匹配,后跑嵌入式',
delasin: '收集统一要删除的ASIN,批量删除清理', delasin: '收集统一要删除的ASIN,批量删除清理',
risk: risk:
'A 搜索结果中禁止显示;B 需要批准(最通用、使用频率最高);C 详情页面已删除;D 账户状态处理(需审批时用后台处理)。', 'A 搜索结果中禁止显示;B 需要批准(最通用、使用频率最高);C 详情页面已删除;D 账户状态处理(需审批没有时候后台处理)。',
follow: follow:
'一个虚拟桌面跟一个国家,错开跑即可时时跟价抢购物车。指定 ASIN 文档跟价:用后台有利润数据快速跟价,下班前跑一下,确保出单数据及时是我们的购物车。', '一个虚拟桌面跟一个国家,错开跑即可时时跟价抢购物车。指定 ASIN 文档跟价:用后台有利润数据快速跟价,下班前跑一下,确保出单数据及时是我们的购物车。',
patrol: '巡店删除是按商品状态删除,如审核多次不通过的商品。不放任何状态跑一遍,可清楚掌握店铺数据情况。', patrol: '巡店删除是按商品状态删除,如审核多次不通过的商品。不放任何状态跑一遍,可清楚掌握店铺数据情况。',
@@ -1,6 +1,6 @@
<template> <template>
<div class="secret-settings-entry"> <div class="secret-settings-entry">
<button type="button" class="secret-settings-trigger" @click="dialogVisible = true"> <button type="button" class="secret-settings-trigger" :class="{ 'variant-topbar': variant === 'topbar' }" @click="dialogVisible = true">
<span class="secret-settings-icon" aria-hidden="true"></span> <span class="secret-settings-icon" aria-hidden="true"></span>
<span>密钥设置</span> <span>密钥设置</span>
</button> </button>
@@ -173,6 +173,16 @@ type SecretState = {
} }
const dialogVisible = ref(false) const dialogVisible = ref(false)
const props = withDefaults(
defineProps<{
/** topbar=顶栏纯文字样式(对齐主程序);默认=现有胶囊按钮 */
variant?: 'topbar'
}>(),
{
variant: undefined,
},
)
const proxyUrl = ref('') const proxyUrl = ref('')
const proxyMode = ref<ProxyMode>(1) const proxyMode = ref<ProxyMode>(1)
const proxyLoading = ref(false) const proxyLoading = ref(false)
@@ -378,6 +388,29 @@ loadStates()
border-color: #4c647d; border-color: #4c647d;
} }
/* topbar 变体:纯文字「🔑 密钥设置」,hover 整块变 CARD2(对齐主程序顶栏) */
.secret-settings-trigger.variant-topbar {
height: auto;
padding: 4px 10px;
border: 0;
border-radius: 0;
background: transparent;
color: #c8d2e2;
font-size: 11px;
font-weight: 400;
transition: background 0.12s ease, color 0.12s ease;
}
.secret-settings-trigger.variant-topbar:hover {
background: #343f55;
color: #f5f8fc;
}
.secret-settings-trigger.variant-topbar .secret-settings-icon {
color: #c8d2e2;
font-size: 12px;
}
.secret-settings-icon { .secret-settings-icon {
color: #8dc4ff; color: #8dc4ff;
font-size: 14px; font-size: 14px;
@@ -27,7 +27,7 @@
{{ parsing ? '解析中...' : '解析并创建任务' }} {{ parsing ? '解析中...' : '解析并创建任务' }}
</button> </button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue"> <button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }} {{ pushing ? '启动中...' : '启动任务' }}
</button> </button>
</div> </div>
<p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后累计 50 条调用 LLM当前任务区展示 LLM 回流和结果组装进度</p> <p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后累计 50 条调用 LLM当前任务区展示 LLM 回流和结果组装进度</p>
@@ -41,96 +41,32 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">外观专利检测</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="外观专利检测"
<div class="summary-card"> :cards="patentCards"
<span class="summary-label">运行中任务</span> :current-items="currentTaskViews"
<strong>{{ dashboard.pendingTaskCount }}</strong> :history-items="historyTaskViews"
</div> current-title="当前任务"
<div class="summary-card"> current-empty-text="暂无当前任务"
<span class="summary-label">已结束任务</span> history-empty-text="暂无历史记录"
<strong>{{ dashboard.processedTaskCount }}</strong> >
</div> <template #item-extra="{ item }">
<div class="summary-card"> <div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
<span class="summary-label">成功任务</span> </template>
<strong>{{ dashboard.successTaskCount }}</strong> <template #history-item-extra="{ item }">
</div> <div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
<div class="summary-card"> </template>
<span class="summary-label">失败任务</span> <template #item-actions="{ item }">
<strong>{{ dashboard.failedTaskCount }}</strong> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</div> </template>
</div> <template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
<div class="subsection-title">匹配任务</div> @click="downloadResult(itemSource(item))">下载</button>
<div class="result-list-wrap"> <button v-if="itemSource(item).resultId" type="button" class="btn-delete"
<div class="result-list-header"> @click="deleteTaskRecord(itemSource(item))">删除</button>
<span>当前任务</span> </template>
</div> </TaskCenterPanel>
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressMessage(item, '处理中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</span>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>历史记录</span>
</div>
<div v-if="!historyOnlyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyOnlyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressMessage(item, '结果生成中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -142,6 +78,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue' import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { import {
activateAppearancePatentTask, activateAppearancePatentTask,
deleteAppearancePatentHistory, deleteAppearancePatentHistory,
@@ -244,6 +182,51 @@ const historyOnlyItems = computed(() =>
}), }),
) )
const patentCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() =>
currentItems.value.map(toPatentTaskView),
)
const historyTaskViews = computed<TaskItemView[]>(() =>
historyOnlyItems.value.map(toPatentTaskView),
)
function itemSource(item: TaskItemView) {
return item.source as AppearancePatentHistoryItem
}
function toPatentTaskView(item: AppearancePatentHistoryItem): TaskItemView {
const showProgress = showFileProgress(item)
const progressPercent = fileProgressPercent(item)
return {
key: `patent-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '外观专利检测',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
...(item.resultFilename && !showProgress ? [item.resultFilename] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
progress: showProgress
? {
percent: progressPercent,
stage: displayFileProgressMessage(item, '处理中'),
}
: null,
source: item,
}
}
function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) { function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
if (item.taskId == null) return item if (item.taskId == null) return item
const liveItem = liveProgressItems.value[item.taskId] const liveItem = liveProgressItems.value[item.taskId]
@@ -531,7 +514,7 @@ async function pushToPythonQueue() {
clearParsedTask() clearParsedTask()
await loadDashboard() await loadDashboard()
await loadHistory({ force: true }) await loadHistory({ force: true })
ElMessage.success('已推送到 Python 队列') ElMessage.success('已启动任务')
} finally { } finally {
pushing.value = false pushing.value = false
} }
@@ -979,6 +962,26 @@ onUnmounted(() => {
stopPolling() stopPolling()
timers.clearScope() timers.clearScope()
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1006,36 +1009,12 @@ onUnmounted(() => {
.prompt-input:focus, .secret-input:focus { border-color: #3498db; } .prompt-input:focus, .secret-input:focus { border-color: #3498db; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2e3a52; border-radius: 8px; background: #222b3d; color: #a0acbe; font-size: 12px; } .parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2e3a52; border-radius: 8px; background: #222b3d; color: #a0acbe; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; } .queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; font-size: 15px; font-weight: 600; color: #c8d2e2; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
.summary-label { color: #5e6878; font-size: 12px; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; min-height: 180px; margin: 0 0 16px; }
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 18px; text-align: center; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; } .result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.task-list { list-style: none; margin: 0; padding: 12px; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
.status.pending { background: rgba(149, 165, 166, .18); color: #a0acbe; }
.result-hint { margin-top: 6px; color: #e0b96d; } .result-hint { margin-top: 6px; color: #e0b96d; }
.file-progress { margin-top: 8px; max-width: 520px; }
.file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; }
.file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #333f55; border: 1px solid #3b3b3b; }
.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; }
.download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); } .download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); }
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); } .btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; } .main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
</style> </style>
@@ -76,44 +76,36 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header"> <div class="panel-actions-row">
<span>品牌检测任务</span>
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button> <button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
</div> </div>
<div class="task-list-wrap"> <TaskCenterPanel
<div v-if="!tasks.length" class="empty-tasks"> :on-batch-delete="batchDeleteHistory"
暂无品牌检测任务选择待检文件后点击运行添加到任务队列 title="品牌检测任务"
</div> :cards="brandCards"
<ul v-else class="task-list"> :current-items="currentTaskViews"
<li v-for="item in tasks" :key="`brand-${item.id}`" class="task-item"> :history-items="historyTaskViews"
<div class="left"> current-title="当前任务"
<span class="id" :title="taskDesc(item)">{{ taskDesc(item) }}</span> current-empty-text="暂无当前任务,提交检测后运行中任务会显示在这里"
<div class="files">任务 ID{{ item.id }}</div> history-empty-text="暂无历史记录"
<div v-if="item.file_paths?.length" class="files">文件{{ item.file_paths.length }} </div> >
<div class="files">创建时间{{ formatDateTime(item.created_at) }}</div> <template #item-actions="{ item }">
<template v-if="isRunning(item)"> <template v-if="itemSource(item)">
<div class="task-progress"> <button v-if="isRunning(itemSource(item))" type="button" class="btn-delete"
<div class="task-progress-meta"> @click="cancelTask(itemSource(item))">取消</button>
<span>任务进度</span> <button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
<span>{{ taskProgress(item) }}%</span> @click="deleteTask(itemSource(item))">删除</button>
</div> </template>
<div class="task-progress-track"> </template>
<div class="task-progress-fill" :style="{ width: `${taskProgress(item)}%` }"></div> <template #history-item-actions="{ item }">
</div> <template v-if="itemSource(item)">
</div> <button v-if="canDownload(itemSource(item))" type="button" class="download"
<div v-if="errorMessage(item)" class="files error">{{ errorMessage(item) }}</div> @click="downloadResult(itemSource(item))">下载结果</button>
</template> <button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
<div v-else-if="errorMessage(item)" class="files error">错误{{ errorMessage(item) }}</div> @click="deleteTask(itemSource(item))">删除</button>
</div> </template>
<div class="task-right"> </template>
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span> </TaskCenterPanel>
<button v-if="isRunning(item)" type="button" class="act-btn danger" @click="cancelTask(item)">取消</button>
<button v-if="canDownload(item)" type="button" class="act-btn ok" @click="downloadResult(item)">下载结果</button>
<button v-if="canDelete(item)" type="button" class="act-btn" @click="deleteTask(item)">删除</button>
</div>
</li>
</ul>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -124,18 +116,21 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue' import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { requestPostJson } from '@/shared/api/http'
import { import {
cancelBrandTask, cancelBrandTask,
createBrandTask, createBrandTask,
deleteBrandTask, deleteBrandTask,
expandBrandFolder,
getBrandTaskDownloadUrl, getBrandTaskDownloadUrl,
getBrandTasks, getBrandTasks,
runBrandNow, runBrandNow,
type BrandTaskItem, type BrandTaskItem,
} from '@/shared/api/brand' } from '@/shared/api/brand'
/** 扩展文件夹接口响应(向后端真实字段 paths 兼容,shared 类型仅声明 items 时以本接口为准) */
interface BrandExpandFolderPathsResponse { interface BrandExpandFolderPathsResponse {
success: boolean success: boolean
paths?: string[] paths?: string[]
@@ -172,6 +167,11 @@ function isRunning(item: BrandTaskItem) {
return (item.status || '').toLowerCase() === 'running' return (item.status || '').toLowerCase() === 'running'
} }
function isBusyBrandTask(item: BrandTaskItem) {
const status = (item.status || '').toLowerCase()
return status === 'pending' || status === 'running'
}
function isTerminal(item: BrandTaskItem) { function isTerminal(item: BrandTaskItem) {
const status = (item.status || '').toLowerCase() const status = (item.status || '').toLowerCase()
return status === 'success' || status === 'failed' || status === 'cancelled' return status === 'success' || status === 'failed' || status === 'cancelled'
@@ -193,11 +193,6 @@ function taskProgress(item: BrandTaskItem) {
return Math.max(0, Math.min(100, Math.round((current / total) * 100))) return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
} }
function taskDesc(item: BrandTaskItem) {
const desc = (item.desc || '').trim()
return desc ? (desc.length > 60 ? `${desc.slice(0, 60)}` : desc) : `任务 #${item.id}`
}
function errorMessage(item: BrandTaskItem) { function errorMessage(item: BrandTaskItem) {
return (item.error_message || '').trim() return (item.error_message || '').trim()
} }
@@ -205,7 +200,7 @@ function errorMessage(item: BrandTaskItem) {
function statusText(status?: BrandTaskItem['status']) { function statusText(status?: BrandTaskItem['status']) {
const value = (status || '').toLowerCase() const value = (status || '').toLowerCase()
const map: Record<string, string> = { const map: Record<string, string> = {
pending: '等待中', pending: '排队中',
running: '执行中', running: '执行中',
success: '已完成', success: '已完成',
failed: '失败', failed: '失败',
@@ -219,10 +214,57 @@ function statusClass(status?: BrandTaskItem['status']) {
if (value === 'running') return 'running' if (value === 'running') return 'running'
if (value === 'success') return 'success' if (value === 'success') return 'success'
if (value === 'failed') return 'failed' if (value === 'failed') return 'failed'
if (value === 'cancelled') return 'cancelled'
return 'pending' return 'pending'
} }
// ---- TaskCenterPanel----
/** 当前任务:排队中 / 执行中的任务 */
const currentTaskViews = computed<TaskItemView[]>(() =>
tasks.value.filter(isBusyBrandTask).map(toBrandTaskView),
)
/** 历史任务:终态(成功 / 失败 / 已取消 / 其他状态)的任务 */
const historyTaskViews = computed<TaskItemView[]>(() =>
tasks.value.filter((item) => !isBusyBrandTask(item)).map(toBrandTaskView),
)
const brandCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function itemSource(item: TaskItemView): BrandTaskItem {
return item.source as BrandTaskItem
}
function toBrandTaskView(item: BrandTaskItem): TaskItemView {
const total = Number(item.progress_total) || 0
const current = Number(item.progress_current) || 0
return {
key: `brand-${item.id}`,
title: (item.desc || '').trim() || '品牌检测',
taskId: item.id,
startedAt: formatDateTime(item.created_at),
finishedAt: isTerminal(item) ? formatDateTime(item.updated_at) : '',
statusText: statusText(item.status),
statusClass: statusClass(item.status),
extraLines: [
...(item.file_paths?.length ? [`文件:${item.file_paths.length}`] : []),
...(errorMessage(item) ? [`错误:${errorMessage(item)}`] : []),
],
progress: isRunning(item)
? {
percent: taskProgress(item),
stage: '任务进度',
countLabel: total > 0 ? `${current}/${total}` : undefined,
}
: null,
source: item,
}
}
function formatDateTime(value?: string) { function formatDateTime(value?: string) {
if (!value) return '-' if (!value) return '-'
const date = new Date(value) const date = new Date(value)
@@ -267,7 +309,7 @@ async function selectFolder() {
try { try {
const folder = await bridge.select_brand_folder() const folder = await bridge.select_brand_folder()
if (!folder) return if (!folder) return
const res = await requestPostJson<BrandExpandFolderPathsResponse>('/api/brand/expand-folder', { folder }) const res = (await expandBrandFolder(folder)) as BrandExpandFolderPathsResponse
if (!res.success || !res.paths?.length) { if (!res.success || !res.paths?.length) {
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件') ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
return return
@@ -433,6 +475,26 @@ onBeforeUnmount(() => {
pollTimer = null pollTimer = null
} }
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTask(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -466,29 +528,12 @@ onBeforeUnmount(() => {
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; } .btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; } .btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-msg { margin-top: 10px; } .loading-msg { margin-top: 10px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 600; } .panel-actions-row { display: flex; justify-content: flex-end; padding: 10px 20px 0; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; } .btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; } .download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 28px; text-align: center; } .download:hover { background: rgba(52, 152, 219, 0.28); }
.task-list { list-style: none; margin: 0; padding: 0; } .btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
.task-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 8px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; } .btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.files.error, .error { color: #ff6b6b; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.cancelled { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.act-btn { padding: 5px 10px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
.act-btn.danger { background: rgba(231, 76, 60, 0.15); color: #ff8f8f; border-color: transparent; }
.task-progress { margin-top: 6px; max-width: 460px; }
.task-progress-meta { display: flex; justify-content: space-between; color: #5e6878; font-size: 11px; margin-bottom: 4px; }
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
.task-progress-fill { height: 100%; background: #3498db; transition: width 0.25s ease; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; } .main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
@@ -102,10 +102,10 @@
:disabled="pushing || !lastTaskId" :disabled="pushing || !lastTaskId"
@click="pushToPythonQueue" @click="pushToPythonQueue"
> >
{{ pushing ? '推送中...' : '推送到 Python 队列' }} {{ pushing ? '启动中...' : '启动任务' }}
</button> </button>
</div> </div>
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务PENDING推送到 Python 队列将激活任务RUNNINGPython 端按 50 /页拉取明细执行采集</p> <p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务PENDING启动任务将激活任务RUNNINGPython 端按 50 /页拉取明细执行采集</p>
<div v-if="queuePushResult" class="queue-debug-card"> <div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div> <div class="section-title queue-debug-title">推送结果</div>
@@ -117,93 +117,26 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">采集数据</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="采集数据"
<div class="summary-card"> :cards="collectCards"
<span class="summary-label">运行中任务</span> :current-items="currentTaskViews"
<strong>{{ dashboard.pendingTaskCount }}</strong> :history-items="historyTaskViews"
</div> current-title="当前任务"
<div class="summary-card"> current-empty-text="暂无当前任务"
<span class="summary-label">已结束任务</span> history-empty-text="暂无历史记录"
<strong>{{ dashboard.processedTaskCount }}</strong> >
</div> <template #item-actions="{ item }">
<div class="summary-card"> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
<span class="summary-label">成功任务</span> </template>
<strong>{{ dashboard.successTaskCount }}</strong> <template #history-item-actions="{ item }">
</div> <button v-if="canDownload(itemSource(item))" type="button" class="download"
<div class="summary-card"> @click="downloadResult(itemSource(item))">下载</button>
<span class="summary-label">失败任务</span> <button v-if="itemSource(item).resultId" type="button" class="btn-delete"
<strong>{{ dashboard.failedTaskCount }}</strong> @click="deleteTaskRecord(itemSource(item))">删除</button>
</div> </template>
</div> </TaskCenterPanel>
<div class="subsection-title">匹配任务</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>当前任务</span>
</div>
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div class="files">总数据去重过滤{{ item.dedupeFilteredCount ?? 0 }}</div>
<div class="files">不符合 ASIN 过滤{{ item.invalidFilteredCount ?? 0 }}</div>
<div v-if="taskKeywordProgress(item)" class="files">{{ taskKeywordProgress(item) }}</div>
<div v-if="taskStageProgress(item)" class="files">{{ taskStageProgress(item) }}</div>
<div class="task-progress">
<div class="task-progress-meta"><span>关键词进度</span><span>{{ taskProgressPercent(item) }}%</span></div>
<div class="task-progress-track"><div class="task-progress-fill" :style="{ width: `${taskProgressPercent(item)}%` }"></div></div>
</div>
</div>
<div class="task-right">
<span class="status running">
{{ statusText(item) }}
<span v-if="taskFilterSummary(item)" class="status-filter-summary">{{ taskFilterSummary(item) }}</span>
</span>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>历史记录</span>
</div>
<div v-if="!historyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">总数据去重过滤{{ item.dedupeFilteredCount ?? 0 }}</div>
<div class="files">不符合 ASIN 过滤{{ item.invalidFilteredCount ?? 0 }}</div>
<div v-if="item.resultFilename" class="files">{{ item.resultFilename }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
<div class="task-progress">
<div class="task-progress-meta"><span>关键词进度</span><span>{{ taskProgressPercent(item) }}%</span></div>
<div class="task-progress-track"><div class="task-progress-fill" :style="{ width: `${taskProgressPercent(item)}%` }"></div></div>
</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">
{{ statusText(item) }}
<span v-if="taskFilterSummary(item)" class="status-filter-summary">{{ taskFilterSummary(item) }}</span>
</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -215,6 +148,8 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue' import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand' import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop' import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
@@ -518,7 +453,7 @@ async function pushToPythonQueue() {
} }
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.enqueue_json) { if (!api?.enqueue_json) {
ElMessage.warning('当前环境未提供 Python 队列能力') ElMessage.warning('当前环境未提供任务队列能力')
return return
} }
pushing.value = true pushing.value = true
@@ -549,12 +484,12 @@ async function pushToPythonQueue() {
if (!result?.success) { if (!result?.success) {
throw new Error(result?.error || '入队失败') throw new Error(result?.error || '入队失败')
} }
queuePushResult.value = `推送至 Python 队列(队列长度:${result.queue_size ?? '-'}` queuePushResult.value = `启动任务(队列长度:${result.queue_size ?? '-'}`
queuePayloadText.value = JSON.stringify(payload, null, 2) queuePayloadText.value = JSON.stringify(payload, null, 2)
if (lastTaskId.value) { if (lastTaskId.value) {
progressLoop.add(lastTaskId.value) progressLoop.add(lastTaskId.value)
} }
ElMessage.success('已推送到 Python 队列') ElMessage.success('已启动任务')
await loadDashboard() await loadDashboard()
} catch (error) { } catch (error) {
if (activated && lastTaskId.value) { if (activated && lastTaskId.value) {
@@ -746,6 +681,63 @@ async function deleteTaskRecord(item: CollectDataHistoryItem) {
} }
} }
const collectCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toCollectTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historyItems.value.map(toCollectTaskView))
function itemSource(item: TaskItemView): CollectDataHistoryItem {
return item.source as CollectDataHistoryItem
}
function isCollectTaskActive(item: CollectDataHistoryItem) {
const status = normalizeTaskStatus(item)
return status !== 'SUCCESS' && status !== 'FAILED' && !item.success
}
function toCollectTaskView(item: CollectDataHistoryItem): TaskItemView {
const isActive = isCollectTaskActive(item)
const keywordProgress = taskKeywordProgress(item)
const stageProgress = taskStageProgress(item)
const percent = taskProgressPercent(item)
const finishedAt = item.finishedAt ? formatDateTime(item.finishedAt) : isActive ? '进行中' : '-'
const extraLines: string[] = isActive
? [
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
`总数据去重过滤:${item.dedupeFilteredCount ?? 0}`,
`不符合 ASIN 过滤:${item.invalidFilteredCount ?? 0}`,
...(keywordProgress ? [keywordProgress] : []),
...(stageProgress ? [stageProgress] : []),
]
: [
`总数据去重过滤:${item.dedupeFilteredCount ?? 0}`,
`不符合 ASIN 过滤:${item.invalidFilteredCount ?? 0}`,
...(item.resultFilename ? [item.resultFilename] : []),
...(taskFilterSummary(item) ? [`筛选统计:${taskFilterSummary(item)}`] : []),
...(item.error ? [`错误:${item.error}`] : []),
]
return {
key: `collect-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '采集数据',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt,
statusText: statusText(item),
statusClass: statusClass(item),
extraLines,
progress: isActive || percent > 0
? { percent, stage: '关键词进度' }
: null,
source: item,
}
}
onMounted(() => { onMounted(() => {
void loadCountryPreference() void loadCountryPreference()
void loadDashboard() void loadDashboard()
@@ -759,6 +751,26 @@ onBeforeUnmount(() => {
} }
timers.clearScope() timers.clearScope()
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -766,7 +778,7 @@ onBeforeUnmount(() => {
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); } .main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; } .left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; } .right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.section-title, .subsection-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; } .section-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #2e3a52; margin-bottom: 18px; } .upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #2e3a52; margin-bottom: 18px; }
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; } .hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; } .btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
@@ -831,35 +843,10 @@ onBeforeUnmount(() => {
.queue-debug-line { color: #9ad; font-size: 12px; } .queue-debug-line { color: #9ad; font-size: 12px; }
.queue-debug-payload { margin-top: 8px; padding: 8px 10px; background: #151a25; border-radius: 6px; color: #a0acbe; font-size: 11px; overflow: auto; max-height: 200px; } .queue-debug-payload { margin-top: 8px; padding: 8px 10px; background: #151a25; border-radius: 6px; color: #a0acbe; font-size: 11px; overflow: auto; max-height: 200px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; font-size: 15px; font-weight: 600; color: #c8d2e2; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
.summary-label { color: #5e6878; font-size: 12px; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; min-height: 180px; margin: 0 0 16px; }
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 18px; text-align: center; }
.task-list { list-style: none; margin: 0; padding: 12px; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
.status.pending { background: rgba(149, 165, 166, .18); color: #a0acbe; }
.status-filter-summary { font-weight: 400; }
.download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); } .download { padding: 6px 10px; color: #c8d2e2; background: rgba(52, 152, 219, .18); }
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); } .btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
.task-progress { margin-top: 8px; max-width: 520px; }
.task-progress-meta { display: flex; justify-content: space-between; color: #5e6878; font-size: 11px; margin-bottom: 4px; }
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
.task-progress-fill { height: 100%; border-radius: inherit; background: #3498db; transition: width .25s ease; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; } .main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
</style> </style>
@@ -111,59 +111,37 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">转换结果</div> <TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
<div class="task-list-wrap"> title="转换结果"
<div class="clean-result-summary"> :cards="convertCards"
<div class="summary-card"> :current-items="currentTaskViews"
<span class="summary-label">已处理文件</span> :history-items="historyTaskViews"
<strong>{{ convertSummary.total }}</strong> current-title="当前任务"
</div> current-empty-text="暂无当前任务,完成格式转换后会在这里展示"
<div class="summary-card"> history-empty-text="暂无历史记录"
<span class="summary-label">成功结果</span> >
<strong>{{ convertSummary.successCount }}</strong> <template #history-item-actions="{ item }">
</div> <template v-if="itemSource(item)">
<div class="summary-card"> <button
<span class="summary-label">失败文件</span> v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
<strong>{{ convertSummary.failedCount }}</strong> type="button"
</div> class="download"
</div> @click="downloadConvertResult(itemSource(item))"
>
<div class="result-list-wrap"> 下载压缩包
<div class="result-list-header"> </button>
<span>生成的结果压缩包列表</span> <button
</div> v-if="itemSource(item).resultId"
type="button"
<div v-if="convertResultItems.length === 0" class="empty-tasks"> class="btn-delete"
暂无转换结果完成格式转换后会在这里展示结果压缩包 @click="deleteConvertHistoryRecord(itemSource(item).resultId!)"
</div> >
删除
<ul v-else class="task-list clean-result-list"> </button>
<li v-for="item in convertResultItems" </template>
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item"> </template>
<div class="left"> </TaskCenterPanel>
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">压缩包文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
@click="downloadConvertResult(item)">
下载压缩包
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteConvertHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -174,6 +152,8 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { expandBrandFolderRecursive, getBrandTemplateXlsxUrl, getBrandTemplateZipUrl } from '@/shared/api/brand' import { expandBrandFolderRecursive, getBrandTemplateXlsxUrl, getBrandTemplateZipUrl } from '@/shared/api/brand'
import type { BrandExpandFolderItem } from '@/shared/api/brand' import type { BrandExpandFolderItem } from '@/shared/api/brand'
import { import {
@@ -186,7 +166,6 @@ import {
runConvert, runConvert,
setDefaultConvertTemplate, setDefaultConvertTemplate,
type ConvertResultItem, type ConvertResultItem,
type ConvertRunVo,
type ConvertTemplateVo, type ConvertTemplateVo,
} from '@/shared/api/java-modules' } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
@@ -199,7 +178,6 @@ const convertArchiveName = ref('')
const convertUploadedFiles = ref<UploadedJavaFile[]>([]) const convertUploadedFiles = ref<UploadedJavaFile[]>([])
const convertRunning = ref(false) const convertRunning = ref(false)
const convertResultItems = ref<ConvertResultItem[]>([]) const convertResultItems = ref<ConvertResultItem[]>([])
const convertSummary = ref<ConvertRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const convertTemplates = ref<ConvertTemplateVo[]>([]) const convertTemplates = ref<ConvertTemplateVo[]>([])
const convertTemplateId = ref('') const convertTemplateId = ref('')
const templateUploading = ref(false) const templateUploading = ref(false)
@@ -215,6 +193,77 @@ const canDeleteCurrentTemplate = computed(
() => Boolean(currentConvertTemplate.value && !currentConvertTemplate.value.builtIn), () => Boolean(currentConvertTemplate.value && !currentConvertTemplate.value.builtIn),
) )
// ---- TaskCenterPanel----
const convertCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function isConvertTaskBusy(item: ConvertResultItem) {
const status = (item.taskStatus || '').toUpperCase()
return status === 'RUNNING' || status === 'PENDING'
}
/** 当前任务:运行中/待执行项(转换接口通常同步完成即出结果,正常为空) */
const currentTaskViews = computed<TaskItemView[]>(() =>
convertResultItems.value.filter(isConvertTaskBusy).map(toConvertTaskView),
)
/** 历史任务:全部已结束的结果项 */
const historyTaskViews = computed<TaskItemView[]>(() =>
convertResultItems.value.filter((item) => !isConvertTaskBusy(item)).map(toConvertTaskView),
)
function itemSource(item: TaskItemView): ConvertResultItem {
return item.source as ConvertResultItem
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function convertTaskStatusText(item: ConvertResultItem) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING') return '执行中'
if (status === 'PENDING') return '等待中'
return item.success ? '已完成' : '失败'
}
function convertTaskStatusClass(item: ConvertResultItem) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING') return 'running'
if (status === 'PENDING') return 'pending'
return item.success ? 'success' : 'failed'
}
function toConvertTaskView(item: ConvertResultItem): TaskItemView {
return {
key: `convert-${item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '格式转换',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: convertTaskStatusText(item),
statusClass: convertTaskStatusClass(item),
extraLines: [
...(item.outputFilename ? [`压缩包文件:${item.outputFilename}`] : []),
...(item.error ? [`错误信息:${item.error}`] : []),
],
source: item,
}
}
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) { async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.upload_file_to_java) { if (!api?.upload_file_to_java) {
@@ -458,7 +507,6 @@ async function submitConvertRun() {
? convertArchiveName.value ? convertArchiveName.value
: undefined, : undefined,
}) })
convertSummary.value = result
convertResultItems.value = result.items || [] convertResultItems.value = result.items || []
await loadConvertHistory() await loadConvertHistory()
if (result.total > 0 && result.successCount === 0) { if (result.total > 0 && result.successCount === 0) {
@@ -485,12 +533,6 @@ async function loadConvertHistory() {
try { try {
const response = await getConvertHistory() const response = await getConvertHistory()
convertResultItems.value = response.items || [] convertResultItems.value = response.items || []
convertSummary.value = {
total: response.items?.length || 0,
successCount: response.items?.filter((item) => item.success).length || 0,
failedCount: response.items?.filter((item) => !item.success).length || 0,
items: response.items || [],
}
} catch { } catch {
// ignore history load errors // ignore history load errors
} }
@@ -526,6 +568,26 @@ onMounted(() => {
loadConvertTemplates().catch(() => undefined) loadConvertTemplates().catch(() => undefined)
loadConvertHistory().catch(() => undefined) loadConvertHistory().catch(() => undefined)
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteConvertHistoryRecord(itemSource(view).resultId!)
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -832,81 +894,6 @@ onMounted(() => {
font-size: 13px; font-size: 13px;
} }
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.task-list-wrap {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 10px;
background: #1c2333;
}
.left {
flex: 1;
min-width: 0;
}
.id {
display: inline-block;
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
margin-top: 4px;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.download { .download {
padding: 6px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
@@ -931,60 +918,16 @@ onMounted(() => {
background: rgba(231, 76, 60, 0.22); background: rgba(231, 76, 60, 0.22);
} }
.empty-tasks { .files {
font-size: 12px;
color: #5e6878; color: #5e6878;
font-size: 13px; margin-top: 4px;
padding: 24px 8px;
} }
.clean-placeholder { .clean-placeholder {
max-height: none; max-height: none;
} }
.clean-result-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 220px));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 16px 18px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { .main-content {
flex-direction: column; flex-direction: column;
@@ -1000,19 +943,5 @@ onMounted(() => {
.right-panel { .right-panel {
min-height: 420px; min-height: 420px;
} }
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
} }
</style> </style>
@@ -85,59 +85,29 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">去重结果</div> <TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
<div class="task-list-wrap"> title="去重结果"
<div class="clean-result-summary"> :cards="dedupeCards"
<div class="summary-card"> :current-items="currentTaskViews"
<span class="summary-label">已处理文件</span> :history-items="historyTaskViews"
<strong>{{ cleanSummary.total }}</strong> current-title="当前任务"
</div> current-empty-text="暂无当前任务,完成数据去重后会在这里展示"
<div class="summary-card"> history-empty-text="暂无历史记录"
<span class="summary-label">成功结果</span> >
<strong>{{ cleanSummary.successCount }}</strong> <template #item-actions="{ item }">
</div> <template v-if="itemSource(item)">
<div class="summary-card"> <button v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
<span class="summary-label">失败文件</span> type="button" class="download" @click="downloadCleanResult(itemSource(item))">
<strong>{{ cleanSummary.failedCount }}</strong> 下载文件
</div> </button>
</div> <button v-if="itemSource(item).resultId" type="button" class="btn-delete"
@click="deleteCleanHistoryRecord(itemSource(item).resultId!)">
<div class="result-list-wrap"> 删除
<div class="result-list-header"> </button>
<span>处理后的 Excel 列表</span> </template>
</div> </template>
</TaskCenterPanel>
<div v-if="cleanResultItems.length === 0" class="empty-tasks">
暂无去重结果完成数据去重后会在这里展示输出文件
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in cleanResultItems"
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item">
<div class="left">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">输出文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
@click="downloadCleanResult(item)">
下载文件
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteCleanHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -148,6 +118,8 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand' import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules' import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
@@ -168,6 +140,88 @@ const cleanProgressProcessed = ref(0)
const cleanResultItems = ref<DedupeResultItem[]>([]) const cleanResultItems = ref<DedupeResultItem[]>([])
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] }) const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8)) const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
// ""/ID/
const latestRunId = ref('')
const cleanRunStartedAt = ref('')
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function itemSource(item: TaskItemView): DedupeResultItem {
return item.source as DedupeResultItem
}
const dedupeCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: cleanSummary.value.total },
{ label: '成功任务', value: cleanSummary.value.successCount },
{ label: '失败任务', value: cleanSummary.value.failedCount },
])
/** 当前任务:运行中的去重 run(占位卡) + 历史列表中非终态任务 */
const currentTaskViews = computed<TaskItemView[]>(() => {
const views: TaskItemView[] = []
if (cleanRunning.value && latestRunId.value) {
views.push({
key: `run-${latestRunId.value}`,
title: cleanArchiveName.value || '数据去重',
taskId: latestRunId.value,
startedAt: cleanRunStartedAt.value,
finishedAt: '',
statusText: '执行中',
statusClass: 'running',
progress: {
percent: cleanSummary.value.total
? Math.round((cleanProgressProcessed.value / cleanSummary.value.total) * 100)
: 0,
stage: `正在处理文件 ${cleanProgressProcessed.value}/${cleanSummary.value.total}`,
},
})
}
for (const item of cleanResultItems.value) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING' || status === 'PENDING') {
views.push(toDedupeTaskView(item))
}
}
return views
})
const historyTaskViews = computed<TaskItemView[]>(() =>
cleanResultItems.value
.filter((item) => {
const status = (item.taskStatus || '').toUpperCase()
return status !== 'RUNNING' && status !== 'PENDING'
})
.map(toDedupeTaskView),
)
function toDedupeTaskView(item: DedupeResultItem): TaskItemView {
return {
key: `dedupe-${item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: item.success ? '已完成' : '失败',
statusClass: item.success ? 'success' : 'failed',
extraLines: [
...(item.outputFilename ? [`输出文件:${item.outputFilename}`] : []),
...(item.error ? [`错误信息:${item.error}`] : []),
],
source: item,
}
}
function selectAllCleanColumns() { function selectAllCleanColumns() {
cleanSelectedColumns.value = [...cleanAvailableColumns.value] cleanSelectedColumns.value = [...cleanAvailableColumns.value]
@@ -292,6 +346,8 @@ async function submitCleanRun() {
try { try {
cleanRunning.value = true cleanRunning.value = true
latestRunId.value = ''
cleanRunStartedAt.value = formatDateTime(new Date().toISOString())
const progress = await runDedupe({ const progress = await runDedupe({
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })), files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
selectedColumns: cleanSelectedColumns.value, selectedColumns: cleanSelectedColumns.value,
@@ -312,6 +368,7 @@ async function submitCleanRun() {
items: cleanResultItems.value, items: cleanResultItems.value,
} }
}) })
latestRunId.value = progress.runId
cleanSummary.value = result cleanSummary.value = result
cleanResultItems.value = result.items || [] cleanResultItems.value = result.items || []
await loadCleanHistory() await loadCleanHistory()
@@ -410,6 +467,26 @@ async function downloadCleanResult(item: DedupeResultItem) {
onMounted(() => { onMounted(() => {
loadCleanHistory().catch(() => undefined) loadCleanHistory().catch(() => undefined)
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteCleanHistoryRecord(itemSource(view).resultId!)
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -666,81 +743,6 @@ onMounted(() => {
font-size: 13px; font-size: 13px;
} }
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.task-list-wrap {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 10px;
background: #1c2333;
}
.left {
flex: 1;
min-width: 0;
}
.id {
display: inline-block;
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
margin-top: 4px;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.download { .download {
padding: 6px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
@@ -775,50 +777,6 @@ onMounted(() => {
max-height: none; max-height: none;
} }
.clean-result-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 220px));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 16px 18px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { .main-content {
flex-direction: column; flex-direction: column;
@@ -834,19 +792,5 @@ onMounted(() => {
.right-panel { .right-panel {
min-height: 420px; min-height: 420px;
} }
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
} }
</style> </style>
@@ -58,7 +58,7 @@
</button> </button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length" <button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length"
@click="pushToPythonQueue"> @click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }} {{ pushing ? '启动中...' : '启动任务' }}
</button> </button>
<span class="loading-msg"> <span class="loading-msg">
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }} {{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
@@ -66,7 +66,7 @@
</div> </div>
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card"> <div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
<div class="section-title queue-debug-title">Python 队列推送结果</div> <div class="section-title queue-debug-title">任务启动结果</div>
<div v-if="queuePushResult" class="queue-debug-line"> <div v-if="queuePushResult" class="queue-debug-line">
{{ queuePushResult }} {{ queuePushResult }}
</div> </div>
@@ -77,135 +77,29 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">删除品牌结果</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="删除品牌结果"
<div class="summary-card"> :cards="deleteBrandCards"
<span class="summary-label">已处理文件</span> :current-items="currentTaskViews"
<strong>{{ summary.total }}</strong> :history-items="historyTaskViews"
</div> current-title="当前任务"
<div class="summary-card"> current-empty-text="暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果"
<span class="summary-label">成功结果</span> history-empty-text="暂无历史记录"
<strong>{{ summary.successCount }}</strong> >
</div> <template #item-actions="{ item }">
<div class="summary-card"> <button v-if="canDownloadTaskResult(itemSource(item))" type="button" class="download"
<span class="summary-label">失败文件</span> @click="downloadTaskResult(itemSource(item))">下载</button>
<strong>{{ summary.failedCount }}</strong> <button v-if="itemSource(item).resultId" type="button" class="btn-delete"
</div> @click="deleteHistoryRecord(itemSource(item).resultId!)">删除</button>
</div> </template>
<template #history-item-actions="{ item }">
<div class="result-list-wrap"> <button v-if="canDownloadTaskResult(itemSource(item))" type="button" class="download"
<div class="result-list-header"> @click="downloadTaskResult(itemSource(item))">下载</button>
<span>删除品牌结果列表</span> <button v-if="itemSource(item).resultId" type="button" class="btn-delete"
</div> @click="deleteHistoryRecord(itemSource(item).resultId!)">删除</button>
</template>
<div v-if="!hasVisibleItems" class="empty-tasks"> </TaskCenterPanel>
暂无删除品牌结果完成解析后会在这里展示按国家分组的去重结果
</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list clean-result-list">
<li v-for="item in currentSectionItems"
:key="`current-${item.taskId || item.resultId || item.sourceFilename}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ formatMatchResult(item) }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="getQueueStatus(item)" class="files">队列状态{{ getQueueStatus(item) }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount
}}</div>
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }}
</div>
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
<div class="delete-brand-progress-header">
<span>任务进度</span>
<span>{{ formatProgressPercent(item) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill"
:style="{ width: `${formatProgressPercent(item)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item) }}</div>
</div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }}
</div>
<div v-if="getDisplayError(item)" class="files">错误信息{{ getDisplayError(item) }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="getTaskStatusInfo(item).className">
{{ getTaskStatusInfo(item).text }}
</span>
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
@click="downloadTaskResult(item)">
下载
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史记录</div>
<ul class="task-list clean-result-list">
<li v-for="item in historySectionItems"
:key="`history-${item.taskId || item.resultId || item.sourceFilename}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ formatMatchResult(item) }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount
}}</div>
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }}
</div>
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
<div class="delete-brand-progress-header">
<span>任务进度</span>
<span>{{ formatProgressPercent(item) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill"
:style="{ width: `${formatProgressPercent(item)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item) }}</div>
</div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }}
</div>
<div v-if="getDisplayError(item)" class="files">错误信息{{ getDisplayError(item) }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="getTaskStatusInfo(item).className">
{{ getTaskStatusInfo(item).text }}
</span>
<!-- 历史记录区不再显示打开紫鸟按钮 -->
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
@click="downloadTaskResult(item)">
下载
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -217,6 +111,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue' import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue' import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand' import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { import {
@@ -403,11 +299,78 @@ const historySectionItems = computed(() =>
(item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item)) (item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item))
), ),
); );
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
const queueRunnableItems = computed(() => const queueRunnableItems = computed(() =>
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未入队'), currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未入队'),
) )
const deleteBrandCards = computed<TaskStatCard[]>(() => {
const historyRows = historySectionItems.value
let successCount = 0
let failedCount = 0
for (const item of historyRows) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'SUCCESS') {
successCount++
} else if (status === 'FAILED') {
failedCount++
} else if (item.success === true) {
successCount++
} else {
failedCount++
}
}
return [
{ label: '运行中任务', value: currentSectionItems.value.length },
{ label: '已结束任务', value: historyRows.length },
{ label: '成功任务', value: successCount },
{ label: '失败任务', value: failedCount },
]
})
const currentTaskViews = computed<TaskItemView[]>(() =>
currentSectionItems.value.map(toDeleteBrandTaskView),
)
const historyTaskViews = computed<TaskItemView[]>(() =>
historySectionItems.value.map(toDeleteBrandTaskView),
)
function itemSource(item: TaskItemView): DeleteBrandResultItem {
return item.source as DeleteBrandResultItem
}
function toDeleteBrandTaskView(item: DeleteBrandResultItem): TaskItemView {
const statusInfo = getTaskStatusInfo(item)
const showProgress = Boolean(shouldShowProgress(item))
const queueStatus = getQueueStatus(item)
const taskStatus = (item.taskStatus || '').toUpperCase()
const detailStatus = (getTaskStatus(item.taskId) || '').toUpperCase()
const isTerminal = isTerminalStatus(taskStatus) || isTerminalStatus(detailStatus)
const extraLines: string[] = [
`店铺名:${item.shopName || '-'}`,
`匹配结果:${formatMatchResult(item)}`,
...(item.platform ? [`平台:${item.platform}`] : []),
...(!isTerminal && queueStatus ? [`队列状态:${queueStatus}`] : []),
...(item.countryCount !== undefined && item.matched ? [`国家数:${item.countryCount}`] : []),
...(item.totalRows !== undefined && item.matched ? [`去重后 ${item.totalRows}`] : []),
...(showProgress && formatProgress(item) ? [formatProgress(item)] : []),
...(item.previewRows?.length ? [formatPreview(item.previewRows)] : []),
...(getDisplayError(item) ? [`错误信息:${getDisplayError(item)}`] : []),
]
return {
key: `delete-brand-${item.taskId ?? ''}-${item.resultId ?? item.fileKey ?? item.sourceFilename}`,
title: item.sourceFilename || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(taskCreatedAt(item)),
finishedAt: taskFinishedAt(item) ? formatDateTime(taskFinishedAt(item)) : '进行中',
statusText: statusInfo.text,
statusClass: statusInfo.className as TaskItemView['statusClass'],
extraLines,
progress: showProgress ? { percent: formatProgressPercent(item), stage: '任务进度' } : null,
source: item,
}
}
function persistSessionTasks() { function persistSessionTasks() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
if (sessionTasks.value.length === 0) window.localStorage.removeItem(getStorageKey()) if (sessionTasks.value.length === 0) window.localStorage.removeItem(getStorageKey())
@@ -560,6 +523,32 @@ function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
}) })
} }
/** 任务卡开始时间(任务详情快照的创建时间) */
function taskCreatedAt(item: DeleteBrandResultItem) {
if (!item.taskId) return ''
return taskDetails.value[item.taskId]?.task?.createdAt ?? ''
}
/** 任务卡结束时间(任务详情快照的完成时间) */
function taskFinishedAt(item: DeleteBrandResultItem) {
if (!item.taskId) return ''
return taskDetails.value[item.taskId]?.task?.finishedAt ?? ''
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
}
function formatMatchResult(item: DeleteBrandResultItem) { function formatMatchResult(item: DeleteBrandResultItem) {
if (item.matchStatus === 'INDEX_STALE' && isUsableMatchedItem(item)) { if (item.matchStatus === 'INDEX_STALE' && isUsableMatchedItem(item)) {
return `已匹配 ${item.shopId || ''}(索引过期,可继续推送)`.trim() return `已匹配 ${item.shopId || ''}(索引过期,可继续推送)`.trim()
@@ -1135,7 +1124,7 @@ async function submitRun() {
const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item)) const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item))
queuePushResult.value = hasRunnableItems queuePushResult.value = hasRunnableItems
? '解析完成,可在左侧点击“推送到 Python 队列”开始串行处理' ? '解析完成,可在左侧点击“启动任务”开始串行处理'
: '解析完成,当前没有可推送的文件' : '解析完成,当前没有可推送的文件'
queuePayloadText.value = '' queuePayloadText.value = ''
@@ -1147,9 +1136,9 @@ async function submitRun() {
if (hasBlockedItems) { if (hasBlockedItems) {
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。') ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
} else if (hasStaleMatchedItems) { } else if (hasStaleMatchedItems) {
ElMessage.warning('部分文件命中的是过期索引,仍可推送到 Python 队列;后台刷新后会自动重新匹配。') ElMessage.warning('部分文件命中的是过期索引,仍可启动任务;后台刷新后会自动重新匹配。')
} else if (hasRunnableItems) { } else if (hasRunnableItems) {
ElMessage.success('删除品牌解析完成,请在左侧推送到 Python 队列') ElMessage.success('删除品牌解析完成,请在左侧启动任务')
} else { } else {
ElMessage.warning('当前没有可推送的文件,请先等待店铺索引刷新。') ElMessage.warning('当前没有可推送的文件,请先等待店铺索引刷新。')
} }
@@ -1379,7 +1368,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
return { success: true, retryable: false } return { success: true, retryable: false }
} else { } else {
const message = pushResult?.error || '未知错误' const message = pushResult?.error || '未知错误'
qpr = `推送到 Python 队列失败:${message}` qpr = `启动任务失败:${message}`
queuePushResult.value = qpr queuePushResult.value = qpr
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value) saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
if (options?.auto && isQueueBusyError(message)) { if (options?.auto && isQueueBusyError(message)) {
@@ -1407,7 +1396,7 @@ async function pushToPythonQueue() {
activeItemKey.value = '' activeItemKey.value = ''
return return
} }
ElMessage.success('已开始按顺序推送到 Python 队列') ElMessage.success('已开始按顺序启动任务')
} }
async function maybeAutoAdvance() { async function maybeAutoAdvance() {
@@ -1535,6 +1524,26 @@ onUnmounted(() => {
timers.clearScope() timers.clearScope()
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteHistoryRecord(itemSource(view).resultId!)
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1754,154 +1763,12 @@ onUnmounted(() => {
font-size: 13px; font-size: 13px;
} }
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.task-list-wrap {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 10px;
background: #1c2333;
}
.split-result-item {
align-items: flex-start;
}
.left {
flex: 1;
min-width: 0;
}
.split-result-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.id {
display: inline-block;
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files { .files {
font-size: 12px; font-size: 12px;
color: #5e6878; color: #5e6878;
margin-top: 4px; margin-top: 4px;
} }
.time {
font-size: 12px;
color: #5e6878;
margin-top: 2px;
}
.split-entry-list {
line-height: 1.6;
word-break: break-all;
max-width: 100%;
}
.delete-brand-preview {
white-space: normal;
}
.delete-brand-progress-block {
margin-top: 8px;
}
.delete-brand-progress-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
color: #8fbfff;
}
.delete-brand-progress-bar {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.delete-brand-progress-bar-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2d8cf0 0%, #4db3ff 100%);
transition: width 0.3s ease;
}
.delete-brand-progress {
color: #69b6ff;
line-height: 1.6;
white-space: normal;
margin-top: 6px;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.split-result-actions {
flex-shrink: 0;
min-width: 180px;
justify-content: flex-end;
align-self: center;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.status.running {
background: rgba(52, 152, 219, 0.18);
color: #3498db;
}
.download { .download {
padding: 6px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
@@ -1926,60 +1793,10 @@ onUnmounted(() => {
background: rgba(231, 76, 60, 0.22); background: rgba(231, 76, 60, 0.22);
} }
.empty-tasks {
color: #5e6878;
font-size: 13px;
padding: 24px 8px;
}
.clean-placeholder { .clean-placeholder {
max-height: 112px; max-height: 112px;
} }
.clean-result-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 220px));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 16px 18px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { .main-content {
flex-direction: column; flex-direction: column;
@@ -1995,25 +1812,6 @@ onUnmounted(() => {
.right-panel { .right-panel {
min-height: 420px; min-height: 420px;
} }
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.split-result-actions {
min-width: 0;
align-self: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
} }
</style> </style>
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div> <div class="section-title">店铺输入</div>
<div class="input-zone"> <div class="input-zone">
<div class="hint"> <div class="hint">
左侧负责录入店铺并加入备选区点击匹配店铺只会生成匹配结果确认后需手动点击推送到 Python 队列才会创建并执行任务任务只会跑下方勾选的国家 左侧负责录入店铺并加入备选区点击匹配店铺只会生成匹配结果确认后需手动点击启动任务才会创建并执行任务任务只会跑下方勾选的国家
</div> </div>
<div class="input-row"> <div class="input-row">
<el-input <el-input
@@ -136,7 +136,7 @@
:disabled="pushing || !matchedRunnableItems.length" :disabled="pushing || !matchedRunnableItems.length"
@click="pushToPythonQueue" @click="pushToPythonQueue"
> >
{{ pushing ? "任务执行中..." : "推送到 Python 队列" }} {{ pushing ? "任务执行中..." : "启动任务" }}
</button> </button>
</div> </div>
@@ -154,215 +154,85 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">匹配与任务</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="匹配与任务"
<div class="summary-card"> :cards="patrolCards"
<span class="summary-label">备选店铺</span> :current-items="currentTaskViews"
<strong>{{ dashboard.candidateCount }}</strong> :history-items="historyTaskViews"
current-empty-text="暂无当前任务。启动任务后,正在执行的任务会展示在这里,历史任务可在右上角「历史任务」中查看"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">
完成匹配店铺这里会展示匹配结果
</div> </div>
<div class="summary-card"> <el-table
<span class="summary-label">已处理任务</span> v-else
<strong>{{ dashboard.processedTaskCount }}</strong> :data="matchedItems"
</div> :row-key="rowKeyForMatch"
<div class="summary-card"> :highlight-current-row="false"
<span class="summary-label">成功任务</span> class="result-table match-table"
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">
完成匹配店铺这里会展示匹配结果
</div>
<el-table
v-else
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
删除
</button>
</template>
</el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header">
<span>任务记录</span>
</div>
<div
v-if="!currentSectionItems.length && !historySectionItems.length"
class="empty-tasks"
> >
暂无任务记录推送到 Python 队列后这里会展示当前任务和历史任务 <el-table-column prop="shopName" label="店铺名" min-width="120" />
</div> <el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<template v-else> <span :class="row.matched ? 'ok' : 'fail'">
<div v-if="currentSectionItems.length" class="result-subsection"> {{ row.matched ? "是" : "否" }}
<div class="result-subsection-title">当前任务</div> </span>
<ul class="task-list"> </template>
<li </el-table-column>
v-for="item in currentSectionItems" <el-table-column
:key="taskGroupKey(item)" prop="shopId"
class="task-item" label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
> >
<div class="left split-result-main"> 删除
<span class="id" :title="formatTaskGroupShopNames(item)"> </button>
{{ formatTaskGroupShopNames(item) }} </template>
</span> </el-table-column>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div> </el-table>
<div v-if="formatTaskGroupResultIds(item)" class="files"> </template>
结果 ID: {{ formatTaskGroupResultIds(item) }} <template #item-actions="{ item }">
</div> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
<div v-if="formatTaskGroupShopIds(item)" class="files"> </template>
店铺 ID: {{ formatTaskGroupShopIds(item) }} <template #history-item-actions="{ item }">
</div> <button v-if="canDownload(itemSource(item))" type="button" class="download"
<div v-if="item.platform" class="files"> @click="downloadResult(itemSource(item))">下载结果</button>
平台: {{ item.platform }} <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</div> </template>
<div class="files"> </TaskCenterPanel>
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div class="files">
模板: {{ formatTemplateSummary(item) }}
</div>
<div v-if="formatTaskGroupErrors(item)" class="files">
错误: {{ formatTaskGroupErrors(item) }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史任务</div>
<ul class="task-list">
<li
v-for="item in historySectionItems"
:key="taskGroupKey(item)"
class="task-item"
>
<div class="left split-result-main">
<span class="id" :title="formatTaskGroupShopNames(item)">
{{ formatTaskGroupShopNames(item) }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="formatTaskGroupResultIds(item)" class="files">
结果 ID: {{ formatTaskGroupResultIds(item) }}
</div>
<div v-if="formatTaskGroupShopIds(item)" class="files">
店铺 ID: {{ formatTaskGroupShopIds(item) }}
</div>
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div v-if="item.finishedAt" class="files">
完成时间: {{ formatDateTime(item.finishedAt) }}
</div>
<div class="files">
模板: {{ formatTemplateSummary(item) }}
</div>
<div v-if="formatTaskGroupErrors(item)" class="files">
错误: {{ formatTaskGroupErrors(item) }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
v-if="canDownload(item)"
type="button"
class="archive"
@click="downloadResult(item)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -373,6 +243,8 @@
import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from "@/shared/components/tasks/TaskCenterPanel.vue";
import type { TaskItemView, TaskStatCard } from "@/shared/components/tasks/types";
import { import {
addPatrolDeleteCandidate, addPatrolDeleteCandidate,
addPatrolDeleteCondition, addPatrolDeleteCondition,
@@ -456,6 +328,48 @@ const currentSectionItems = computed(() =>
const historySectionItems = computed(() => const historySectionItems = computed(() =>
taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)), taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
); );
/** 统一统计卡:运行中/已结束按页面当前分区统计,成功/失败按已结束任务的 taskStatus 统计 */
const patrolCards = computed<TaskStatCard[]>(() => {
const ended = historySectionItems.value
return [
{ label: '运行中任务', value: currentSectionItems.value.length },
{ label: '已结束任务', value: ended.length },
{
label: '成功任务',
value: ended.filter((item) => item.taskStatus === 'SUCCESS' || item.taskStatus === 'COMPLETED').length,
},
{ label: '失败任务', value: ended.filter((item) => item.taskStatus === 'FAILED').length },
]
})
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toPatrolTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toPatrolTaskView))
function itemSource(item: TaskItemView) {
return item.source as PatrolDeleteHistoryItem
}
function toPatrolTaskView(item: PatrolDeleteHistoryItem): TaskItemView {
return {
key: taskGroupKey(item),
title: formatTaskGroupShopNames(item),
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.createdAt),
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : '进行中',
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: [
...(formatTaskGroupResultIds(item) ? [`结果 ID: ${formatTaskGroupResultIds(item)}`] : []),
...(formatTaskGroupShopIds(item) ? [`店铺 ID: ${formatTaskGroupShopIds(item)}`] : []),
...(item.platform ? [`平台: ${item.platform}`] : []),
`模板: ${formatTemplateSummary(item)}`,
...(formatTaskGroupErrors(item) ? [`错误: ${formatTaskGroupErrors(item)}`] : []),
],
source: item,
}
}
const hasQueueWork = computed( const hasQueueWork = computed(
() => () =>
queueWorkerRunning.value || queueWorkerRunning.value ||
@@ -571,7 +485,7 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
const message = (row.matchMessage || "").trim(); const message = (row.matchMessage || "").trim();
if (message) return message; if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") { if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可推送到 Python 队列"; return "索引已命中,可启动任务";
} }
if (row.matched) { if (row.matched) {
return "已命中索引,请结合状态列确认"; return "已命中索引,请结合状态列确认";
@@ -1320,6 +1234,26 @@ onUnmounted(() => {
clearSleepTimers(); clearSleepTimers();
timers.clearScope(); timers.clearScope();
}); });
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1363,42 +1297,19 @@ onUnmounted(() => {
.queue-debug-title { margin-bottom: 8px; } .queue-debug-title { margin-bottom: 8px; }
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; } .queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; } .queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
.summary-label { font-size: 12px; color: #5e6878; }
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; } .subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; } .match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.empty-tasks.narrow { padding: 12px 8px; }
.match-table { margin-bottom: 18px; } .match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; } .match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; } .result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; } .ok { color: #27ae60; }
.fail { color: #e67e22; } .fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; } .download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; } .download:hover { background: rgba(52, 152, 219, 0.28); }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
.files { font-size: 12px; color: #5e6878; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.archive:hover { background: rgba(52, 152, 219, 0.28); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; } .btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); } .btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; } .main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
</style> </style>
@@ -126,7 +126,7 @@
{{ matching ? '匹配中…' : '匹配店铺' }} {{ matching ? '匹配中…' : '匹配店铺' }}
</button> </button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue"> <button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
{{ pushing ? '推送中…' : '推送到 Python 队列' }} {{ pushing ? '启动中…' : '启动任务' }}
</button> </button>
</div> </div>
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>跟价结果合并输出全量和自定义的区别仅在于ASIN来源不同</p> <p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>跟价结果合并输出全量和自定义的区别仅在于ASIN来源不同</p>
@@ -152,109 +152,53 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">匹配与任务</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary pr-summary"> title="匹配与任务"
<div class="summary-card"> :cards="priceTrackCards"
<span class="summary-label">备选店铺数</span> :current-items="currentTaskViews"
<strong>{{ dashboard.candidateCount }}</strong> :history-items="historyTaskViews"
</div> current-empty-text="暂无当前任务。完成「匹配店铺」并启动任务后,将在此展示处理进度"
<div class="summary-card"> history-empty-text="暂无历史记录"
<span class="summary-label">已结束任务</span> >
<strong>{{ dashboard.processedTaskCount }}</strong> <template #cards-extra>
</div> <div class="subsection-title">匹配结果</div>
<div class="summary-card"> <div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<span class="summary-label">成功任务</span> <el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
<strong>{{ dashboard.successTaskCount }}</strong> class="result-table match-table">
</div> <el-table-column prop="shopName" label="店铺名" min-width="100" />
<div class="summary-card"> <el-table-column label="匹配" width="72" align="center">
<span class="summary-label">失败任务</span> <template #default="{ row }">
<strong>{{ dashboard.failedTaskCount }}</strong> <span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
</div> </template>
</div> </el-table-column>
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<div class="subsection-title">匹配结果</div> <el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div> <el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" <el-table-column label="状态" width="100" show-overflow-tooltip>
class="result-table match-table"> <template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
<el-table-column prop="shopName" label="店铺名" min-width="100" /> </el-table-column>
<el-table-column label="匹配" width="72" align="center"> <el-table-column label="说明" min-width="140" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">{{ formatMatchRemark(row) }}</template>
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span> </el-table-column>
</template> <el-table-column label="操作" width="72" align="center">
</el-table-column> <template #default="{ row }">
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip /> <button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip /> </template>
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip /> </el-table-column>
<el-table-column label="状态" width="100" show-overflow-tooltip> </el-table>
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template> </template>
</el-table-column> <template #item-actions="{ item }">
<el-table-column label="说明" min-width="140" show-overflow-tooltip> <button v-if="canDownload(itemSource(item))" type="button" class="download"
<template #default="{ row }">{{ formatMatchRemark(row) }}</template> @click="downloadResult(itemSource(item))">下载</button>
</el-table-column> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
<el-table-column label="操作" width="72" align="center"> </template>
<template #default="{ row }"> <template #history-item-actions="{ item }">
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button> <button v-if="canDownload(itemSource(item))" type="button" class="download"
</template> @click="downloadResult(itemSource(item))">下载</button>
</el-table-column> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</el-table> </template>
</TaskCenterPanel>
<div class="result-list-wrap">
<div class="result-list-header">
<span>处理记录</span>
</div>
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录推送队列并等待 Python 回传后将在此显示下载链接</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list clean-result-list">
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<div v-if="item.roundIndex" class="files">轮次 {{ item.roundIndex }} </div>
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
下载
</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史记录</div>
<ul class="task-list clean-result-list">
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<div v-if="item.roundIndex" class="files">轮次 {{ item.roundIndex }} </div>
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.outputFilename" class="files">文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
下载
</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -267,6 +211,8 @@ import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue' import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue' import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters' import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { expandBrandFolderRecursive } from '@/shared/api/brand' import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
@@ -386,6 +332,25 @@ const historyItems = ref<PriceTrackHistoryItem[]>([])
const pollingTaskIds = ref<number[]>([]) const pollingTaskIds = ref<number[]>([])
const taskDetails = ref<Record<number, string>>({}) const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({}) const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
function snapTask(item: PriceTrackHistoryItem) {
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
}
const pollTimer = ref<number | null>(null) const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false) const pollingInFlight = ref(false)
let disposed = false let disposed = false
@@ -1050,7 +1015,7 @@ async function pushToPythonQueueLegacy() {
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...` queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
addPollingTask(taskVo.taskId) addPollingTask(taskVo.taskId)
scheduleNextPoll(true) scheduleNextPoll(true)
ElMessage.success('已推送到 Python 队列') ElMessage.success('已启动任务')
} catch (e) { } catch (e) {
queuePushResult.value = e instanceof Error ? e.message : '推送异常' queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value) ElMessage.error(queuePushResult.value)
@@ -1097,7 +1062,7 @@ async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayloa
nonEmptyObjectKeys: mode === 'asin' ? ['asin_rows_by_country'] : [], nonEmptyObjectKeys: mode === 'asin' ? ['asin_rows_by_country'] : [],
}) })
if (!(await passGuard(guard))) { if (!(await passGuard(guard))) {
const reason = `任务 ${taskId} 数据校验未通过,已阻止推送到 Python 队列` const reason = `任务 ${taskId} 数据校验未通过,已阻止启动任务`
await compensateDispatchFailure(taskId, reason) await compensateDispatchFailure(taskId, reason)
throw new Error(reason) throw new Error(reason)
} }
@@ -1105,12 +1070,12 @@ async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayloa
try { try {
pushResult = await api.enqueue_json(queuePayload) pushResult = await api.enqueue_json(queuePayload)
} catch (error) { } catch (error) {
const reason = `Python 队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}` const reason = `任务队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}`
await compensateDispatchFailure(taskId, reason) await compensateDispatchFailure(taskId, reason)
throw new Error(reason) throw new Error(reason)
} }
if (!pushResult?.success) { if (!pushResult?.success) {
const reason = pushResult?.error || 'Python 队列拒绝接收任务' const reason = pushResult?.error || '任务队列拒绝接收任务'
await compensateDispatchFailure(taskId, reason) await compensateDispatchFailure(taskId, reason)
throw new Error(reason) throw new Error(reason)
} }
@@ -1650,7 +1615,40 @@ const historySectionItems = computed(() =>
historyItems.value.filter((row) => !row.taskId || !pollingTaskIds.value.includes(row.taskId) || isTaskTerminalById(row.taskId)) historyItems.value.filter((row) => !row.taskId || !pollingTaskIds.value.includes(row.taskId) || isTaskTerminalById(row.taskId))
) )
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0) /** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const priceTrackCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toPriceTrackTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toPriceTrackTaskView))
function itemSource(item: TaskItemView) {
return item.source as PriceTrackHistoryItem
}
function toPriceTrackTaskView(item: PriceTrackHistoryItem): TaskItemView {
const task = snapTask(item)
return {
key: `follow-${item.resultId ?? `t${item.taskId ?? ''}`}-${item.shopName ?? ''}`,
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(task?.createdAt),
finishedAt: formatDateTime(task?.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.roundIndex ? [`轮次:第 ${item.roundIndex}`] : []),
...(item.platform ? [`平台:${item.platform}`] : []),
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
source: item,
}
}
function taskStatusOf(taskId?: number) { function taskStatusOf(taskId?: number) {
if (!taskId) return '' if (!taskId) return ''
@@ -1770,6 +1768,26 @@ onUnmounted(() => {
timers.clearScope() timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer) if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -2253,61 +2271,19 @@ onUnmounted(() => {
} }
/* Right panel */ /* Right panel */
.panel-header {
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: #f5f8fc;
border-bottom: 1px solid #2e3a52;
}
.task-list-wrap {
flex: 1;
padding: 16px 20px;
overflow: auto;
}
.pr-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 22px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.subsection-title { .subsection-title {
font-size: 13px; font-size: 13px;
color: #a0acbe; color: #a0acbe;
margin: 8px 0 10px; margin: 8px 0 10px;
} }
.empty-tasks { /* 匹配结果空态(#cards-extra 区块内使用) */
.match-empty {
color: #5e6878; color: #5e6878;
font-size: 13px; font-size: 13px;
padding: 16px;
text-align: center;
}
.empty-tasks.narrow {
padding: 12px 8px; padding: 12px 8px;
margin-bottom: 12px;
text-align: center;
} }
.match-table { .match-table {
@@ -2325,96 +2301,6 @@ onUnmounted(() => {
.ok { color: #27ae60; } .ok { color: #27ae60; }
.fail { color: #e67e22; } .fail { color: #e67e22; }
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 200px;
margin-top: 8px;
}
.result-list-header {
padding: 12px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
.result-subsection {
padding: 12px 12px 4px;
}
.result-subsection-title {
font-size: 12px;
color: #5e6878;
margin-bottom: 8px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 8px;
background: #222;
}
.split-result-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.id {
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
}
.task-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.status.running {
background: rgba(52, 152, 219, 0.18);
color: #3498db;
}
.download { .download {
padding: 6px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
@@ -2454,10 +2340,6 @@ onUnmounted(() => {
border-right: none; border-right: none;
border-bottom: 1px solid #2e3a52; border-bottom: 1px solid #2e3a52;
} }
.pr-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
</style> </style>
@@ -6,7 +6,7 @@
<aside class="left-panel"> <aside class="left-panel">
<div class="section-title">店铺名称</div> <div class="section-title">店铺名称</div>
<div class="input-zone"> <div class="input-zone">
<div class="hint">输入店铺名后点确认加入下方备选区按当前登录用户保存仅索引可命中的店铺可入库勾选备选后点匹配店铺推送到 Python 队列</div> <div class="hint">输入店铺名后点确认加入下方备选区按当前登录用户保存仅索引可命中的店铺可入库勾选备选后点匹配店铺启动任务</div>
<div class="input-row"> <div class="input-row">
<el-input v-model="shopInput" clearable placeholder="请输入店铺名称" @keyup.enter="confirmAdd" /> <el-input v-model="shopInput" clearable placeholder="请输入店铺名称" @keyup.enter="confirmAdd" />
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd"> <button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">
@@ -56,7 +56,7 @@
<div v-if="countryPrefSaving" class="country-pref-status">保存中</div> <div v-if="countryPrefSaving" class="country-pref-status">保存中</div>
<div class="section-title">商品列表筛选</div> <div class="section-title">商品列表筛选</div>
<p class="hint listing-filter-hint">推送到 Python 队列一并下发 Python 区分处理场景</p> <p class="hint listing-filter-hint">启动任务一并下发 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 PRODUCT_RISK_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"
@@ -72,7 +72,7 @@
</button> </button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" <button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length"
@click="pushToPythonQueue"> @click="pushToPythonQueue">
{{ pushing ? '推送中…' : '推送到 Python 队列' }} {{ pushing ? '启动中…' : '启动任务' }}
</button> </button>
</div> </div>
<p class="loading-msg">在搜索结果中禁止显示详情页面已删除会把匹配店铺合并为一个任务入队避免同时打开多个紫鸟窗口其它筛选仍按店铺逐条入队Python <p class="loading-msg">在搜索结果中禁止显示详情页面已删除会把匹配店铺合并为一个任务入队避免同时打开多个紫鸟窗口其它筛选仍按店铺逐条入队Python
@@ -87,107 +87,53 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">匹配与任务</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary pr-summary"> title="匹配与任务"
<div class="summary-card"> :cards="riskCards"
<span class="summary-label">备选店铺数</span> :current-items="currentTaskViews"
<strong>{{ dashboard.candidateCount }}</strong> :history-items="historyTaskViews"
</div> current-empty-text="暂无当前任务。完成「匹配店铺」并启动任务后,将在此展示处理进度"
<div class="summary-card"> history-empty-text="暂无历史记录"
<span class="summary-label">已结束任务</span> >
<strong>{{ dashboard.processedTaskCount }}</strong> <template #cards-extra>
</div> <div class="subsection-title">匹配结果</div>
<div class="summary-card"> <div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<span class="summary-label">成功任务</span> <el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
<strong>{{ dashboard.successTaskCount }}</strong> class="result-table match-table">
</div> <el-table-column prop="shopName" label="店铺名" min-width="100" />
<div class="summary-card"> <el-table-column label="匹配" width="72" align="center">
<span class="summary-label">失败任务</span> <template #default="{ row }">
<strong>{{ dashboard.failedTaskCount }}</strong> <span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
</div> </template>
</div> </el-table-column>
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<div class="subsection-title">匹配结果</div> <el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div> <el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" <el-table-column label="状态" width="100" show-overflow-tooltip>
class="result-table match-table"> <template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
<el-table-column prop="shopName" label="店铺名" min-width="100" /> </el-table-column>
<el-table-column label="匹配" width="72" align="center"> <el-table-column label="说明" min-width="140" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">{{ formatMatchRemark(row) }}</template>
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span> </el-table-column>
</template> <el-table-column label="操作" width="72" align="center">
</el-table-column> <template #default="{ row }">
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip /> <button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip /> </template>
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip /> </el-table-column>
<el-table-column label="状态" width="100" show-overflow-tooltip> </el-table>
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template> </template>
</el-table-column> <template #item-actions="{ item }">
<el-table-column label="说明" min-width="140" show-overflow-tooltip> <button v-if="canDownload(itemSource(item))" type="button" class="download"
<template #default="{ row }">{{ formatMatchRemark(row) }}</template> @click="downloadResult(itemSource(item))">下载</button>
</el-table-column> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
<el-table-column label="操作" width="72" align="center"> </template>
<template #default="{ row }"> <template #history-item-actions="{ item }">
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button> <button v-if="canDownload(itemSource(item))" type="button" class="download"
</template> @click="downloadResult(itemSource(item))">下载</button>
</el-table-column> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</el-table> </template>
</TaskCenterPanel>
<div class="result-list-wrap">
<div class="result-list-header">
<span>处理记录</span>
</div>
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录推送队列并等待 Python 回传后将在此显示下载链接</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list clean-result-list">
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
下载
</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史记录</div>
<ul class="task-list clean-result-list">
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.outputFilename" class="files">文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
下载
</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -198,6 +144,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters' import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { import {
addProductRiskCandidate, addProductRiskCandidate,
@@ -387,6 +335,25 @@ const pollingTaskIds = ref<number[]>([])
const taskDetails = ref<Record<number, string>>({}) const taskDetails = ref<Record<number, string>>({})
/** 本地缓存 POST /tasks/batch 各 task 最新快照 + 创建任务响应,便于刷新后查看 */ /** 本地缓存 POST /tasks/batch 各 task 最新快照 + 创建任务响应,便于刷新后查看 */
const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({}) const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({})
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
function snapTask(item: ProductRiskHistoryItem) {
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds
}
const pollTimer = ref<number | null>(null) const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false) const pollingInFlight = ref(false)
let disposed = false let disposed = false
@@ -702,7 +669,39 @@ const historySectionItems = computed(() =>
), ),
) )
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0) /** 统一统计卡:运行中任务用页面当前列表实时计算,其余沿用后端 dashboard */
const riskCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toRiskTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toRiskTaskView))
function itemSource(item: TaskItemView) {
return item.source as ProductRiskHistoryItem
}
function toRiskTaskView(item: ProductRiskHistoryItem): TaskItemView {
const task = snapTask(item)
return {
key: `risk-${item.resultId ?? `t${item.taskId ?? ''}`}-${item.shopName ?? ''}`,
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(task?.createdAt),
finishedAt: formatDateTime(task?.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.platform ? [`平台:${item.platform}`] : []),
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
source: item,
}
}
const TRANSIENT_BACKEND_ERROR_PATTERNS = [ const TRANSIENT_BACKEND_ERROR_PATTERNS = [
'无法连接到后端服务', '无法连接到后端服务',
@@ -1267,6 +1266,26 @@ onUnmounted(() => {
timers.clearScope() timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer) if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1577,61 +1596,19 @@ onUnmounted(() => {
word-break: break-all; word-break: break-all;
} }
.panel-header {
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: #f5f8fc;
border-bottom: 1px solid #2e3a52;
}
.task-list-wrap {
flex: 1;
padding: 16px 20px;
overflow: auto;
}
.clean-result-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 22px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.subsection-title { .subsection-title {
font-size: 13px; font-size: 13px;
color: #a0acbe; color: #a0acbe;
margin: 8px 0 10px; margin: 8px 0 10px;
} }
.empty-tasks { /* 匹配结果空态(#cards-extra 区块内使用) */
.match-empty {
color: #5e6878; color: #5e6878;
font-size: 13px; font-size: 13px;
padding: 16px;
text-align: center;
}
.empty-tasks.narrow {
padding: 12px 8px; padding: 12px 8px;
margin-bottom: 12px;
text-align: center;
} }
.match-table { .match-table {
@@ -1661,101 +1638,6 @@ onUnmounted(() => {
color: #e67e22; color: #e67e22;
} }
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 200px;
margin-top: 8px;
}
.result-list-header {
padding: 12px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
.result-subsection {
padding: 12px 12px 4px;
}
.result-subsection-title {
font-size: 12px;
color: #5e6878;
margin-bottom: 8px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 8px;
background: #222;
}
.left {
flex: 1;
min-width: 0;
}
.split-result-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.id {
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files {
font-size: 12px;
color: #5e6878;
}
.task-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.status.running {
background: rgba(52, 152, 219, 0.18);
color: #3498db;
}
.download { .download {
padding: 6px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
@@ -1795,10 +1677,6 @@ onUnmounted(() => {
border-right: none; border-right: none;
border-bottom: 1px solid #2e3a52; border-bottom: 1px solid #2e3a52;
} }
.clean-result-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
</style> </style>
@@ -91,110 +91,91 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">上架结果</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="上架结果"
<div class="summary-card"> :cards="publishCards"
<span class="summary-label">任务文件</span> :current-items="currentTaskViews"
<strong>{{ summary.total }}</strong> :history-items="historyTaskViews"
</div> current-title="当前任务"
<div class="summary-card"> current-empty-text="暂无上架任务,完成文件选择后点击“开始上架”创建任务。"
<span class="summary-label">已处理文件</span> history-empty-text="暂无历史记录"
<strong>{{ summary.completed }}</strong> >
</div> <template #item-extra="{ item }">
<div class="summary-card"> <ul class="publish-file-list">
<span class="summary-label">成功结果</span> <li v-for="file in itemSource(item).files" :key="fileKey(file)" class="publish-file-row">
<strong>{{ summary.successCount }}</strong> <div class="publish-file-row-head">
</div> <span class="publish-file-row-name" :title="file.sourceFilename || ''">
<div class="summary-card"> {{ file.sourceFilename || `文件 ${file.fileId}` }}
<span class="summary-label">失败文件</span>
<strong>{{ summary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>上架任务列表</span>
<span class="history-count">历史任务 {{ historyItems.length }}</span>
</div>
<div v-if="!visibleTasks.length" class="empty-tasks">
暂无上架任务完成文件选择后点击开始上架创建任务
</div>
<section
v-for="detail in visibleTasks"
v-else
:key="detail.task.id"
class="task-section"
>
<div class="task-section-header">
<div class="task-title-wrap">
<strong>上架任务 #{{ detail.task.id }}</strong>
<span class="task-meta">创建时间{{ formatDateTime(detail.task.createdAt) }}</span>
</div>
<div class="task-actions">
<span class="status" :class="statusClass(detail.task.status)">
{{ statusText(detail.task.status) }}
</span> </span>
<button <span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
v-if="canDownload(detail)"
type="button"
class="download"
@click="downloadResult(detail)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
:disabled="isDeletingTask(detail.task.id)"
@click="deleteTaskRecord(detail)"
>
{{ isDeletingTask(detail.task.id) ? '删除中...' : '删除' }}
</button>
</div> </div>
</div> <div class="publish-file-row-info">
<span>店铺{{ file.shopName || '-' }}</span>
<div v-if="detail.task.errorMessage" class="task-error"> <span>匹配{{ matchText(file) }}</span>
{{ detail.task.errorMessage }} <span v-if="file.platform">平台{{ file.platform }}</span>
</div> <span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<ul class="file-list"> <template v-if="shouldShowFileProgress(file)">
<li v-for="file in detail.files" :key="fileKey(file)" class="file-item"> <div class="publish-file-row-progress-meta">
<div class="file-main"> <span>{{ file.progressMessage || statusText(file.status) }}</span>
<div class="file-title" :title="file.sourceFilename || ''"> <span>{{ fileProgressPercent(file) }}%</span>
{{ file.sourceFilename || `文件 ${file.fileId}` }}
</div>
<div class="file-info">
<span>店铺{{ file.shopName || '-' }}</span>
<span>匹配{{ matchText(file) }}</span>
<span v-if="file.platform">平台{{ file.platform }}</span>
<span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<template v-if="shouldShowFileProgress(file)">
<div class="file-progress-header">
<span>{{ file.progressMessage || statusText(file.status) }}</span>
<span>{{ fileProgressPercent(file) }}%</span>
</div>
<div class="file-progress-track">
<div
class="file-progress-fill"
:class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"
/>
</div>
</template>
<div v-if="fileErrorText(file)" class="file-error">{{ fileErrorText(file) }}</div>
</div> </div>
<span class="status file-status" :class="statusClass(file.status)"> <div class="publish-file-row-progress-track">
{{ statusText(file.status) }} <div class="publish-file-row-progress-fill" :class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"></div>
</div>
</template>
<div v-if="fileErrorText(file)" class="publish-file-row-error file-error">{{ fileErrorText(file) }}</div>
</li>
</ul>
</template>
<template #history-item-extra="{ item }">
<ul class="publish-file-list">
<li v-for="file in itemSource(item).files" :key="fileKey(file)" class="publish-file-row">
<div class="publish-file-row-head">
<span class="publish-file-row-name" :title="file.sourceFilename || ''">
{{ file.sourceFilename || `文件 ${file.fileId}` }}
</span> </span>
</li> <span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
</ul> </div>
</section> <div class="publish-file-row-info">
</div> <span>店铺{{ file.shopName || '-' }}</span>
</div> <span>匹配{{ matchText(file) }}</span>
<span v-if="file.platform">平台{{ file.platform }}</span>
<span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<template v-if="shouldShowFileProgress(file)">
<div class="publish-file-row-progress-meta">
<span>{{ file.progressMessage || statusText(file.status) }}</span>
<span>{{ fileProgressPercent(file) }}%</span>
</div>
<div class="publish-file-row-progress-track">
<div class="publish-file-row-progress-fill" :class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"></div>
</div>
</template>
<div v-if="fileErrorText(file)" class="publish-file-row-error file-error">{{ fileErrorText(file) }}</div>
</li>
</ul>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" :disabled="isDeletingTask(itemSource(item).task.id)"
@click="deleteTaskRecord(itemSource(item))">
{{ isDeletingTask(itemSource(item).task.id) ? '删除中...' : '删除' }}
</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" :disabled="isDeletingTask(itemSource(item).task.id)"
@click="deleteTaskRecord(itemSource(item))">
{{ isDeletingTask(itemSource(item).task.id) ? '删除中...' : '删除' }}
</button>
</template>
</TaskCenterPanel>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -207,6 +188,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue' import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue' import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand' import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { import {
@@ -410,6 +393,65 @@ const visibleTasks = computed(() => {
return Array.from(tasks.values()).sort((left, right) => right.task.id - left.task.id) return Array.from(tasks.values()).sort((left, right) => right.task.id - left.task.id)
}) })
function isPublishTaskTerminal(detail: PublishTaskDetailVo) {
return isTerminalStatus(detail.task?.status)
}
const publishCards = computed<TaskStatCard[]>(() => {
const visible = visibleTasks.value
const terminal = visible.filter((detail) => isPublishTaskTerminal(detail))
let successCount = 0
for (const detail of terminal) {
const value = normalizeStatus(detail.task?.status)
if (value === 'SUCCESS' || value === 'COMPLETED') successCount += 1
}
return [
{ label: '运行中任务', value: visible.length - terminal.length },
{ label: '已结束任务', value: terminal.length },
{ label: '成功任务', value: successCount },
{ label: '失败任务', value: terminal.length - successCount },
]
})
const currentTaskViews = computed<TaskItemView[]>(() =>
visibleTasks.value
.filter((detail) => !isPublishTaskTerminal(detail))
.map(toPublishTaskView),
)
const historyTaskViews = computed<TaskItemView[]>(() =>
historyItems.value
.filter((detail) => isPublishTaskTerminal(detail))
.map(toPublishTaskView),
)
function itemSource(item: TaskItemView): PublishTaskDetailVo {
return item.source as PublishTaskDetailVo
}
function toPublishTaskView(detail: PublishTaskDetailVo): TaskItemView {
const task = detail.task
const taskStatus = task?.status
const terminal = isTerminalStatus(taskStatus)
const rawPercent = Number(task?.percent || 0)
const percent = Number.isFinite(rawPercent) ? Math.max(0, Math.min(100, Math.round(rawPercent))) : 0
const errorMessage = task?.errorMessage || ''
const extraLines: string[] = []
if (errorMessage) extraLines.push(`错误:${errorMessage}`)
return {
key: `publish-${task?.id ?? 'unknown'}`,
title: `上架任务 #${task?.id ?? '-'}`,
taskId: task?.id ?? '-',
startedAt: formatDateTime(task?.startedAt || task?.createdAt),
finishedAt: task?.finishedAt ? formatDateTime(task.finishedAt) : terminal ? '-' : '进行中',
statusText: statusText(taskStatus),
statusClass: statusClass(taskStatus),
extraLines,
progress: !terminal && percent > 0 ? { percent, stage: '任务进度' } : null,
source: detail,
}
}
const summary = computed(() => { const summary = computed(() => {
const files = visibleTasks.value.flatMap((detail) => detail.files || []) const files = visibleTasks.value.flatMap((detail) => detail.files || [])
const successCount = files.filter((file) => ['SUCCESS', 'COMPLETED'].includes(normalizeStatus(file.status))).length const successCount = files.filter((file) => ['SUCCESS', 'COMPLETED'].includes(normalizeStatus(file.status))).length
@@ -870,7 +912,7 @@ async function processQueue() {
saveQueueState() saveQueueState()
try { try {
await activatePublishFile(taskId, nextFileId) await activatePublishFile(taskId, nextFileId)
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已派发到 Python 队列' }) updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已启动任务' })
const payload = buildQueuePayload(taskId, file) const payload = buildQueuePayload(taskId, file)
// totalRows/totalPages 0 Python // totalRows/totalPages 0 Python
const guard = checkQueuePayload(payload, { const guard = checkQueuePayload(payload, {
@@ -882,7 +924,7 @@ async function processQueue() {
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`) throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`)
} }
const result = await api.enqueue_json(payload) const result = await api.enqueue_json(payload)
if (!result?.success) throw new Error(result?.error || 'Python 队列拒绝接收任务') if (!result?.success) throw new Error(result?.error || '任务队列拒绝接收任务')
queueMessage.value = pendingFileIds.value.length queueMessage.value = pendingFileIds.value.length
? `文件 ${file.sourceFilename || nextFileId} 已入队,完成后继续剩余 ${pendingFileIds.value.length} 个文件。` ? `文件 ${file.sourceFilename || nextFileId} 已入队,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
: `文件 ${file.sourceFilename || nextFileId} 已入队,等待执行完成。` : `文件 ${file.sourceFilename || nextFileId} 已入队,等待执行完成。`
@@ -1158,6 +1200,26 @@ onBeforeUnmount(() => {
disposed = true disposed = true
timers.clearScope() timers.clearScope()
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1167,8 +1229,8 @@ onBeforeUnmount(() => {
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; } .right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; } .section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
.upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; text-align: center; } .upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; text-align: center; }
.hint, .loading-msg, .task-meta, .file-info, .history-count { color: #5e6878; font-size: 12px; line-height: 1.5; } .hint, .loading-msg { color: #5e6878; font-size: 12px; line-height: 1.5; }
.btns, .run-row, .task-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } .btns, .run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.btns { justify-content: center; } .btns { justify-content: center; }
.opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; } .opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
.opt-btn { padding: 8px 16px; border: 1px solid #3e4a62; background: #2e3a52; color: #c8d2e2; font-size: 13px; } .opt-btn { padding: 8px 16px; border: 1px solid #3e4a62; background: #2e3a52; color: #c8d2e2; font-size: 13px; }
@@ -1190,54 +1252,38 @@ onBeforeUnmount(() => {
.btn-run:hover:not(:disabled) { background: #2980b9; } .btn-run:hover:not(:disabled) { background: #2980b9; }
.queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #2e3a52; color: #b8d8ef; font-size: 12px; line-height: 1.6; } .queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #2e3a52; color: #b8d8ef; font-size: 12px; line-height: 1.6; }
.queue-status-title { margin-bottom: 4px; } .queue-status-title { margin-bottom: 4px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 15px; font-weight: 600; }
.task-list-wrap { flex: 1; padding: 16px; overflow-y: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 12px; margin-bottom: 18px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; color: #f5f8fc; font-size: 22px; }
.summary-label { color: #5e6878; font-size: 12px; }
.result-list-wrap { min-height: 260px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2e3a52; color: #c8d2e2; font-size: 14px; }
.empty-tasks { padding: 24px 16px; color: #5e6878; font-size: 13px; }
.task-section + .task-section { border-top: 1px solid #333f55; }
.task-section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; background: #222; }
.task-title-wrap { display: flex; min-width: 0; flex-direction: column; gap: 4px; color: #c8d2e2; }
.task-error, .file-error { color: #ef8d8d; font-size: 12px; }
.task-error { padding: 10px 16px 0; }
.download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; } .download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; }
.download:hover { background: #367d54; } .download:hover { background: #367d54; }
.btn-delete { padding: 7px 12px; background: #472929; color: #efaaaa; font-size: 12px; white-space: nowrap; } .btn-delete { padding: 7px 12px; background: #472929; color: #efaaaa; font-size: 12px; white-space: nowrap; }
.btn-delete:hover:not(:disabled) { background: #5a3030; color: #ffd0d0; } .btn-delete:hover:not(:disabled) { background: #5a3030; color: #ffd0d0; }
.btn-delete:disabled { cursor: wait; opacity: .55; } .btn-delete:disabled { cursor: wait; opacity: .55; }
.status { display: inline-flex; align-items: center; justify-content: center; min-width: 58px; min-height: 26px; padding: 0 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; } .file-error { color: #ef8d8d; font-size: 12px; }
.status.pending { background: #3a3424; color: #e4c56a; } .publish-file-list { margin: 0; padding: 0; list-style: none; }
.status.running { background: #24394a; color: #75bff1; } .publish-file-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #2e3a52; }
.status.success { background: #233c2d; color: #72d598; } .publish-file-row:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.status.failed { background: #472929; color: #ef8d8d; } .publish-file-row-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
.file-list { margin: 0; padding: 0; list-style: none; } .publish-file-row-name { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.file-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid #292929; } .publish-file-row-chip { flex-shrink: 0; padding: 2px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; }
.file-main { flex: 1; min-width: 0; } .publish-file-row-chip.pending { background: #3a3424; color: #e4c56a; }
.file-title { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .publish-file-row-chip.running { background: #24394a; color: #75bff1; }
.file-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6px; } .publish-file-row-chip.success { background: #233c2d; color: #72d598; }
.file-progress-header { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; color: #969696; font-size: 11px; } .publish-file-row-chip.failed { background: #472929; color: #ef8d8d; }
.file-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; } .publish-file-row-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 5px; color: #5e6878; font-size: 12px; line-height: 1.5; }
.file-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; } .publish-file-row-progress-meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 8px; color: #969696; font-size: 11px; }
.file-progress-fill.running { background: #409eff; } .publish-file-row-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; }
.file-progress-fill.success { background: #42b36b; } .publish-file-row-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
.file-progress-fill.failed { background: #d06161; } .publish-file-row-progress-fill.running { background: #409eff; }
.file-error { margin-top: 7px; } .publish-file-row-progress-fill.success { background: #42b36b; }
.file-status { flex-shrink: 0; } .publish-file-row-progress-fill.failed { background: #d06161; }
.publish-file-row-error { margin-top: 7px; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { height: auto; flex-direction: column; } .main-content { height: auto; flex-direction: column; }
.left-panel { width: 100%; border-right: 0; border-bottom: 1px solid #2e3a52; } .left-panel { width: 100%; border-right: 0; border-bottom: 1px solid #2e3a52; }
.right-panel { min-height: 420px; } .right-panel { min-height: 420px; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.clean-result-summary { grid-template-columns: 1fr; } .publish-file-row-head { flex-direction: column; align-items: flex-start; }
.task-section-header, .file-item { flex-direction: column; }
.task-actions { width: 100%; }
} }
</style> </style>
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div> <div class="section-title">店铺输入</div>
<div class="input-zone"> <div class="input-zone">
<div class="hint"> <div class="hint">
左侧负责录入店铺并加入备选区确认命中后推送到 Python 队列查询ASIN按店铺串行执行上一个任务完成后会自动开始下一个失败也会继续下一个 左侧负责录入店铺并加入备选区确认命中后启动任务查询ASIN按店铺串行执行上一个任务完成后会自动开始下一个失败也会继续下一个
</div> </div>
<div class="input-row"> <div class="input-row">
<el-input <el-input
@@ -77,7 +77,7 @@
:disabled="isQueueBusy || !matchedRunnableItems.length" :disabled="isQueueBusy || !matchedRunnableItems.length"
@click="pushToPythonQueue" @click="pushToPythonQueue"
> >
{{ isQueueBusy ? "串行执行中..." : "推送到 Python 队列" }} {{ isQueueBusy ? "串行执行中..." : "启动任务" }}
</button> </button>
</div> </div>
@@ -95,221 +95,85 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">匹配与任务</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="匹配与任务"
<div class="summary-card"> :cards="queryAsinCards"
<span class="summary-label">备选店铺</span> :current-items="currentTaskViews"
<strong>{{ dashboard.candidateCount }}</strong> :history-items="historyTaskViews"
current-empty-text="暂无当前任务。启动任务后,正在执行的任务会展示在这里,历史任务可在右上角「历史任务」中查看"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">
完成匹配店铺这里会展示匹配结果
</div> </div>
<div class="summary-card"> <el-table
<span class="summary-label">已处理任务</span> v-else
<strong>{{ dashboard.processedTaskCount }}</strong> :data="matchedItems"
</div> :row-key="rowKeyForMatch"
<div class="summary-card"> :highlight-current-row="false"
<span class="summary-label">成功任务</span> class="result-table match-table"
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">
完成匹配店铺这里会展示匹配结果
</div>
<el-table
v-else
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
删除
</button>
</template>
</el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header">
<span>任务记录</span>
</div>
<div
v-if="!currentSectionItems.length && !historySectionItems.length"
class="empty-tasks"
> >
暂无任务记录推送到 Python 队列后这里会展示当前任务和历史任务 <el-table-column prop="shopName" label="店铺名" min-width="120" />
</div> <el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<template v-else> <span :class="row.matched ? 'ok' : 'fail'">
<div v-if="currentSectionItems.length" class="result-subsection"> {{ row.matched ? "是" : "否" }}
<div class="result-subsection-title">当前任务</div> </span>
<ul class="task-list"> </template>
<li </el-table-column>
v-for="item in currentSectionItems" <el-table-column
:key="`${item.resultId}-${item.taskId}`" prop="shopId"
class="task-item" label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
> >
<div class="left split-result-main"> 删除
<span class="id" :title="item.shopName || ''"> </button>
{{ item.shopName || "-" }} </template>
</span> </el-table-column>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div> </el-table>
<div v-if="item.resultId" class="files"> </template>
结果 ID: {{ item.resultId }} <template #item-actions="{ item }">
</div> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
<div v-if="item.shopId" class="files"> </template>
店铺 ID: {{ item.shopId }} <template #history-item-actions="{ item }">
</div> <button v-if="canDownload(itemSource(item))" type="button" class="download"
<div v-if="item.platform" class="files"> @click="downloadResult(itemSource(item))">下载结果</button>
平台: {{ item.platform }} <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</div> </template>
<div class="files"> </TaskCenterPanel>
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div class="files">
ASIN结构: {{ formatTemplateSummary(item) }}
</div>
<div v-if="item.error" class="files">
错误: {{ item.error }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史任务</div>
<ul class="task-list">
<li
v-for="item in historySectionItems"
:key="`${item.resultId}-${item.taskId}`"
class="task-item"
>
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">
{{ item.shopName || "-" }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="item.resultId" class="files">
结果 ID: {{ item.resultId }}
</div>
<div v-if="item.shopId" class="files">
店铺 ID: {{ item.shopId }}
</div>
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div v-if="item.finishedAt" class="files">
完成时间: {{ formatDateTime(item.finishedAt) }}
</div>
<div class="files">
ASIN结构: {{ formatTemplateSummary(item) }}
</div>
<div v-if="item.error" class="files">
错误: {{ item.error }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
v-if="canDownload(item)"
type="button"
class="archive"
@click="downloadResult(item)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -320,6 +184,8 @@
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed, onMounted, onUnmounted, ref } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from "@/shared/components/tasks/TaskCenterPanel.vue";
import type { TaskItemView, TaskStatCard } from "@/shared/components/tasks/types";
import { import {
addQueryAsinCandidate, addQueryAsinCandidate,
createQueryAsinTask, createQueryAsinTask,
@@ -386,6 +252,44 @@ const currentSectionItems = computed(() =>
const historySectionItems = computed(() => const historySectionItems = computed(() =>
historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)), historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
); );
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const queryAsinCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentSectionItems.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
]);
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toQueryAsinTaskView));
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toQueryAsinTaskView));
function itemSource(item: TaskItemView) {
return item.source as QueryAsinHistoryItem;
}
function toQueryAsinTaskView(item: QueryAsinHistoryItem): TaskItemView {
const startedRaw = taskStartTime(item.taskId);
return {
key: `qasin-${item.taskId ?? "r"}-${item.resultId ?? ""}-${item.shopName ?? ""}`,
title: item.shopName || "-",
taskId: item.taskId ?? "-",
startedAt: formatDateTime(startedRaw || item.createdAt),
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : "进行中",
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: [
...(item.resultId ? [`结果 ID: ${item.resultId}`] : []),
...(item.shopId ? [`店铺 ID: ${item.shopId}`] : []),
...(item.platform ? [`平台: ${item.platform}`] : []),
...(startedRaw && item.createdAt && startedRaw !== item.createdAt ? [`创建时间: ${formatDateTime(item.createdAt)}`] : []),
`ASIN结构: ${formatTemplateSummary(item)}`,
...(item.error ? [`错误: ${item.error}`] : []),
],
source: item,
};
}
const hasQueuedTaskWork = computed( const hasQueuedTaskWork = computed(
() => () =>
!!activeTaskId.value || !!activeTaskId.value ||
@@ -499,7 +403,7 @@ function formatMatchRemark(row: QueryAsinShopQueueItem) {
const message = (row.matchMessage || "").trim(); const message = (row.matchMessage || "").trim();
if (message) return message; if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") { if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可推送到 Python 队列"; return "索引已命中,可启动任务";
} }
if (row.matched) { if (row.matched) {
return "已命中索引,请结合状态列确认"; return "已命中索引,请结合状态列确认";
@@ -1206,6 +1110,26 @@ onUnmounted(() => {
clearSleepTimers(); clearSleepTimers();
timers.clearScope(); timers.clearScope();
}); });
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1237,43 +1161,20 @@ onUnmounted(() => {
.queue-debug-title { margin-bottom: 8px; } .queue-debug-title { margin-bottom: 8px; }
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; } .queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; } .queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
.summary-label { font-size: 12px; color: #5e6878; }
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; } .subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; } .match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.empty-tasks.narrow { padding: 12px 8px; }
.match-table { margin-bottom: 18px; } .match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; } .match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; } .result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; } .ok { color: #27ae60; }
.fail { color: #e67e22; } .fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; } .download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; } .download:hover { background: rgba(52, 152, 219, 0.28); }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
.files { font-size: 12px; color: #5e6878; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.archive:hover { background: rgba(52, 152, 219, 0.28); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; } .btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); } .btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; } .main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
</style> </style>
@@ -66,52 +66,51 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">匹配与任务</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="匹配与任务"
<div class="summary-card"><span class="summary-label">备选店铺</span><strong>{{ dashboard.candidateCount }}</strong></div> :cards="shopDataCards"
<div class="summary-card"><span class="summary-label">已处理任务</span><strong>{{ dashboard.processedTaskCount }}</strong></div> :current-items="currentTaskViews"
<div class="summary-card"><span class="summary-label">成功任务</span><strong>{{ dashboard.successTaskCount }}</strong></div> :history-items="historyTaskViews"
<div class="summary-card"><span class="summary-label">失败任务</span><strong>{{ dashboard.failedTaskCount }}</strong></div> current-empty-text="暂无当前任务。启动任务后,正在执行的任务会展示在这里,历史任务可在右上角「历史任务」中查看"
</div> history-empty-text="暂无历史记录"
>
<div class="subsection-title">匹配结果</div> <template #cards-extra>
<div v-if="!matchedItems.length" class="empty-tasks narrow">匹配后将在这里显示结果</div> <div class="subsection-title">匹配结果</div>
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false" <div v-if="!matchedItems.length" class="match-empty">匹配后将在这里显示结果</div>
class="result-table match-table"> <el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
<el-table-column prop="shopName" label="店铺名" min-width="120" /> class="result-table match-table">
<el-table-column label="匹配" width="64" align="center"> <el-table-column prop="shopName" label="店铺名" min-width="120" />
<template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template> <el-table-column label="匹配" width="64" align="center">
</el-table-column> <template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template>
<el-table-column prop="shopId" label="店铺 ID" min-width="120" show-overflow-tooltip /> </el-table-column>
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip /> <el-table-column prop="shopId" label="店铺 ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="companyName" label="公司" min-width="120" show-overflow-tooltip /> <el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column label="状态" width="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column> <el-table-column prop="companyName" label="公司" min-width="120" show-overflow-tooltip />
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column> <el-table-column label="状态" width="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
<el-table-column label="操作" width="64" align="center"><template #default="{ row }"> <el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column>
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button> <el-table-column label="操作" width="64" align="center"><template #default="{ row }">
</template></el-table-column> <button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
</el-table> </template></el-table-column>
</el-table>
<div class="result-list-wrap"> </template>
<div class="result-list-header"><span>任务记录</span></div> <template #item-extra="{ item }">
<div v-if="!currentItems.length && !historySectionItems.length" class="empty-tasks">暂无任务记录</div> <div v-if="itemSource(item).error" class="files error-text">错误{{ itemSource(item).error }}</div>
<div v-if="currentItems.length" class="result-subsection"> </template>
<div class="result-subsection-title">当前任务</div> <template #history-item-extra="{ item }">
<ul class="task-list"> <div v-if="itemSource(item).error" class="files error-text">错误{{ itemSource(item).error }}</div>
<TaskRow v-for="item in currentItems" :key="historyKey(item)" :item="item" </template>
@download="downloadResult" @delete="deleteTaskRecord" /> <template #item-actions="{ item }">
</ul> <button v-if="canDownload(itemSource(item))" type="button" class="download"
</div> @click="downloadResult(itemSource(item))">下载</button>
<div v-if="historySectionItems.length" class="result-subsection"> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
<div class="result-subsection-title">历史记录</div> </template>
<ul class="task-list"> <template #history-item-actions="{ item }">
<TaskRow v-for="item in historySectionItems" :key="historyKey(item)" :item="item" <button v-if="canDownload(itemSource(item))" type="button" class="download"
@download="downloadResult" @delete="deleteTaskRecord" /> @click="downloadResult(itemSource(item))">下载</button>
</ul> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</div> </template>
</div> </TaskCenterPanel>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -119,9 +118,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue' import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version' import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getPywebviewApi } from '@/shared/bridges/pywebview'
@@ -160,28 +161,6 @@ const COUNTRY_OPTIONS = [
{ code: 'IT', label: '意大利' }, { code: 'IT', label: '意大利' },
] as const ] as const
const TaskRow = defineComponent({
props: { item: { type: Object as () => ShopDataCrawlHistoryItem, required: true } },
emits: ['download', 'delete'],
setup(props, { emit }) {
return () => h('li', { class: 'task-item' }, [
h('div', { class: 'left split-result-main' }, [
h('span', { class: 'id', title: props.item.shopName || '' }, props.item.shopName || '-'),
h('div', { class: 'files' }, `任务 ID${props.item.taskId ?? '-'}`),
props.item.platform ? h('div', { class: 'files' }, `平台:${props.item.platform}`) : null,
props.item.outputFilename ? h('div', { class: 'files' }, `文件:${props.item.outputFilename}`) : null,
props.item.createdAt ? h('div', { class: 'files' }, `创建时间:${formatDateTime(props.item.createdAt)}`) : null,
props.item.error ? h('div', { class: 'files error-text' }, `错误:${props.item.error}`) : null,
]),
h('div', { class: 'task-right' }, [
h('span', { class: ['status', statusClass(props.item.taskStatus)] }, statusText(props.item)),
canDownload(props.item) ? h('button', { type: 'button', class: 'download', onClick: () => emit('download', props.item) }, '下载') : null,
h('button', { type: 'button', class: 'btn-delete', onClick: () => emit('delete', props.item) }, '删除'),
]),
])
},
})
const timers = createCategorizedTimers('shop-data-crawl') const timers = createCategorizedTimers('shop-data-crawl')
const ziniaoVersion = useZiniaoVersion() const ziniaoVersion = useZiniaoVersion()
const shopInput = ref('') const shopInput = ref('')
@@ -213,6 +192,39 @@ let disposed = false
const matchedRunnableItems = computed(() => matchedItems.value.filter((item) => item.matched)) const matchedRunnableItems = computed(() => matchedItems.value.filter((item) => item.matched))
const currentItems = computed(() => historyItems.value.filter((item) => !isTerminal(item.taskStatus))) const currentItems = computed(() => historyItems.value.filter((item) => !isTerminal(item.taskStatus)))
const historySectionItems = computed(() => historyItems.value.filter((item) => isTerminal(item.taskStatus))) const historySectionItems = computed(() => historyItems.value.filter((item) => isTerminal(item.taskStatus)))
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const shopDataCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentItems.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toShopDataCrawlTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toShopDataCrawlTaskView))
function itemSource(item: TaskItemView) {
return item.source as ShopDataCrawlHistoryItem
}
function toShopDataCrawlTaskView(item: ShopDataCrawlHistoryItem): TaskItemView {
return {
key: historyKey(item),
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.createdAt),
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : '',
statusText: statusText(item),
statusClass: statusClass(item.taskStatus),
extraLines: [
...(item.platform ? [`平台:${item.platform}`] : []),
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
],
source: item,
}
}
const isQueueBusy = computed(() => queueWorkerRunning.value || !!activeTaskId.value || pendingQueue.value.length > 0) const isQueueBusy = computed(() => queueWorkerRunning.value || !!activeTaskId.value || pendingQueue.value.length > 0)
const countryCheckboxRows = computed(() => { const countryCheckboxRows = computed(() => {
const selected = new Set(orderedCountryCodes.value) const selected = new Set(orderedCountryCodes.value)
@@ -566,20 +578,40 @@ onUnmounted(() => {
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer) if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
timers.clearScope() timers.clearScope()
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
.module-page { min-height: 100vh; background: #151a25; } .module-page { min-height: 100vh; background: #151a25; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); } .main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 400px; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; background: #1c2333; } .left-panel { width: 400px; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; background: #1c2333; }
.right-panel { flex: 1; min-width: 0; background: #151a25; } .right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; } .section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
.input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; } .input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; }
.input-row { display: flex; gap: 10px; } .input-row { display: flex; gap: 10px; }
.opt-btn, .btn-run { min-height: 36px; padding: 0 16px; border: 0; border-radius: 5px; background: #409eff; color: #f5f8fc; white-space: nowrap; cursor: pointer; } .opt-btn, .btn-run { min-height: 36px; padding: 0 16px; border: 0; border-radius: 5px; background: #409eff; color: #f5f8fc; white-space: nowrap; cursor: pointer; }
.opt-btn { flex: 0 0 auto; } .opt-btn { flex: 0 0 auto; }
.opt-btn:disabled, .btn-run:disabled { opacity: .5; cursor: not-allowed; } .opt-btn:disabled, .btn-run:disabled { opacity: .5; cursor: not-allowed; }
.empty-candidates, .empty-tasks { padding: 16px; border: 1px dashed #333; border-radius: 6px; color: #5e6878; font-size: 13px; } .empty-candidates { padding: 16px; border: 1px dashed #333; border-radius: 6px; color: #5e6878; font-size: 13px; }
.candidate-table-scroll { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 6px; overflow: hidden; } .candidate-table-scroll { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 6px; overflow: hidden; }
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; } .candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.link-danger { border: 0; background: transparent; color: #f56c6c; cursor: pointer; } .link-danger { border: 0; background: transparent; color: #f56c6c; cursor: pointer; }
@@ -595,25 +627,14 @@ onUnmounted(() => {
.country-pref-status, .queue-status { color: #8dc4ff; font-size: 12px; line-height: 1.5; } .country-pref-status, .queue-status { color: #8dc4ff; font-size: 12px; line-height: 1.5; }
.run-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; } .run-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
.btn-queue { background: #67c23a; } .btn-queue { background: #67c23a; }
.panel-header { height: 52px; padding: 16px 22px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 700; } .subsection-title { margin: 18px 0 10px; color: #a0acbe; font-size: 13px; font-weight: 700; }
.task-list-wrap { height: calc(100% - 52px); padding: 18px 22px 28px; overflow-y: auto; } .match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 16px; text-align: center; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(100px, 1fr)); gap: 12px; margin-bottom: 20px; }
.summary-card { display: flex; min-height: 70px; flex-direction: column; justify-content: center; padding: 12px 16px; border: 1px solid #333f55; border-radius: 6px; background: #222; }
.summary-label { margin-bottom: 4px; color: #5e6878; font-size: 12px; }
.summary-card strong { color: #f5f8fc; font-size: 22px; }
.subsection-title, .result-subsection-title { margin: 18px 0 10px; color: #a0acbe; font-size: 13px; font-weight: 700; }
.match-table { margin-bottom: 20px; --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #292929; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; } .match-table { margin-bottom: 20px; --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #292929; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #67c23a; }.fail, .error-text { color: #f56c6c; } .ok { color: #67c23a; }
.result-list-header { padding: 13px 0; border-bottom: 1px solid #333f55; color: #f5f8fc; font-weight: 700; } .files { font-size: 12px; color: #5e6878; }
.task-list { margin: 0; padding: 0; list-style: none; } .fail, .error-text { color: #f56c6c; }
:deep(.task-item) { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 84px; padding: 14px 0; border-bottom: 1px solid #2b2b2b; } .download, .btn-delete { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #c8d2e2; cursor: pointer; }
:deep(.split-result-main) { min-width: 0; } .download { border-color: #409eff; color: #8dc4ff; }
:deep(.id) { display: block; margin-bottom: 5px; color: #f5f8fc; font-weight: 700; } .btn-delete { color: #f56c6c; }
:deep(.files) { margin-top: 3px; color: #5e6878; font-size: 12px; } @media (max-width: 900px) { .main-content { height: auto; flex-direction: column; }.left-panel { width: 100%; border-right: 0; } }
:deep(.task-right) { display: flex; align-items: center; gap: 10px; }
:deep(.status) { min-width: 88px; font-size: 12px; text-align: center; white-space: nowrap; }
:deep(.status.success) { color: #67c23a; }:deep(.status.failed) { color: #f56c6c; }:deep(.status.running) { color: #e6a23c; }
:deep(.download), :deep(.btn-delete) { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #c8d2e2; cursor: pointer; }
:deep(.download) { border-color: #409eff; color: #8dc4ff; }:deep(.btn-delete) { color: #f56c6c; }
@media (max-width: 900px) { .main-content { height: auto; flex-direction: column; }.left-panel { width: 100%; border-right: 0; }.clean-result-summary { grid-template-columns: repeat(2, 1fr); } }
</style> </style>
@@ -5,7 +5,7 @@
<aside class="left-panel"> <aside class="left-panel">
<div class="section-title">店铺候选</div> <div class="section-title">店铺候选</div>
<div class="input-zone"> <div class="input-zone">
<div class="hint">输入店铺名后点添加加入下方备选区勾选备选店铺后先做匹配店铺确认命中紫鸟索引后再推送到 Python 队列</div> <div class="hint">输入店铺名后点添加加入下方备选区勾选备选店铺后先做匹配店铺确认命中紫鸟索引后再启动任务</div>
<div class="input-row"> <div class="input-row">
<el-input v-model="shopInput" clearable placeholder="请输入店铺名后回车或点击添加" @keyup.enter="confirmAdd" /> <el-input v-model="shopInput" clearable placeholder="请输入店铺名后回车或点击添加" @keyup.enter="confirmAdd" />
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">{{ adding ? '添加中...' : '添加' }}</button> <button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">{{ adding ? '添加中...' : '添加' }}</button>
@@ -39,7 +39,7 @@
</div> </div>
<div v-if="countryPrefSaving" class="country-pref-status">保存中...</div> <div v-if="countryPrefSaving" class="country-pref-status">保存中...</div>
<div class="section-title">商品列表筛选</div> <div class="section-title">商品列表筛选</div>
<p class="hint listing-filter-hint">推送到 Python 队列一并下发 Python 区分处理场景</p> <p class="hint listing-filter-hint">启动任务一并下发 Python 区分处理场景</p>
<div class="listing-filter-row"> <div class="listing-filter-row">
<el-select v-model="shopMatchListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型"> <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-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
@@ -59,47 +59,48 @@
<div class="run-row"> <div class="run-row">
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button> <button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '推送中...' : scheduleEnabled ? '创建定时任务' : '推送到 Python 队列' }}</button> <button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '启动中...' : scheduleEnabled ? '创建定时任务' : '启动任务' }}</button>
</div> </div>
<p class="loading-msg">已支持多阶段定时执行Python 每处理完一店可回传 <code>/api/shop-match/tasks/{taskId}/result</code>后端会按阶段汇总并在超时场景自动收尾</p> <p class="loading-msg">已支持多阶段定时执行Python 每处理完一店可回传 <code>/api/shop-match/tasks/{taskId}/result</code>后端会按阶段汇总并在超时场景自动收尾</p>
<div v-if="queuePushResult" class="queue-debug-card"><div class="section-title queue-debug-title">推送结果</div><div class="queue-debug-line">{{ queuePushResult }}</div><pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre></div> <div v-if="queuePushResult" class="queue-debug-card"><div class="section-title queue-debug-title">推送结果</div><div class="queue-debug-line">{{ queuePushResult }}</div><pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre></div>
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">匹配与任务</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="匹配与任务"
<div class="summary-card"><span class="summary-label">候选店铺</span><strong>{{ dashboard.candidateCount }}</strong></div> :cards="shopMatchCards"
<div class="summary-card"><span class="summary-label">已结束任务</span><strong>{{ dashboard.processedTaskCount }}</strong></div> :current-items="currentTaskViews"
<div class="summary-card"><span class="summary-label">成功</span><strong>{{ dashboard.successTaskCount }}</strong></div> :history-items="historyTaskViews"
<div class="summary-card"><span class="summary-label">失败</span><strong>{{ dashboard.failedTaskCount }}</strong></div> current-empty-text="暂无当前任务。完成「匹配店铺」并启动任务后,将在此展示处理进度"
</div> history-empty-text="暂无历史记录"
<div class="subsection-title">匹配结果</div> >
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div> <template #cards-extra>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table"> <div class="subsection-title">匹配结果</div>
<el-table-column prop="shopName" label="店铺名" min-width="100" /> <div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table-column label="匹配" width="72" align="center"><template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template></el-table-column> <el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table">
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip /> <el-table-column prop="shopName" label="店铺" min-width="100" />
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip /> <el-table-column label="匹配" width="72" align="center"><template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template></el-table-column>
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip /> <el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
<el-table-column label="状态" width="100" show-overflow-tooltip><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column> <el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
<el-table-column label="说明" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatMatchRemark(row) }}</template></el-table-column> <el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
<el-table-column label="操作" width="72" align="center"><template #default="{ row }"><button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button></template></el-table-column> <el-table-column label="状态" width="100" show-overflow-tooltip><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
</el-table> <el-table-column label="说明" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatMatchRemark(row) }}</template></el-table-column>
<div class="result-list-wrap"> <el-table-column label="操作" width="72" align="center"><template #default="{ row }"><button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button></template></el-table-column>
<div class="result-list-header"><span>处理记录</span></div> </el-table>
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录推送队列并等待 Python 回传后将在此显示下载链接</div> </template>
<template v-else> <template #item-extra="{ item }">
<div v-if="currentSectionItems.length" class="result-subsection"> <div v-if="currentTaskStageText(itemSource(item))" class="files">{{ currentTaskStageText(itemSource(item)) }}</div>
<div class="result-subsection-title">当前任务</div> <div v-if="nextScheduledDisplay(itemSource(item))" class="files">下一次: {{ nextScheduledDisplay(itemSource(item)) }}</div>
<ul class="task-list"><li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`" class="task-item"><div class="left split-result-main"><span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span><div class="files">任务 ID: {{ item.taskId ?? '-' }}</div><div v-if="item.platform" class="files">平台: {{ item.platform }}</div><div v-if="currentTaskStageText(item)" class="files">{{ currentTaskStageText(item) }}</div><div v-if="nextScheduledDisplay(item)" class="files">下一次: {{ nextScheduledDisplay(item) }}</div><div v-if="item.error" class="files">错误: {{ item.error }}</div></div><div class="task-right"><span class="status" :class="statusClass(item)">{{ statusText(item) }}</span><button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button><button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button></div></li></ul> </template>
</div> <template #item-actions="{ item }">
<div v-if="historySectionItems.length" class="result-subsection"> <button v-if="canDownload(itemSource(item))" type="button" class="download" @click="downloadResult(itemSource(item))">下载</button>
<div class="result-subsection-title">历史任务</div> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
<ul class="task-list"><li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item"><div class="left split-result-main"><span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span><div class="files">任务 ID: {{ item.taskId ?? '-' }}</div><div v-if="item.outputFilename" class="files">文件: {{ item.outputFilename }}</div><div v-if="item.error" class="files">错误: {{ item.error }}</div></div><div class="task-right"><span class="status" :class="statusClass(item)">{{ statusText(item) }}</span><button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button><button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button></div></li></ul> </template>
</div> <template #history-item-actions="{ item }">
</template> <button v-if="canDownload(itemSource(item))" type="button" class="download" @click="downloadResult(itemSource(item))">下载</button>
</div> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</div> </template>
</TaskCenterPanel>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -110,6 +111,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue' import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters' import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules' import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
@@ -145,6 +148,11 @@ const historyItems = ref<ShopMatchHistoryItem[]>([])
const pollingTaskIds = ref<number[]>([]) const pollingTaskIds = ref<number[]>([])
const taskDetails = ref<Record<number, string>>({}) const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, ShopMatchTaskDetailVo>>({}) const taskSnapshots = ref<Record<number, ShopMatchTaskDetailVo>>({})
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
function snapTask(item: ShopMatchHistoryItem) {
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
}
const pollTimer = ref<number | null>(null) const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false) const pollingInFlight = ref(false)
const dispatchTimers = new Map<number, number>() const dispatchTimers = new Map<number, number>()
@@ -156,7 +164,40 @@ let disposed = false
const timers = createCategorizedTimers('shop-match') const timers = createCategorizedTimers('shop-match')
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current')) const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current'))
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history')) const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history'))
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const shopMatchCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toShopMatchTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toShopMatchTaskView))
function itemSource(item: TaskItemView) {
return item.source as ShopMatchHistoryItem
}
function toShopMatchTaskView(item: ShopMatchHistoryItem): TaskItemView {
const task = snapTask(item)
return {
key: taskRowKey(item),
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(task?.createdAt),
finishedAt: formatDateTime(task?.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.platform ? [`平台: ${item.platform}`] : []),
...(item.outputFilename ? [`文件: ${item.outputFilename}`] : []),
...(item.error ? [`错误: ${item.error}`] : []),
],
source: item,
}
}
const countryCheckboxRows = computed(() => { const selectedSet = new Set(orderedCountryCodes.value); return [...orderedCountryCodes.value.map((code) => ({ code, label: countryLabel(code) })), ...COUNTRY_OPTIONS.filter((item) => !selectedSet.has(item.code)).map((item) => ({ code: item.code, label: item.label }))] }) const countryCheckboxRows = computed(() => { const selectedSet = new Set(orderedCountryCodes.value); return [...orderedCountryCodes.value.map((code) => ({ code, label: countryLabel(code) })), ...COUNTRY_OPTIONS.filter((item) => !selectedSet.has(item.code)).map((item) => ({ code: item.code, label: item.label }))] })
function uidForStorage() { return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0' } function uidForStorage() { return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0' }
@@ -507,6 +548,26 @@ function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } } async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }) onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() }) onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -560,39 +621,18 @@ onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); cle
.queue-debug-title { margin-bottom: 8px; } .queue-debug-title { margin-bottom: 8px; }
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; } .queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; } .queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; }
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; }
.summary-label { font-size: 12px; color: #5e6878; }
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; } .subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; } .match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.empty-tasks.narrow { padding: 12px 8px; }
.match-table { margin-bottom: 18px; } .match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell),.match-table :deep(.el-table__body tr.hover-row > td.el-table__cell),.match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; } .match-table :deep(.el-table__body tr:hover > td.el-table__cell),.match-table :deep(.el-table__body tr.hover-row > td.el-table__cell),.match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; } .result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; } .ok { color: #27ae60; }
.fail { color: #e67e22; } .fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 200px; margin-top: 8px; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
.files { font-size: 12px; color: #5e6878; } .files { font-size: 12px; color: #5e6878; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; } .download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.download:hover { background: rgba(52, 152, 219, 0.28); } .download:hover { background: rgba(52, 152, 219, 0.28); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; } .btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); } .btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-row { flex-direction: column; align-items: stretch; } } @media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .schedule-row { flex-direction: column; align-items: stretch; } }
</style> </style>
@@ -65,7 +65,7 @@
</button> </button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" <button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId"
@click="pushToPythonQueue"> @click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }} {{ pushing ? '启动中...' : '启动任务' }}
</button> </button>
</div> </div>
<p class="loading-msg">Python 回传字段按 idasin国家价格标题图片链接数组提交后端接收分片后按 10 条一批送检结果区展示真实 LLM 批次进度</p> <p class="loading-msg">Python 回传字段按 idasin国家价格标题图片链接数组提交后端接收分片后按 10 条一批送检结果区展示真实 LLM 批次进度</p>
@@ -80,104 +80,32 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">货源查询</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="货源查询"
<div class="summary-card"> :cards="asinCards"
<span class="summary-label">运行中任务</span> :current-items="currentTaskViews"
<strong>{{ dashboard.pendingTaskCount }}</strong> :history-items="historyTaskViews"
</div> current-title="当前任务"
<div class="summary-card"> current-empty-text="暂无当前任务"
<span class="summary-label">已结束任务</span> history-empty-text="暂无历史记录"
<strong>{{ dashboard.processedTaskCount }}</strong> >
</div> <template #item-extra="{ item }">
<div class="summary-card"> <div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
<span class="summary-label">成功任务</span> </template>
<strong>{{ dashboard.successTaskCount }}</strong> <template #history-item-extra="{ item }">
</div> <div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
<div class="summary-card"> </template>
<span class="summary-label">失败任务</span> <template #item-actions="{ item }">
<strong>{{ dashboard.failedTaskCount }}</strong> <button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</div> </template>
</div> <template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
<div class="subsection-title">匹配任务</div> @click="downloadResult(itemSource(item))">下载</button>
<div class="result-list-wrap"> <button v-if="itemSource(item).resultId" type="button" class="btn-delete"
<div class="result-list-header"> @click="deleteTaskRecord(itemSource(item))">删除</button>
<span>当前任务</span> </template>
</div> </TaskCenterPanel>
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressStage(item, '处理中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</span>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>历史记录</span>
</div>
<div v-if="!historyOnlyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyOnlyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressStage(item, '结果生成中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download"
@click="downloadResult(item)">下载</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</div>
</section> </section>
</div> </div>
@@ -190,6 +118,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue' import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { import {
activateSimilarAsinTask, activateSimilarAsinTask,
deleteSimilarAsinHistory, deleteSimilarAsinHistory,
@@ -301,6 +231,48 @@ const historyOnlyItems = computed(() =>
}), }),
) )
const asinCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toAsinTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historyOnlyItems.value.map(toAsinTaskView))
function itemSource(item: TaskItemView): SimilarAsinHistoryItem {
return item.source as SimilarAsinHistoryItem
}
function toAsinTaskView(item: SimilarAsinHistoryItem): TaskItemView {
const showProgress = showFileProgress(item)
const isRunning = normalizeTaskStatus(item) === 'RUNNING'
return {
key: `asin-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '货源查询',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
...(item.resultFilename && !showProgress ? [item.resultFilename] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
progress: showProgress
? {
percent: fileProgressPercent(item),
stage: displayFileProgressStage(item, isRunning ? '处理中' : '结果生成中'),
countLabel: hasFileProgressCount(item) ? fileProgressCountLabel(item) : undefined,
}
: null,
source: item,
}
}
function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) { function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) {
if (item.taskId == null) return item if (item.taskId == null) return item
const liveItem = liveProgressItems.value[item.taskId] const liveItem = liveProgressItems.value[item.taskId]
@@ -599,7 +571,7 @@ async function pushToPythonQueue() {
clearParsedTask() clearParsedTask()
await loadDashboard() await loadDashboard()
await loadHistory({ force: true }) await loadHistory({ force: true })
ElMessage.success('已推送到 Python 队列') ElMessage.success('已启动任务')
} finally { } finally {
pushing.value = false pushing.value = false
} }
@@ -1122,6 +1094,26 @@ onUnmounted(() => {
stopPolling() stopPolling()
timers.clearScope() timers.clearScope()
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1152,8 +1144,7 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
} }
.section-title, .section-title {
.subsection-title {
font-size: 13px; font-size: 13px;
color: #a0acbe; color: #a0acbe;
margin-bottom: 10px; margin-bottom: 10px;
@@ -1368,172 +1359,11 @@ onUnmounted(() => {
white-space: pre-wrap; white-space: pre-wrap;
} }
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.task-list-wrap {
flex: 1;
padding: 16px 20px;
overflow: auto;
}
.clean-result-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 8px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
color: #f5f8fc;
font-size: 22px;
}
.summary-label {
color: #5e6878;
font-size: 12px;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 8px;
background: #1c2333;
min-height: 180px;
margin: 0 0 16px;
}
.result-list-header {
display: flex;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2e3a52;
color: #c8d2e2;
font-size: 14px;
}
.empty-tasks {
color: #5e6878;
font-size: 13px;
padding: 18px;
text-align: center;
}
.task-list {
list-style: none;
margin: 0;
padding: 12px;
}
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 8px;
background: #222;
}
.left {
flex: 1;
min-width: 0;
}
.id {
color: #f5f8fc;
font-size: 13px;
font-weight: 600;
}
.task-right {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
}
.status.success {
background: rgba(46, 204, 113, .18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, .18);
color: #ff6b6b;
}
.status.running {
background: rgba(52, 152, 219, .18);
color: #3498db;
}
.status.pending {
background: rgba(149, 165, 166, .18);
color: #a0acbe;
}
.result-hint { .result-hint {
margin-top: 6px; margin-top: 6px;
color: #e0b96d; color: #e0b96d;
} }
.file-progress {
margin-top: 8px;
max-width: 520px;
}
.file-progress-meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: #d8c278;
font-size: 12px;
}
.file-progress-track {
margin-top: 5px;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: #333f55;
border: 1px solid #3b3b3b;
}
.file-progress-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #4aa3ff, #f0c75e);
transition: width .25s ease;
}
.file-progress-count {
margin-top: 4px;
color: #858585;
font-size: 11px;
}
.download { .download {
padding: 6px 10px; padding: 6px 10px;
color: #c8d2e2; color: #c8d2e2;
@@ -1557,9 +1387,5 @@ onUnmounted(() => {
border-right: none; border-right: none;
border-bottom: 1px solid #2e3a52; border-bottom: 1px solid #2e3a52;
} }
.clean-result-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
</style> </style>
@@ -98,65 +98,37 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">拆分结果</div> <TaskCenterPanel
<div class="task-list-wrap"> :on-batch-delete="batchDeleteHistory"
<div class="clean-result-summary"> title="拆分结果"
<div class="summary-card"> :cards="splitCards"
<span class="summary-label">已处理文件</span> :current-items="currentTaskViews"
<strong>{{ splitSummary.total }}</strong> :history-items="historyTaskViews"
</div> current-title="当前任务"
<div class="summary-card"> current-empty-text="暂无当前任务,完成数据拆分后会在这里展示"
<span class="summary-label">成功结果</span> history-empty-text="暂无历史记录"
<strong>{{ splitSummary.successCount }}</strong> >
</div> <template #history-item-actions="{ item }">
<div class="summary-card"> <template v-if="itemSource(item)">
<span class="summary-label">失败文件</span> <button
<strong>{{ splitSummary.failedCount }}</strong> v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
</div> type="button"
</div> class="download"
@click="downloadSplitResult(itemSource(item))"
<div class="result-list-wrap"> >
<div class="result-list-header"> 下载压缩包
<span>拆分结果压缩包列表</span> </button>
</div> <button
v-if="itemSource(item).resultId"
<div v-if="splitResultItems.length === 0" class="empty-tasks"> type="button"
暂无拆分结果完成数据拆分后会在这里展示压缩包文件 class="btn-delete"
</div> @click="deleteSplitHistoryRecord(itemSource(item).resultId!)"
>
<ul v-else class="task-list clean-result-list"> 删除
<li v-for="item in splitResultItems" </button>
:key="`${item.resultId || item.outputFilename || item.sourceFilename}-${item.rowCount || 0}`" </template>
class="task-item split-result-item"> </template>
<div class="left split-result-main"> </TaskCenterPanel>
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">压缩包文件{{ item.outputFilename }}</div>
<div v-if="item.rowCount !== undefined" class="time">压缩包共包含 {{ item.rowCount }} 条数据</div>
<div v-if="item.entryCount && item.entryCount > 0" class="files">压缩包内共 {{ item.entryCount }} 个拆分文件
</div>
<div v-if="item.entries && item.entries.length" class="files split-entry-list">
压缩包内容{{ formatSplitEntries(item.entries) }}
</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
@click="downloadSplitResult(item)">
下载压缩包
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteSplitHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</div>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -167,8 +139,10 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand' import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules' import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { saveUrlWithProgress } from '@/shared/utils/download-progress' import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard' import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
@@ -185,15 +159,79 @@ const splitRowsPerFile = ref<number | null>(2000)
const splitParts = ref<number | null>(5) const splitParts = ref<number | null>(5)
const splitRunning = ref(false) const splitRunning = ref(false)
const splitResultItems = ref<SplitResultItem[]>([]) const splitResultItems = ref<SplitResultItem[]>([])
const splitSummary = ref<SplitRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const splitDisplayPaths = computed(() => splitSelectedPaths.value.slice(0, 8)) const splitDisplayPaths = computed(() => splitSelectedPaths.value.slice(0, 8))
function updateSplitSummaryFromItems(items: SplitResultItem[]) { // ---- TaskCenterPanel----
splitSummary.value = { const splitCards = computed<TaskStatCard[]>(() => [
total: items.length, { label: '运行中任务', value: currentTaskViews.value.length },
successCount: items.filter((item) => item.success).length, { label: '已结束任务', value: historyTaskViews.value.length },
failedCount: items.filter((item) => !item.success).length, { label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
items, { label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function isSplitTaskBusy(item: SplitResultItem) {
const status = (item.taskStatus || '').toUpperCase()
return status === 'RUNNING' || status === 'PENDING'
}
/** 当前任务:运行中/待执行项(拆分接口通常同步完成即出结果,正常为空) */
const currentTaskViews = computed<TaskItemView[]>(() =>
splitResultItems.value.filter(isSplitTaskBusy).map(toSplitTaskView),
)
/** 历史任务:全部已结束的结果项 */
const historyTaskViews = computed<TaskItemView[]>(() =>
splitResultItems.value.filter((item) => !isSplitTaskBusy(item)).map(toSplitTaskView),
)
function itemSource(item: TaskItemView): SplitResultItem {
return item.source as SplitResultItem
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function splitTaskStatusText(item: SplitResultItem) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING') return '执行中'
if (status === 'PENDING') return '等待中'
return item.success ? '已完成' : '失败'
}
function splitTaskStatusClass(item: SplitResultItem) {
const status = (item.taskStatus || '').toUpperCase()
if (status === 'RUNNING') return 'running'
if (status === 'PENDING') return 'pending'
return item.success ? 'success' : 'failed'
}
function toSplitTaskView(item: SplitResultItem): TaskItemView {
return {
key: `split-${item.resultId ?? `${item.sourceFilename}-${item.rowCount ?? 0}`}`,
title: item.sourceFilename || '数据拆分',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: splitTaskStatusText(item),
statusClass: splitTaskStatusClass(item),
extraLines: [
...(item.outputFilename ? [`压缩包文件:${item.outputFilename}`] : []),
...(item.rowCount !== undefined ? [`压缩包共包含 ${item.rowCount} 条数据`] : []),
...(item.entryCount && item.entryCount > 0 ? [`压缩包内共 ${item.entryCount} 个拆分文件`] : []),
...(item.entries && item.entries.length ? [`压缩包内容:${formatSplitEntries(item.entries)}`] : []),
...(item.error ? [`错误信息:${item.error}`] : []),
],
source: item,
} }
} }
@@ -314,7 +352,6 @@ async function submitSplitRun() {
? splitArchiveName.value ? splitArchiveName.value
: undefined, : undefined,
}) })
splitSummary.value = result
splitResultItems.value = result.items || [] splitResultItems.value = result.items || []
await loadSplitHistory() await loadSplitHistory()
if (result.total > 0 && result.successCount === 0) { if (result.total > 0 && result.successCount === 0) {
@@ -341,7 +378,6 @@ async function loadSplitHistory() {
try { try {
const response = await getSplitHistory() const response = await getSplitHistory()
splitResultItems.value = response.items || [] splitResultItems.value = response.items || []
updateSplitSummaryFromItems(splitResultItems.value)
} catch { } catch {
// ignore history load errors // ignore history load errors
} }
@@ -387,6 +423,26 @@ async function downloadSplitResult(item: SplitResultItem) {
onMounted(() => { onMounted(() => {
loadSplitHistory().catch(() => undefined) loadSplitHistory().catch(() => undefined)
}) })
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteSplitHistoryRecord(itemSource(view).resultId!)
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -685,110 +741,12 @@ onMounted(() => {
font-size: 13px; font-size: 13px;
} }
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2e3a52;
font-size: 15px;
font-weight: 600;
color: #c8d2e2;
}
.task-list-wrap {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 10px;
background: #1c2333;
}
.split-result-item {
align-items: flex-start;
}
.left {
flex: 1;
min-width: 0;
}
.split-result-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.id {
display: inline-block;
font-weight: 600;
color: #f5f8fc;
font-size: 13px;
}
.files { .files {
font-size: 12px; font-size: 12px;
color: #5e6878; color: #5e6878;
margin-top: 4px; margin-top: 4px;
} }
.time {
font-size: 12px;
color: #5e6878;
margin-top: 2px;
}
.split-entry-list {
line-height: 1.6;
word-break: break-all;
max-width: 100%;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.split-result-actions {
flex-shrink: 0;
min-width: 180px;
justify-content: flex-end;
align-self: center;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.download { .download {
padding: 6px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
@@ -813,60 +771,6 @@ onMounted(() => {
background: rgba(231, 76, 60, 0.22); background: rgba(231, 76, 60, 0.22);
} }
.empty-tasks {
color: #5e6878;
font-size: 13px;
padding: 24px 8px;
}
.clean-placeholder {
max-height: 112px;
}
.clean-result-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 220px));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 16px 18px;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #f5f8fc;
}
.summary-label {
font-size: 12px;
color: #5e6878;
}
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 14px;
color: #c8d2e2;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { .main-content {
flex-direction: column; flex-direction: column;
@@ -882,24 +786,5 @@ onMounted(() => {
.right-panel { .right-panel {
min-height: 420px; min-height: 420px;
} }
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.split-result-actions {
min-width: 0;
align-self: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
} }
</style> </style>
@@ -5,7 +5,7 @@
<div v-if="$slots.logoExtra" class="logo-extra"> <div v-if="$slots.logoExtra" class="logo-extra">
<slot name="logoExtra" /> <slot name="logoExtra" />
</div> </div>
<a v-if="showHomeLink" href="/home" class="btn-home">返回首页</a> <router-link v-if="showHomeLink" to="/home" class="btn-home">返回首页</router-link>
</div> </div>
<nav v-if="showNav" class="nav-sections" aria-label="顶部功能导航"> <nav v-if="showNav" class="nav-sections" aria-label="顶部功能导航">
@@ -13,9 +13,9 @@
<div class="nav-section-title">{{ group.label }}</div> <div class="nav-section-title">{{ group.label }}</div>
<div class="nav-section-items"> <div class="nav-section-items">
<template v-for="item in group.items" :key="item.key"> <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 }"> <router-link v-if="item.href" :to="item.href" class="nav-item" :class="{ active: active === item.key }">
{{ item.label }} {{ item.label }}
</a> </router-link>
<span v-else class="nav-item disabled" :class="{ active: active === item.key }"> <span v-else class="nav-item disabled" :class="{ active: active === item.key }">
{{ item.label }} {{ item.label }}
</span> </span>
@@ -94,30 +94,30 @@ const navGroups: ReadonlyArray<NavGroup> = [
columnKey: 'brand_front_tools', columnKey: 'brand_front_tools',
label: '前端工具', label: '前端工具',
items: [ items: [
{ key: 'collect-data', label: '采集数据', href: '/new_web_source/collect-data.html' }, { key: 'collect-data', label: '采集数据', href: '/collect-data' },
{ key: 'variant-collection', label: 'ASIN变体采集', href: '/new_web_source/variant-collection.html' }, { key: 'variant-collection', label: 'ASIN变体采集', href: '/variant-collection' },
// { key: 'image-video', label: '', href: '/new_web_source/image-video.html' }, // { key: 'image-video', label: '', href: '/image-video' },
{ key: 'brand', label: '品牌检测', href: '/new_web_source/brand.html' }, { key: 'brand', label: '品牌检测', href: '/brand' },
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' }, { key: 'appearance-patent', label: '外观专利检测', href: '/appearance-patent' },
{ key: 'similar-asin', label: '货源查询', href: '/new_web_source/similar-asin.html' }, { key: 'similar-asin', label: '货源查询', href: '/similar-asin' },
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' }, { key: 'dedupe', label: '数据去重', href: '/dedupe' },
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' }, { key: 'split', label: '数据拆分', href: '/split' },
{ key: 'convert', label: '格式转换', href: '/new_web_source/convert.html' }, { key: 'convert', label: '格式转换', href: '/convert' },
], ],
}, },
{ {
columnKey: 'brand_operation_tools', columnKey: 'brand_operation_tools',
label: '运营工具', label: '运营工具',
items: [ items: [
{ key: 'publish', label: '上架', href: '/new_web_source/publish.html' }, { key: 'publish', label: '上架', href: '/publish' },
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' }, { key: 'delete-brand', label: '删除ASIN', href: '/delete-brand' },
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' }, { key: 'product-risk', label: '商品风险解决', href: '/product-risk' },
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' }, { key: 'shop-match', label: '定时匹配', href: '/shop-match' },
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html', aliases: ['price-track'] }, { key: 'pricing', label: '跟价', href: '/price-track', aliases: ['price-track'] },
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' }, { key: 'patrol-delete', label: '巡店删除', href: '/patrol-delete' },
{ key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' }, { key: 'query-asin', label: '查询ASIN', href: '/query-asin' },
{ key: 'shop-data-crawl', label: '店铺数据抓取', href: '/new_web_source/shop-data-crawl.html', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] }, { key: 'shop-data-crawl', label: '店铺数据抓取', href: '/shop-data-crawl', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] },
{ key: 'withdraw', label: '取款', href: '/new_web_source/withdraw.html' }, { key: 'withdraw', label: '取款', href: '/withdraw' },
{ key: 'shop-status', label: '店铺状态查询' }, { key: 'shop-status', label: '店铺状态查询' },
], ],
}, },
@@ -86,45 +86,9 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header"> <div class="panel-toolbar">
<span>变体采集任务</span> <button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
<div class="panel-actions"> <div v-if="totalCount" class="toolbar-pagination">
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
</div>
</div>
<div class="task-list-wrap">
<div v-if="!currentTasks.length" class="empty-tasks">暂无变体采集任务</div>
<ul v-else class="task-list">
<li v-for="item in currentTasks" :key="`vc-${item.task_id}`" class="task-item">
<div class="left">
<span class="id">任务 {{ item.task_id }}</span>
<div v-if="taskMeta(item).source" class="files">来源{{ taskMeta(item).source }}</div>
<div class="files">采集国家{{ countryText(item) || '-' }}</div>
<div class="files">更新时间{{ item.update_time || formatDateTime(item.updated_at) }}</div>
<div v-if="latestMessage(item)" class="files">{{ latestMessage(item) }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
<button type="button" class="act-btn" @click="refreshTask(item)">更新</button>
<button type="button" class="act-btn" @click="showDetail(item)">详情</button>
<button
v-if="hasResultFiles(item)"
type="button"
class="act-btn ok"
@click="downloadResult(item)"
>下载结果</button>
<button
v-if="hasSourceFile(item)"
type="button"
class="act-btn"
@click="downloadSource(item)"
>下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(item)">重新导出</button>
</div>
</li>
</ul>
<div v-if="currentTasks.length" class="pagination">
<span class="page-info"> {{ totalCount }} · {{ currentPage }} / {{ totalPages }} </span> <span class="page-info"> {{ totalCount }} · {{ currentPage }} / {{ totalPages }} </span>
<div class="page-btns"> <div class="page-btns">
<button type="button" class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button> <button type="button" class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
@@ -132,6 +96,38 @@
</div> </div>
</div> </div>
</div> </div>
<TaskCenterPanel
title="变体采集任务"
:cards="variantCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无运行中的变体采集任务"
history-empty-text="暂无历史记录"
>
<template #item-actions="{ item }">
<template v-if="itemSource(item)">
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
@click="downloadSource(itemSource(item))">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
</template>
</template>
<template #history-item-actions="{ item }">
<template v-if="itemSource(item)">
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
@click="downloadSource(itemSource(item))">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
</template>
</template>
</TaskCenterPanel>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -166,6 +162,8 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue' import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { import {
getPywebviewApi, getPywebviewApi,
type VariantTaskListItem, type VariantTaskListItem,
@@ -214,6 +212,59 @@ const canSubmit = computed(() =>
inputMode.value === 'asin' ? asinLines.value.length > 0 : selectedFiles.value.length > 0, inputMode.value === 'asin' ? asinLines.value.length > 0 : selectedFiles.value.length > 0,
) )
// ---- TaskCenterPanel----
/** 当前任务:排队中(0) / 执行中(1) 的任务(当前分页内) */
const currentTaskViews = computed<TaskItemView[]>(() =>
currentTasks.value.filter((item) => isBusy(item.status)).map(toVariantTaskView),
)
/** 历史任务:已完成(2) / 失败(3) 的任务(当前分页内) */
const historyTaskViews = computed<TaskItemView[]>(() =>
currentTasks.value.filter((item) => !isBusy(item.status)).map(toVariantTaskView),
)
const variantCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function itemSource(item: TaskItemView): VariantEntry {
return item.source as VariantEntry
}
function variantStatusClass(status?: number | string) {
const numeric = Number(status)
if (numeric === 1 || /running|processing/i.test(String(status))) return 'running'
if (numeric === 2 || /success|completed|done/i.test(String(status))) return 'success'
if (numeric === 3 || /failed|error/i.test(String(status))) return 'failed'
return 'pending'
}
function toVariantTaskView(item: VariantEntry): TaskItemView {
const extraLines: string[] = []
const source = item.meta?.source || ''
if (source) extraLines.push(`来源:${source}`)
extraLines.push(`采集国家:${countryText(item) || '-'}`)
const updateText = item.update_time || formatDateTime(item.updated_at)
if (updateText) extraLines.push(`更新时间:${updateText}`)
extraLines.push(latestMessage(item))
return {
key: `vc-${item.task_id}`,
title: `任务 ${item.task_id}`,
taskId: item.task_id,
// '-'
startedAt: '-',
//
finishedAt: formatDateTime(item.update_time),
statusText: statusText(item.status),
statusClass: variantStatusClass(item.status),
extraLines,
source: item,
}
}
let autoTimer: number | null = null let autoTimer: number | null = null
let refreshTimer: number | null = null let refreshTimer: number | null = null
@@ -651,26 +702,12 @@ onBeforeUnmount(() => {
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #27ae60; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; } .btn-run { padding: 10px 18px; color: #f5f8fc; background: #27ae60; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; } .btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-msg { margin-top: 10px; } .loading-msg { margin-top: 10px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 600; } .panel-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 20px 0; }
.panel-actions { display: flex; gap: 8px; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; } .btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; } .toolbar-pagination { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 28px; text-align: center; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 8px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.task-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.cancelled { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.act-btn { padding: 4px 9px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; } .act-btn { padding: 4px 9px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; } .act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
.act-btn.warn { background: rgba(230, 162, 60, 0.14); color: #e6a23c; border-color: transparent; } .act-btn.warn { background: rgba(230, 162, 60, 0.14); color: #e6a23c; border-color: transparent; }
.pagination { display: flex; justify-content: space-between; align-items: center; padding: 10px 0 4px; }
.page-info { color: #5e6878; font-size: 12px; } .page-info { color: #5e6878; font-size: 12px; }
.page-btns { display: flex; gap: 8px; } .page-btns { display: flex; gap: 8px; }
.page-btn { padding: 5px 12px; border-radius: 6px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; } .page-btn { padding: 5px 12px; border-radius: 6px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; }
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div> <div class="section-title">店铺输入</div>
<div class="input-zone"> <div class="input-zone">
<div class="hint"> <div class="hint">
左侧负责录入店铺并加入备选区确认命中后推送到 Python 队列每次推送会把本次选中的所有店铺合并为一条取款任务任务之间串行执行 左侧负责录入店铺并加入备选区确认命中后启动任务每次推送会把本次选中的所有店铺合并为一条取款任务任务之间串行执行
</div> </div>
<div class="input-row"> <div class="input-row">
<el-input <el-input
@@ -88,7 +88,7 @@
:disabled="isQueueBusy || !matchedRunnableItems.length" :disabled="isQueueBusy || !matchedRunnableItems.length"
@click="pushToPythonQueue" @click="pushToPythonQueue"
> >
{{ isQueueBusy ? "串行执行中..." : "推送到 Python 队列" }} {{ isQueueBusy ? "串行执行中..." : "启动任务" }}
</button> </button>
</div> </div>
@@ -106,223 +106,114 @@
</aside> </aside>
<section class="right-panel"> <section class="right-panel">
<div class="panel-header">匹配与任务</div> <div class="match-zone">
<div class="task-list-wrap"> <div class="match-zone-header">
<div class="clean-result-summary"> <span>匹配结果</span>
<div class="summary-card">
<span class="summary-label">备选店铺</span>
<strong>{{ dashboard.candidateCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已处理任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div> </div>
<div v-if="!matchedItems.length" class="match-zone-empty">
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">
完成匹配店铺这里会展示匹配结果 完成匹配店铺这里会展示匹配结果
</div> </div>
<el-table <div v-else class="match-zone-scroll">
v-else <el-table
:data="matchedItems" :data="matchedItems"
:row-key="rowKeyForMatch" :row-key="rowKeyForMatch"
:highlight-current-row="false" :highlight-current-row="false"
class="result-table match-table" class="result-table match-table"
>
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
删除
</button>
</template>
</el-table-column>
</el-table>
<div class="result-list-wrap">
<div class="result-list-header">
<span>任务记录</span>
</div>
<div
v-if="!currentSectionItems.length && !historySectionItems.length"
class="empty-tasks"
> >
暂无任务记录推送到 Python 队列后这里会展示当前任务和历史任务 <el-table-column prop="shopName" label="店铺名" min-width="120" />
</div> <el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<template v-else> <span :class="row.matched ? 'ok' : 'fail'">
<div v-if="currentSectionItems.length" class="result-subsection"> {{ row.matched ? "是" : "否" }}
<div class="result-subsection-title">当前任务</div> </span>
<ul class="task-list"> </template>
<li </el-table-column>
v-for="item in currentSectionItems" <el-table-column
:key="`current-${item.taskId || item.resultId}`" prop="shopId"
class="task-item" label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
> >
<div class="left split-result-main"> 删除
<span class="id" :title="item.shopName || ''"> </button>
取款任务 {{ item.taskId ?? "-" }} </template>
</span> </el-table-column>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div> </el-table>
<div class="files">包含店铺: {{ item.shopName || "-" }}</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div class="files">
取款结果: {{ formatTemplateSummary(item) }}
</div>
<div v-if="item.shops.length" class="shop-detail-list">
<div
v-for="shop in item.shops"
:key="`cur-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
<div v-if="item.error" class="files">
错误: {{ item.error }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史任务</div>
<ul class="task-list">
<li
v-for="item in historySectionItems"
:key="`history-${item.taskId || item.resultId}`"
class="task-item"
>
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">
取款任务 {{ item.taskId ?? "-" }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div class="files">包含店铺: {{ item.shopName || "-" }}</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div v-if="item.finishedAt" class="files">
完成时间: {{ formatDateTime(item.finishedAt) }}
</div>
<div class="files">
取款结果: {{ formatTemplateSummary(item) }}
</div>
<div v-if="item.shops.length" class="shop-detail-list">
<div
v-for="shop in item.shops"
:key="`his-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
<div v-if="item.error" class="files">
错误: {{ item.error }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
v-if="canDownload(item)"
type="button"
class="archive"
@click="downloadResult(item)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div> </div>
</div> </div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="withdrawCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务,启动任务后运行中的任务会显示在这里"
history-empty-text="暂无历史任务"
>
<template #item-extra="{ item }">
<div class="files">取款结果{{ formatTemplateSummary(itemSource(item)) }}</div>
<div v-if="itemSource(item).shops.length" class="shop-detail-list">
<div
v-for="shop in itemSource(item).shops"
:key="`cur-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
</template>
<template #history-item-extra="{ item }">
<div class="files">取款结果{{ formatTemplateSummary(itemSource(item)) }}</div>
<div v-if="itemSource(item).shops.length" class="shop-detail-list">
<div
v-for="shop in itemSource(item).shops"
:key="`his-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section> </section>
</div> </div>
</AmazonToolPageShell> </AmazonToolPageShell>
@@ -333,6 +224,8 @@
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed, onMounted, onUnmounted, ref } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'; import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { import {
addWithdrawCandidate, addWithdrawCandidate,
createWithdrawTask, createWithdrawTask,
@@ -419,6 +312,43 @@ const hasQueuedTaskWork = computed(
const hasQueueWork = computed(() => queueWorkerRunning.value || hasQueuedTaskWork.value); const hasQueueWork = computed(() => queueWorkerRunning.value || hasQueuedTaskWork.value);
const isQueueBusy = computed(() => queueWorkerRunning.value && hasQueuedTaskWork.value); const isQueueBusy = computed(() => queueWorkerRunning.value && hasQueuedTaskWork.value);
// ---- TaskCenterPanel----
const withdrawCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
]);
/** 当前任务:按任务分组后非终态的任务卡 */
const currentTaskViews = computed<TaskItemView[]>(() =>
currentSectionItems.value.map(toWithdrawTaskView),
);
/** 历史任务:按任务分组后已终态的任务卡 */
const historyTaskViews = computed<TaskItemView[]>(() =>
historySectionItems.value.map(toWithdrawTaskView),
);
function itemSource(item: TaskItemView): WithdrawTaskGroupItem {
return item.source as WithdrawTaskGroupItem;
}
/** 一任务一卡:标题优先店铺名(多店以“、”连接),其次结果文件名 */
function toWithdrawTaskView(item: WithdrawTaskGroupItem): TaskItemView {
return {
key: `withdraw-${item.taskId ?? item.resultId ?? item.shopName}`,
title: item.shopName || item.outputFilename || `取款任务 ${item.taskId ?? "-"}`,
taskId: item.taskId ?? "-",
startedAt: formatDateTime(taskStartTime(item.taskId) || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: item.error ? [`错误:${item.error}`] : [],
source: item,
};
}
function uidForStorage() { function uidForStorage() {
return typeof window !== "undefined" return typeof window !== "undefined"
? window.localStorage.getItem("uid") || "0" ? window.localStorage.getItem("uid") || "0"
@@ -604,7 +534,7 @@ function formatMatchRemark(row: WithdrawShopQueueItem) {
const message = (row.matchMessage || "").trim(); const message = (row.matchMessage || "").trim();
if (message) return message; if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") { if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可推送到 Python 队列"; return "索引已命中,可启动任务";
} }
if (row.matched) { if (row.matched) {
return "已命中索引,请结合状态列确认"; return "已命中索引,请结合状态列确认";
@@ -1313,6 +1243,26 @@ onUnmounted(() => {
clearSleepTimers(); clearSleepTimers();
timers.clearScope(); timers.clearScope();
}); });
/**
* 历史记录批量删除复用单条删除逻辑删除后原函数各自刷新列表
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -1347,43 +1297,74 @@ onUnmounted(() => {
.queue-debug-title { margin-bottom: 8px; } .queue-debug-title { margin-bottom: 8px; }
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; } .queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; } .queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #f5f8fc; border-bottom: 1px solid #2e3a52; } .match-zone {
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; } flex: 0 0 auto;
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } margin: 16px 20px 0;
.summary-card { padding: 14px 16px; border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; } border: 1px solid #2e3a52;
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #f5f8fc; } border-radius: 10px;
.summary-label { font-size: 12px; color: #5e6878; } background: #1c2333;
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; } overflow: hidden;
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; } }
.empty-tasks.narrow { padding: 12px 8px; }
.match-table { margin-bottom: 18px; } .match-zone-header {
padding: 10px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 13px;
color: #a0acbe;
}
.match-zone-empty {
padding: 18px 16px;
color: #5e6878;
font-size: 13px;
text-align: center;
}
.match-zone-scroll {
max-height: 260px;
overflow: auto;
}
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; } .match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; } .result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; } .ok { color: #27ae60; }
.fail { color: #e67e22; } .fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2e3a52; border-radius: 10px; background: #1c2333; min-height: 220px; margin-top: 8px; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2e3a52; font-size: 14px; color: #c8d2e2; }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #5e6878; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2e3a52; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #f5f8fc; font-size: 13px; }
.files { font-size: 12px; color: #5e6878; } .files { font-size: 12px; color: #5e6878; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; } .shop-detail-list {
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; } display: flex;
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; } flex-direction: column;
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; } gap: 2px;
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; } margin-top: 4px;
.archive:hover { background: rgba(52, 152, 219, 0.28); } }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
.download {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(52, 152, 219, 0.18);
color: #69b6ff;
border: none;
cursor: pointer;
}
.download:hover { background: rgba(52, 152, 219, 0.28); }
.btn-delete {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(231, 76, 60, 0.12);
color: #ff8f8f;
border: none;
cursor: pointer;
}
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); } .btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; } .main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
</style> </style>
+24 -62
View File
@@ -26,49 +26,34 @@
</div> </div>
</div> </div>
<span class="username-text">{{ username || '未登录' }}</span> <span class="username-text">{{ username || '未登录' }}</span>
<a :href="logoutHref" class="logout-link">退出</a> <router-link :to="logoutHref" class="logout-link">退出</router-link>
</div> </div>
</header> </header>
<div class="entrances"> <div class="entrances">
<a <router-link
v-if="canShow.amazon" v-if="canShow.amazon"
:href="amazonHref || undefined" :to="amazonHref"
class="entrance-btn" class="entrance-btn"
:class="{ disabled: !amazonHref }" :class="{ disabled: !amazonHref }"
data-column-key="brand" data-column-key="brand"
>亚马逊</a> >亚马逊</router-link>
<a
v-else-if="showAmazonDisabled"
class="entrance-btn disabled"
data-column-key="brand"
>亚马逊</a>
<a <router-link
v-if="canShow.video" v-if="canShow.video"
:href="videoHref || undefined" :to="videoHref"
class="entrance-btn wb" class="entrance-btn wb"
:class="{ disabled: !videoHref }" :class="{ disabled: !videoHref }"
data-column-key="wb" data-column-key="wb"
>视频</a> >视频</router-link>
<a
v-else-if="showVideoDisabled"
class="entrance-btn wb disabled"
data-column-key="wb"
>视频</a>
<a <router-link
v-if="canShow.image" v-if="canShow.image"
:href="imageHref || undefined" :to="imageHref"
class="entrance-btn image" class="entrance-btn image"
:class="{ disabled: !imageHref }" :class="{ disabled: !imageHref }"
data-column-key="image" data-column-key="image"
>图片</a> >图片</router-link>
<a
v-else-if="showImageDisabled"
class="entrance-btn image disabled"
data-column-key="image"
>图片</a>
</div> </div>
<div class="toast" :class="{ show: Boolean(toastText) }">{{ toastText }}</div> <div class="toast" :class="{ show: Boolean(toastText) }">{{ toastText }}</div>
@@ -77,8 +62,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { requestGetJson } from '@/shared/api/http' import { restoreLoginUser } from '@/shared/auth/ensure-auth'
import { buildJavaUrl } from '@/shared/api/url' import { getCurrentUserAppColumnRaw, type PermissionMenuItem } from '@/shared/api/permission'
import { getPywebviewApi, isDesktopRuntime } from '@/shared/bridges/pywebview' import { getPywebviewApi, isDesktopRuntime } from '@/shared/bridges/pywebview'
import { resolvePageHref } from '@/shared/page-prefix' import { resolvePageHref } from '@/shared/page-prefix'
@@ -90,7 +75,7 @@ const updateReady = ref(false)
const updateFileUrl = ref('') const updateFileUrl = ref('')
const toastText = ref('') const toastText = ref('')
// Flask /logout 线 tokenlogin.html?logout=1 // Flask /logout 线 token/login?logout=1
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`) const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`)
type PermissionState = { amazon: boolean; video: boolean; image: boolean } type PermissionState = { amazon: boolean; video: boolean; image: boolean }
@@ -105,10 +90,6 @@ const imageHref = computed(() => resolvePageHref('/new_web_source/image.html'))
const canShow = computed(() => const canShow = computed(() =>
permissionUnavailable.value ? { amazon: true, video: true, image: true } : permissionState.value, permissionUnavailable.value ? { amazon: true, video: true, image: true } : permissionState.value,
) )
// dev /image
const showAmazonDisabled = computed(() => !permissionUnavailable.value && permissionState.value.amazon && !amazonHref.value)
const showVideoDisabled = computed(() => !permissionUnavailable.value && permissionState.value.video && !videoHref.value)
const showImageDisabled = computed(() => !permissionUnavailable.value && permissionState.value.image && !imageHref.value)
function getUid() { function getUid() {
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || '' const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
@@ -116,7 +97,7 @@ function getUid() {
return Number.isFinite(numeric) && numeric > 0 ? String(numeric) : '' return Number.isFinite(numeric) && numeric > 0 ? String(numeric) : ''
} }
function computePermissionState(items: Array<Record<string, unknown>>): PermissionState { function computePermissionState(items: PermissionMenuItem[]): PermissionState {
const allowedKeys: string[] = [] const allowedKeys: string[] = []
const allowedColumnKeys: string[] = [] const allowedColumnKeys: string[] = []
const allowedRootKeys: string[] = [] const allowedRootKeys: string[] = []
@@ -126,7 +107,8 @@ function computePermissionState(items: Array<Record<string, unknown>>): Permissi
if (key && list.indexOf(key) < 0) list.push(key) if (key && list.indexOf(key) < 0) list.push(key)
} }
;(items || []).forEach((item) => { ;(items || []).forEach((item) => {
push(allowedColumnKeys, item.columnKey || item.column_key) push(allowedColumnKeys, item.columnKey)
push(allowedColumnKeys, item.column_key)
const root = item.rootColumnKey || item.root_column_key const root = item.rootColumnKey || item.root_column_key
if (root) { if (root) {
hasRootMetadata = true hasRootMetadata = true
@@ -154,19 +136,10 @@ async function loadPermissions() {
return return
} }
try { try {
const resp = await requestGetJson<{ success: boolean; data?: Array<Record<string, unknown>> }>( // shared/api/permission catch
buildJavaUrl(`/api/admin/permission-users/${currentUid}/column-permissions?menuType=app`), const items = await getCurrentUserAppColumnRaw()
) permissionState.value = computePermissionState(items)
if (resp?.success && Array.isArray(resp.data)) { permissionUnavailable.value = false
permissionState.value = computePermissionState(resp.data)
try {
window.localStorage.setItem(`app_column_permissions:${currentUid}`, JSON.stringify(resp.data))
} catch {
/* 忽略 */
}
} else {
permissionUnavailable.value = true
}
} catch { } catch {
permissionUnavailable.value = true permissionUnavailable.value = true
} }
@@ -177,21 +150,10 @@ async function loadCurrentUser() {
try { try {
const localName = typeof window === 'undefined' ? '' : window.localStorage.getItem('username') || '' const localName = typeof window === 'undefined' ? '' : window.localStorage.getItem('username') || ''
if (localName) username.value = localName if (localName) username.value = localName
const token = typeof window === 'undefined' ? '' : window.localStorage.getItem('aiimage_auth_token') || '' // shared/auth /newApi shared-api-allowlist
const resp = await window.fetch('/newApi/check_login', { const restored = await restoreLoginUser()
credentials: 'include', if (restored?.username) {
headers: token ? { Authorization: `Bearer ${token}` } : {}, username.value = restored.username
})
const body = await resp.json().catch(() => ({}))
if (body?.success && body.data) {
if (body.data.username) username.value = body.data.username
if (body.data.userId) {
try {
window.localStorage.setItem('uid', String(body.data.userId))
} catch {
/* 忽略 */
}
}
} }
} catch { } catch {
/* 保留本地用户名 */ /* 保留本地用户名 */
@@ -2,7 +2,7 @@
<div v-if="currentView === 'menu'" class="image-video-page"> <div v-if="currentView === 'menu'" class="image-video-page">
<header class="page-header"> <header class="page-header">
<span class="header-title">数富AI</span> <span class="header-title">数富AI</span>
<a class="back-link" :href="goHome()">返回首页</a> <router-link class="back-link" :to="goHome()">返回首页</router-link>
</header> </header>
<main class="entrances" :class="{ 'is-loading': permissionsLoading }" aria-label="视频与图片入口"> <main class="entrances" :class="{ 'is-loading': permissionsLoading }" aria-label="视频与图片入口">
@@ -21,12 +21,9 @@
<button v-if="hasMenuPermission('mix-video')" type="button" class="entrance-btn" @click="showSoon('混剪')"> <button v-if="hasMenuPermission('mix-video')" type="button" class="entrance-btn" @click="showSoon('混剪')">
混剪 混剪
</button> </button>
<a v-if="hasMenuPermission('image') && isDesktop" class="entrance-btn" href="/new_web_source/image.html"> <router-link v-if="hasMenuPermission('image')" class="entrance-btn" to="/image">
图片 图片
</a> </router-link>
<button v-else-if="hasMenuPermission('image')" type="button" class="entrance-btn" @click="showDesktopOnly('图片工作台')">
图片
</button>
</main> </main>
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }"> <div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
@@ -56,7 +53,7 @@
:show-actions="false" :show-actions="false"
> >
<template #logoExtra> <template #logoExtra>
<button type="button" class="delivery-page__top-return" @click="currentView = 'menu'"> <button type="button" class="delivery-page__top-return" @click="goBackToMenu">
返回二级菜单 返回二级菜单
</button> </button>
</template> </template>
@@ -71,30 +68,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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'
import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue' import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue'
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission' import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { isDesktopClientPage } from '@/shared/page-prefix'
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue' import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
type ViewMode = 'menu' | 'delivery' type ViewMode = 'menu' | 'delivery'
const isDesktop = isDesktopClientPage() const route = useRoute()
const router = useRouter()
function goHome() { function goHome() {
// Flask /home Vue homedev MPA /home.html // Web SPA
return isDesktop ? '/home' : '/home.html' return '/home'
} }
function showDesktopOnly(name: string) { /** 视图由路由 query 驱动:?view=delivery 为带货视频工作台,否则二级菜单页 */
showStatus(`${name}为桌面客户端功能,请在本地客户端中打开`, 'error') const currentView = ref<ViewMode>(route.query.view === 'delivery' ? 'delivery' : 'menu')
}
const currentView = ref<ViewMode>('menu')
const allowedColumnKeys = ref(new Set<string>()) const allowedColumnKeys = ref(new Set<string>())
const permissionsLoading = ref(true) const permissionsLoading = ref(true)
const launching = ref(false) const launching = ref(false)
@@ -139,10 +134,22 @@ onMounted(async () => {
} }
}) })
watch(
() => route.query.view,
(view) => {
currentView.value = view === 'delivery' ? 'delivery' : 'menu'
},
)
function openDeliveryWorkspace() { function openDeliveryWorkspace() {
currentView.value = 'delivery' router.push({ path: '/image-video', query: { view: 'delivery' } })
} }
function goBackToMenu() {
router.push({ path: '/image-video' })
}
function formatBytes(value: number) { function formatBytes(value: number) {
const size = Number(value || 0) const size = Number(value || 0)
if (!Number.isFinite(size) || size <= 0) return '未知大小' if (!Number.isFinite(size) || size <= 0) return '未知大小'
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue' import { computed, nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import './index.css' import './index.css'
import { STORAGE_API_KEY, STORAGE_AUTO_SAVE_PATH } from './workbench-shared' import { STORAGE_API_KEY, STORAGE_AUTO_SAVE_PATH } from './workbench-shared'
@@ -12,6 +13,8 @@ import ClothingDetailPanel from './components/panels/ClothingDetailPanel.vue'
import ExtremeDetailPanel from './components/panels/ExtremeDetailPanel.vue' import ExtremeDetailPanel from './components/panels/ExtremeDetailPanel.vue'
import CloneDetailPanel from './components/panels/CloneDetailPanel.vue' import CloneDetailPanel from './components/panels/CloneDetailPanel.vue'
const router = useRouter()
interface BuildResult { interface BuildResult {
params?: Record<string, unknown> params?: Record<string, unknown>
error?: string error?: string
@@ -446,7 +449,7 @@ async function callGenerate(rawParams: Record<string, unknown>) {
signal: currentAbortController.signal, signal: currentAbortController.signal,
}) })
if (resp.status === 401) { if (resp.status === 401) {
window.location.href = '/login' router.replace('/login')
return return
} }
result = await resp.json() result = await resp.json()
@@ -595,7 +598,7 @@ async function loadHistoryPage(append: boolean) {
try { try {
const resp = await fetch(url, { credentials: 'same-origin' }) const resp = await fetch(url, { credentials: 'same-origin' })
if (resp.status === 401) { if (resp.status === 401) {
window.location.href = '/login' router.replace('/login')
return return
} }
const data = await resp.json() const data = await resp.json()
@@ -1182,7 +1185,7 @@ loadHistoryPage(false)
<div class="logo-area"> <div class="logo-area">
<img class="logo" :src="logoUrl" alt="logo" /> <img class="logo" :src="logoUrl" alt="logo" />
<span class="app-name">数富AI</span> <span class="app-name">数富AI</span>
<a href="/home" class="btn-home" title="返回首页"> 返回首页</a> <router-link to="/home" class="btn-home" title="返回首页"> 返回首页</router-link>
</div> </div>
<nav class="nav-tabs"> <nav class="nav-tabs">
<span class="nav-tab-group"> <span class="nav-tab-group">
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
ALLOWED_IMAGE_TYPES,
pasteClipboardImages,
readFilesAsDataUrls,
} from '../image-utils'
const props = withDefaults(
defineProps<{
max: number
title?: string
hint?: string
zoneHint?: string
zonePrefix?: string
/** none=无粘贴 | row=标题行右侧粘贴按钮 | below=标题下独立粘贴按钮 */
pastePos?: 'none' | 'row' | 'below'
}>(),
{
hint: '',
zoneHint: '支持多选/拖拽上传',
zonePrefix: '点击上传图片',
pastePos: 'none',
},
)
const model = defineModel<string[]>({ required: true })
const fileInputRef = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
function choose() {
fileInputRef.value?.click()
}
function onChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files?.length) void addFiles(input.files)
input.value = ''
}
async function addFiles(files: Blob[] | FileList) {
const allowed = Array.from(files).filter((f) => ALLOWED_IMAGE_TYPES.includes(f.type))
if (!allowed.length) return
const remaining = Math.max(0, props.max - model.value.length)
if (!remaining) return
const urls = await readFilesAsDataUrls(allowed.slice(0, remaining))
model.value = model.value.concat(urls).slice(0, props.max)
}
function removeAt(i: number) {
const next = model.value.slice()
next.splice(i, 1)
model.value = next
}
async function pasteImages() {
const images = await pasteClipboardImages()
if (images.length) void addFiles(images)
}
function onDrop(e: DragEvent) {
dragging.value = false
if (e.dataTransfer?.files?.length) void addFiles(e.dataTransfer.files)
}
</script>
<template>
<div class="option-group">
<div v-if="title" class="section-title-row">
<span class="section-title">{{ title }}</span>
<button
v-if="pastePos === 'row'"
type="button"
class="btn-paste-alone btn-paste-right"
@click="pasteImages"
>
<span>📋</span>
粘贴图片
</button>
</div>
<button
v-if="pastePos === 'below'"
type="button"
class="btn-paste-alone"
@click="pasteImages"
>
<span>📋</span>
粘贴图片
</button>
<div v-if="hint" class="hint-text">{{ hint }}</div>
<input
ref="fileInputRef"
type="file"
accept=".jpg,.jpeg,.png,.bmp"
multiple
hidden
@change="onChange"
/>
<div
v-if="model.length < max"
class="upload-zone"
:class="{ 'drag-over': dragging }"
@click="choose"
@dragover.prevent
@dragenter.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<div class="upload-zone-icon"></div>
<div class="upload-zone-text">{{ zonePrefix }} ({{ model.length }}/{{ max }})</div>
<div v-if="zoneHint" class="upload-zone-hint">{{ zoneHint }}</div>
</div>
<div v-if="model.length > 0" class="upload-preview product-detail-preview">
<div v-for="(u, i) in model" :key="i" class="upload-preview-item">
<img :src="u" :alt="'预览' + (i + 1)" />
<button class="remove-btn" type="button" @click.stop="removeAt(i)">×</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import ImageListField from './ImageListField.vue'
import { modelImages } from '../workbench-shared'
</script>
<template>
<ImageListField
v-model="modelImages"
:max="5"
title="多模特图上传(最多5张)"
hint="支持JPG PNG BMP JPEG"
:paste-pos="'none'"
:zone-hint="''"
zone-prefix="点击上传"
/>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ALLOWED_IMAGE_TYPES, readFilesAsDataUrls } from '../image-utils'
const props = withDefaults(
defineProps<{
title?: string
hint?: string
zoneText?: string
accept?: string
}>(),
{
title: '',
hint: '',
zoneText: '点击上传',
accept: '.jpg,.jpeg,.png,.bmp',
},
)
const model = defineModel<string | null>({ default: null })
const fileInputRef = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
function choose() {
fileInputRef.value?.click()
}
function onChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) void setFile(file)
input.value = ''
}
async function setFile(file: File) {
if (props.accept.startsWith('video')) {
if (!file.type.startsWith('video/')) return
} else if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
return
}
const urls = await readFilesAsDataUrls([file])
model.value = urls[0] ?? null
}
function clearFile() {
model.value = null
}
function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer?.files?.[0]
if (file) void setFile(file)
}
</script>
<template>
<div class="option-group">
<div v-if="title" class="section-title">{{ title }}</div>
<div v-if="hint" class="hint-text">{{ hint }}</div>
<input
ref="fileInputRef"
type="file"
:accept="accept"
hidden
@change="onChange"
/>
<div
v-if="!model"
class="upload-zone"
:class="{ 'drag-over': dragging }"
@click="choose"
@dragover.prevent
@dragenter.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<div class="upload-zone-icon"></div>
<div class="upload-zone-text">{{ zoneText }}</div>
</div>
<div v-else class="upload-preview product-detail-preview">
<div v-if="accept.startsWith('video')" class="upload-preview-item" style="grid-column: 1/-1;">
<span style="color: #888;">已选视频</span>
<button class="remove-btn" type="button" @click="clearFile">×</button>
</div>
<div v-else class="upload-preview-item">
<img :src="model" alt="预览" />
<button class="remove-btn" type="button" @click="clearFile">×</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { watch } from 'vue'
const props = defineProps<{ count: number }>()
const model = defineModel<string[]>({ default: [] })
function resize(n: number) {
const safeN = Math.max(0, Math.min(50, Math.max(1, Number(n) || 1)))
const vals = Array.isArray(model.value) ? model.value : []
const next: string[] = []
for (let i = 0; i < safeN; i++) next.push(i < vals.length ? String(vals[i] ?? '') : '')
model.value = next
}
watch(
() => props.count,
(n) => resize(n),
{ immediate: true },
)
</script>
<template>
<div class="specify-screen-list">
<div v-for="(_, i) in model" :key="i" class="specify-screen-item">
<label>{{ i + 1 }}屏文案 (可选)</label>
<input
v-model="model[i]"
type="text"
class="form-input"
:data-screen-index="i + 1"
placeholder="如:高颜值外观,一眼心动"
/>
</div>
</div>
</template>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SpecifyTextList from '../SpecifyTextList.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { RATIO_OPTIONS_AUTO, RES_OPTIONS, collectSpecify } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'CloneDetailPanel' })
const productName = ref('')
const features = ref('')
const cloneMode = ref<'domestic' | 'amazon' | 'specify'>('domestic')
const ratio = ref('auto')
const res = ref('2k')
const language = ref('')
const procImages = ref<string[]>([])
const refImages = ref<string[]>([])
const specify = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!productName.value.trim()) return { error: '请输入产品名称' }
if (procImages.value.length === 0) return { error: '请上传产品实拍图' }
if (refImages.value.length === 0) return { error: '请上传克隆参考图' }
const modeMap = { domestic: '1', amazon: '2', specify: '3' } as const
const params: Record<string, unknown> = {
menu: 10,
name: productName.value.trim(),
desc: features.value.trim(),
ratio: ratio.value,
resolution: res.value,
language: language.value.trim() || '中文',
mode: modeMap[cloneMode.value],
proc_images: procImages.value.slice(),
ref_images: refImages.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
if (cloneMode.value === 'specify') params.text = collectSpecify(specify.value)
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">产品名称 (必填)</label>
<input v-model="productName" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
<textarea
v-model="features"
class="form-textarea"
rows="4"
placeholder="如: 1:保湿效果好 2:适合敏感肌 3:价格实惠"
></textarea>
</div>
<div class="option-group">
<div class="section-title">克隆模式</div>
<div class="btn-group">
<button class="opt-btn" :class="{ active: cloneMode === 'domestic' }" @click="cloneMode = 'domestic'">国内模式</button>
<button class="opt-btn" :class="{ active: cloneMode === 'amazon' }" @click="cloneMode = 'amazon'">亚马逊模式</button>
<button class="opt-btn" :class="{ active: cloneMode === 'specify' }" @click="cloneMode = 'specify'">指定文案</button>
</div>
</div>
<div v-if="cloneMode === 'specify'" class="option-group">
<label class="section-title">每张参考图对应文案 (可选)</label>
<SpecifyTextList v-model="specify" :count="refImages.length" />
</div>
<div class="option-group">
<div class="section-title">画幅比例</div>
<div class="btn-group btn-group-ratio-grid">
<button
v-for="r in RATIO_OPTIONS_AUTO"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
<div class="hint-text">💡 克隆模式强烈建议使用auto自动比例</div>
</div>
<div class="option-group">
<div class="section-title">分辨率 (RESOLUTION)</div>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
<input v-model="language" type="text" class="form-input" placeholder="例如: 中文输出、英文输出、中英混合" />
</div>
<ImageListField v-model="procImages" :max="6" title="产品实拍图" :paste-pos="'row'" />
<ImageListField v-model="refImages" :max="14" title="克隆参考图" :paste-pos="'row'" />
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SingleImageField from '../SingleImageField.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'ClonePosterPanel' })
const mainTitle = ref('')
const subtitle = ref('')
const ratio = ref('16:9')
const res = ref('2k')
const layoutImage = ref<string | null>(null)
const images = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!layoutImage.value) return { error: '请上传版式图片(1张)' }
if (images.value.length === 0) return { error: '请上传图片(最多6张)' }
const params: Record<string, unknown> = {
menu: 4,
name: mainTitle.value.trim() || '产品',
desc: subtitle.value.trim(),
ratio: ratio.value,
resolution: res.value,
count: 1,
layout_image: layoutImage.value,
ref_images: images.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">主标题</label>
<input v-model="mainTitle" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">副标题</label>
<textarea
v-model="subtitle"
class="form-textarea"
rows="4"
placeholder="如: 1: 保湿效果好 2: 适合敏感肌 3: 价格实惠"
></textarea>
</div>
<div class="option-group">
<label class="section-title">画幅比例</label>
<div class="btn-group btn-group-ratio-grid btn-group-ratio-4col">
<button
v-for="r in RATIO_OPTIONS"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<SingleImageField v-model="layoutImage" title="上传版式 (1张)" zone-text="上传版式" />
<ImageListField v-model="images" :max="6" title="上传图片 (最多6张)" />
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { COUNT_DETAIL, RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'ClothingDetailPanel' })
const productName = ref('')
const features = ref('')
const ratio = ref('16:9')
const res = ref('2k')
const style = ref('')
const language = ref('')
const count = ref(9)
const images = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!productName.value.trim()) return { error: '请输入产品名称' }
if (images.value.length === 0) return { error: '请上传服装细节图片' }
const params: Record<string, unknown> = {
menu: 5,
name: productName.value.trim(),
desc: features.value.trim(),
ratio: ratio.value,
resolution: res.value,
count: count.value,
style: style.value || '极简高级',
language: language.value.trim() || '中文',
ref_images: images.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">产品名称(必填)</label>
<input v-model="productName" type="text" class="form-input" placeholder="如:口红吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
<textarea
v-model="features"
class="form-textarea"
rows="4"
placeholder="如:1:保湿效果好 2:适合敏感肌 3:价格实惠"
></textarea>
</div>
<div class="option-group">
<label class="section-title">画幅比例</label>
<div class="btn-group">
<button
v-for="r in RATIO_OPTIONS"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">风格选择 <span class="title-icon"></span></label>
<input v-model="style" class="form-select" list="styleList_clothes" placeholder="-- 选择或输入风格 --" />
<datalist id="styleList_clothes">
<option value="极简高级"></option>
<option value="时尚潮流"></option>
<option value="复古颗粒"></option>
<option value="网红外拍"></option>
<option value="手机自拍"></option>
</datalist>
</div>
<div class="option-group">
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
<input v-model="language" type="text" class="form-input" placeholder="例如:中文输出、英文输出、中英混合" />
</div>
<div class="option-group">
<label class="section-title">生成张数 <span class="title-icon">🖼</span></label>
<div class="btn-group">
<button
v-for="c in COUNT_DETAIL"
:key="c"
class="opt-btn"
:class="{ active: count === c }"
@click="count = c"
>
{{ c }}
</button>
</div>
</div>
<ImageListField
v-model="images"
:max="8"
title="服装细节(至少1张)"
:paste-pos="'below'"
/>
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SpecifyTextList from '../SpecifyTextList.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { COUNT_DETAIL, RATIO_OPTIONS, RES_OPTIONS, collectSpecify } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'ExtremeDetailPanel' })
const DEFAULT_MANUAL = `1. A类母婴级纯棉: 软糯透气, 呵护娇嫩敏感肌
2. 加宽腰头无痕边: 不勒肚不卡档, 自在无束缚
3. 0荧光0甲醛: 严守安全标准, 贴身穿着才安心
4. 萌趣卡通印花: 色彩清新柔和, 孩子一眼就爱
5. 立体透气抑菌档: 吸湿排汗强, 干爽舒适不闷
6. 高弹面料不松垮: 耐洗不易变形, 久穿不起球
7. 2-12岁全尺码: 剪裁贴合身形, 舒适贴合不紧
8. 弹力平角不卡腿: 穿脱顺畅方便, 孩子自己穿
9. 多种花色随心选: 满足日常替换, 天天不重样`
const productName = ref('')
const featureMode = ref<'auto' | 'manual' | 'specify'>('manual')
const manualText = ref(DEFAULT_MANUAL)
const ratio = ref('3:4')
const res = ref('2k')
const styleDesc = ref('')
const language = ref('')
const count = ref(9)
const images = ref<string[]>([])
const specify = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!productName.value.trim()) return { error: '请输入产品名称' }
if (images.value.length === 0) return { error: '请上传参考图片' }
const modeMap = { auto: '1', manual: '1', specify: '3' } as const
let desc = ''
if (featureMode.value === 'manual') desc = manualText.value.trim()
const params: Record<string, unknown> = {
menu: 8,
name: productName.value.trim(),
desc,
style: styleDesc.value.trim() || '极简高级',
language: language.value.trim() || '中文',
ratio: ratio.value,
resolution: res.value,
count: count.value,
mode: modeMap[featureMode.value],
ref_images: images.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
if (featureMode.value === 'specify') params.text = collectSpecify(specify.value)
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">产品名称(必填)</label>
<input v-model="productName" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
<div class="feature-mode-tabs btn-group">
<button class="opt-btn" :class="{ active: featureMode === 'auto' }" @click="featureMode = 'auto'">自动模式</button>
<button class="opt-btn" :class="{ active: featureMode === 'manual' }" @click="featureMode = 'manual'">手动模式</button>
<button class="opt-btn" :class="{ active: featureMode === 'specify' }" @click="featureMode = 'specify'">指定文案</button>
</div>
<div v-if="featureMode === 'manual'" class="feature-mode-content">
<div class="feature-example-label">示例2:</div>
<textarea
v-model="manualText"
class="form-textarea"
rows="10"
placeholder="每行一个特点,格式如:1. A类母婴级纯棉: 软糯透气, 呵护娇嫩敏感肌"
></textarea>
</div>
<div v-if="featureMode === 'specify'" class="feature-mode-content">
<SpecifyTextList v-model="specify" :count="count" />
</div>
</div>
<div class="option-group">
<label class="section-title">画幅比例 (ASPECT RATIO)</label>
<div class="btn-group">
<button
v-for="r in RATIO_OPTIONS"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">风格和功能卖点描述</label>
<input v-model="styleDesc" type="text" class="form-input" placeholder="例如:高级质感、极简主义、奢华轻奢" />
</div>
<div class="option-group">
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
<input v-model="language" type="text" class="form-input" placeholder="例如:使用中文输出、使用英文输出" />
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">生成张数</label>
<div class="btn-group">
<button
v-for="c in COUNT_DETAIL"
:key="c"
class="opt-btn"
:class="{ active: count === c }"
@click="count = c"
>
{{ c }}
</button>
</div>
</div>
<ImageListField
v-model="images"
:max="8"
title="参考图片 (最多8张)"
hint="建议包含正面及多角度图"
:paste-pos="'row'"
/>
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SpecifyTextList from '../SpecifyTextList.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { COUNT_MAIN, RATIO_OPTIONS_MAIN, RES_OPTIONS, collectSpecify } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'MainImagePanel' })
const props = defineProps<{ panelId: 'productMainImage' | 'buyerShow' }>()
const productName = ref('')
const featureMode = ref<'auto' | 'specify'>('auto')
const ratio = ref('3:4')
const res = ref('2k')
const count = ref(1)
const styleDesc = ref('')
const language = ref('')
const images = ref<string[]>([])
const specify = ref<string[]>([])
const menu = computed(() => (props.panelId === 'buyerShow' ? 12 : 9))
function collectModelImages(): string[] | null {
return modelImages.value.length > 0 ? modelImages.value.slice() : null
}
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!productName.value.trim()) return { error: '请输入产品名称' }
if (images.value.length === 0) return { error: '请上传产品图片' }
const params: Record<string, unknown> = {
menu: menu.value,
name: productName.value.trim(),
desc: '',
style: styleDesc.value.trim() || '白底图',
language: language.value.trim() || '中文',
ratio: ratio.value,
resolution: res.value,
count: count.value,
mode: featureMode.value === 'specify' ? '3' : '1',
ref_images: images.value.slice(),
}
const modelImages = collectModelImages()
if (modelImages) params.model_images = modelImages
if (featureMode.value === 'specify') params.text = collectSpecify(specify.value)
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">产品名称(必填)</label>
<input v-model="productName" type="text" class="form-input" placeholder="如:口红 吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
<div class="feature-mode-tabs btn-group">
<button class="opt-btn" :class="{ active: featureMode === 'auto' }" @click="featureMode = 'auto'">自动模式</button>
<button class="opt-btn" :class="{ active: featureMode === 'specify' }" @click="featureMode = 'specify'">指定文案</button>
</div>
<div v-if="featureMode === 'specify'" class="feature-mode-content">
<SpecifyTextList v-model="specify" :count="count" />
</div>
</div>
<div class="option-group">
<label class="section-title">画幅比例</label>
<div class="btn-group">
<button
v-for="r in RATIO_OPTIONS_MAIN"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">风格描述 (可选)</label>
<input v-model="styleDesc" type="text" class="form-input" placeholder="如:白底图 浅色调 墨绿色调 户外 居家" />
</div>
<div class="option-group">
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
<input v-model="language" type="text" class="form-input" placeholder="例如:中文输出、英文输出、中英混合" />
</div>
<div class="option-group">
<label class="section-title">生成张数</label>
<div class="btn-group">
<button
v-for="c in COUNT_MAIN"
:key="c"
class="opt-btn"
:class="{ active: count === c }"
@click="count = c"
>
{{ c }}
</button>
</div>
</div>
<ImageListField
v-model="images"
:max="8"
title="产品图片 (最多8张)"
hint="建议包含正面及多角度图"
:paste-pos="'row'"
/>
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'ProductPosterPanel' })
const mainTitle = ref('')
const subtitle = ref('')
const brandName = ref('')
const ingredients = ref('')
const activity = ref('')
const ratio = ref('16:9')
const res = ref('2k')
const images = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (images.value.length === 0) return { error: '请上传图片(最多6张)' }
const params: Record<string, unknown> = {
menu: 6,
name: mainTitle.value.trim() || '产品',
desc: subtitle.value.trim(),
brand_name: brandName.value.trim(),
Ingredients: ingredients.value.trim(),
activity: activity.value.trim(),
ratio: ratio.value,
resolution: res.value,
count: 1,
ref_images: images.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">主标题</label>
<input v-model="mainTitle" type="text" class="form-input" placeholder="如: 口红吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">副标题</label>
<textarea
v-model="subtitle"
class="form-textarea"
rows="4"
placeholder="如: 1:保湿效果好 2: 适合敏感肌&#10;3:价格实惠"
></textarea>
</div>
<div class="option-group">
<label class="section-title">品牌名 (可选)</label>
<input v-model="brandName" type="text" class="form-input" placeholder="(可选)" />
</div>
<div class="option-group">
<label class="section-title">成分 (INGREDIENTS)</label>
<input v-model="ingredients" type="text" class="form-input" placeholder="例如: 纯棉、透明质酸...(可选)" />
</div>
<div class="option-group">
<label class="section-title">活动 (ACTIVITY)</label>
<input v-model="activity" type="text" class="form-input" placeholder="例如: 买一送一、限时折扣...(可选)" />
</div>
<div class="option-group">
<label class="section-title">画幅比例</label>
<div class="btn-group">
<button
v-for="r in RATIO_OPTIONS"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<ImageListField
v-model="images"
:max="6"
title="上传图片 (最多6张)"
hint="支持多选与拖拽排序"
:paste-pos="'row'"
/>
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SingleImageField from '../SingleImageField.vue'
defineOptions({ name: 'TextToImagePanel' })
const prompt = ref('')
const refImages = ref<string[]>([])
const video = ref<string | null>(null)
function buildParams(): { params?: Record<string, unknown>; error?: string } {
const params: Record<string, unknown> = {
menu: 1,
prompt: prompt.value.trim() || '',
ref_images: refImages.value.slice(),
}
if (video.value) params.video = video.value
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<div class="section-title">提示词</div>
<div class="textarea-wrap">
<textarea
v-model="prompt"
class="prompt-textarea prompt-textarea-large"
placeholder="输入自定义指令,用于反推提示词..."
></textarea>
<span class="char-count char-count-top-right">{{ prompt.length }} 字符</span>
</div>
</div>
<ImageListField
v-model="refImages"
:max="8"
title="上传图片(最多8张)"
hint="支持JPG PNG BMP JPEG"
:zone-hint="'支持多选/拖拽上传'"
/>
<SingleImageField
v-model="video"
title="上传视频(只支持1个)"
hint="支持 MP4 等视频格式"
zone-text="点击上传视频"
accept="video/*,.mp4,.webm"
/>
</div>
</template>
@@ -0,0 +1,36 @@
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
/** 读取多个文件为 data URL,返回解析完的数组。 */
export function readFilesAsDataUrls(files: Blob[] | FileList): Promise<string[]> {
const list = Array.from(files)
return Promise.all(
list.map(
(file) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error('读取文件失败'))
reader.readAsDataURL(file)
}),
),
)
}
/** 从剪贴板读取图片文件。 */
export async function pasteClipboardImages(): Promise<Blob[]> {
try {
const items = await navigator.clipboard.read()
const images: Blob[] = []
for (const item of items) {
if (item.types.includes('image/png')) {
images.push(await item.getType('image/png'))
} else if (item.types.includes('image/jpeg')) {
images.push(await item.getType('image/jpeg'))
}
}
return images
} catch (err) {
console.warn('粘贴图片失败:', err)
return []
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
export const RES_OPTIONS = [
{ label: '2K', value: '2k' },
{ label: '4K', value: '4k' },
]
/** 不含 auto 的常规画幅比例 */
export const RATIO_OPTIONS = ['3:4', '1:1', '16:9', '9:16', '4:3', '2:3', '3:2', '21:9']
/** 含 auto 的画幅比例 */
export const RATIO_OPTIONS_AUTO = ['auto', ...RATIO_OPTIONS]
/** 产品主图/买家秀仅三种比例,默认 3:4 */
export const RATIO_OPTIONS_MAIN = ['1:1', '3:4', '4:3']
export const COUNT_MAIN = [1, 3, 5, 7, 9]
export const COUNT_DETAIL = [3, 5, 9, 14]
export function collectSpecify(list: string[]): string[] {
return (Array.isArray(list) ? list : []).map((s) => String(s || '').trim()).filter(Boolean)
}
@@ -0,0 +1,7 @@
import { ref } from 'vue'
/** 多模特图:除反推词外所有栏目共享同一组上传 */
export const modelImages = ref<string[]>([])
export const STORAGE_API_KEY = 'maixiang_api_key'
export const STORAGE_AUTO_SAVE_PATH = 'maixiang_auto_save_path'
@@ -53,7 +53,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { resolvePageHref } from '@/shared/page-prefix' import { useRouter } from 'vue-router'
import { loginWithDevice } from '@/shared/api/user'
const router = useRouter()
const AUTH_TOKEN_KEY = 'aiimage_auth_token' const AUTH_TOKEN_KEY = 'aiimage_auth_token'
const DEVICE_ID_KEY = 'aiimage_device_id' const DEVICE_ID_KEY = 'aiimage_device_id'
@@ -68,10 +71,6 @@ function togglePassword() {
passwordVisible.value = !passwordVisible.value passwordVisible.value = !passwordVisible.value
} }
function isDesktopClient() {
return Boolean(window.pywebview?.api) || window.location.pathname === '/login'
}
function clearAppPermissionCaches() { function clearAppPermissionCaches() {
try { try {
const keys: string[] = [] const keys: string[] = []
@@ -137,17 +136,8 @@ async function submitLogin() {
errorMessage.value = '未获取到设备ID,请在桌面端打开' errorMessage.value = '未获取到设备ID,请在桌面端打开'
return return
} }
const loginResp = await window.fetch('/newApi/login', { // shared/api/user /newApi shared-api-allowlist
method: 'POST', const loginBody = await loginWithDevice(account, password.value, deviceId)
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Device-Id': deviceId,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ username: account, password: password.value, deviceId }),
})
const loginBody = await loginResp.json().catch(() => ({}))
if (!loginBody || loginBody.success !== true || !loginBody.data) { if (!loginBody || loginBody.success !== true || !loginBody.data) {
errorMessage.value = (loginBody && (loginBody.message || loginBody.msg)) || '登录失败' errorMessage.value = (loginBody && (loginBody.message || loginBody.msg)) || '登录失败'
return return
@@ -174,20 +164,10 @@ async function submitLogin() {
/* 忽略 */ /* 忽略 */
} }
} }
// Java JWT Python cookie // Flask cookie 线 localStorageJWT Bearer cookie
try {
await window.fetch('/api/auth/sync', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: data.token }),
})
} catch {
/* cookie 同步失败不阻塞跳转(页面仍可读 localStorage */
}
clearAppPermissionCaches() clearAppPermissionCaches()
// Flask /homeWeb /new_web_source dev resolvePageHref // SPAURL .html src/router
window.location.href = isDesktopClient() ? '/home' : resolvePageHref('/new_web_source/home.html') await router.replace('/home')
} catch { } catch {
errorMessage.value = '网络异常,请稍后重试' errorMessage.value = '网络异常,请稍后重试'
} finally { } finally {
-17
View File
@@ -1,17 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandPatrolDeleteTab from '@/pages/brand/components/BrandPatrolDeleteTab.vue'
dayjs.locale('zh-cn')
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandPatrolDeleteTab).use(ElementPlus, { locale: zhCn }).mount('#app')
}
})
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandPriceTrackTab from '@/pages/brand/components/BrandPriceTrackTab.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandPriceTrackTab).use(ElementPlus).mount('#app')
}
})
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandProductRiskTab from '@/pages/brand/components/BrandProductRiskTab.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandProductRiskTab).use(ElementPlus).mount('#app')
}
})
-12
View File
@@ -1,12 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandPublishTab from '@/pages/brand/components/BrandPublishTab.vue'
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandPublishTab).use(ElementPlus).mount('#app')
}
})
-18
View File
@@ -1,18 +0,0 @@
import { createApp } from 'vue'
import { ensureAuth } from '@/shared/auth/ensure-auth'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandQueryAsinTab from '@/pages/brand/components/BrandQueryAsinTab.vue'
dayjs.locale('zh-cn')
ensureAuth().then((ok) => {
if (ok) {
createApp(BrandQueryAsinTab).use(ElementPlus, { locale: zhCn }).mount('#app')
}
})
+138
View File
@@ -0,0 +1,138 @@
import { createRouter, createWebHistory } from 'vue-router'
/**
* AI SPA .html
*
* MPA22 html + 22 *-main.ts index.html
* chunkURL /xxx
*/
const routes = [
{ path: '/', redirect: '/home' },
{
path: '/login',
name: 'login',
component: () => import('@/pages/login/DesktopLoginPage.vue'),
},
{
path: '/home',
name: 'home',
component: () => import('@/pages/home/DesktopHomePage.vue'),
},
{
path: '/amazon-console',
name: 'amazon-console',
component: () => import('@/pages/amazon/AmazonConsolePage.vue'),
},
{
path: '/image',
name: 'image',
component: () => import('@/pages/image/ImageWorkbenchPage.vue'),
},
{
path: '/image-video',
name: 'image-video',
// 视图切换走 ?view=delivery(菜单页 / 带货视频工作台)
component: () => import('@/pages/image-video/ImageVideoPage.vue'),
},
// ---- 亚马逊运营工具(品牌工具,右侧统一任务面板)----
{
path: '/collect-data',
name: 'collect-data',
component: () => import('@/pages/brand/components/BrandCollectDataTab.vue'),
},
{
path: '/variant-collection',
name: 'variant-collection',
component: () => import('@/pages/brand/components/BrandVariantTab.vue'),
},
{
path: '/dedupe',
name: 'dedupe',
component: () => import('@/pages/brand/components/BrandDedupeTab.vue'),
},
{
path: '/similar-asin',
name: 'similar-asin',
component: () => import('@/pages/brand/components/BrandSimilarAsinTab.vue'),
},
{
path: '/brand',
name: 'brand',
component: () => import('@/pages/brand/components/BrandBrandTab.vue'),
},
{
path: '/appearance-patent',
name: 'appearance-patent',
component: () => import('@/pages/brand/components/BrandAppearancePatentTab.vue'),
},
{
path: '/split',
name: 'split',
component: () => import('@/pages/brand/components/BrandSplitTab.vue'),
},
{
path: '/convert',
name: 'convert',
component: () => import('@/pages/brand/components/BrandConvertTab.vue'),
},
{
path: '/publish',
name: 'publish',
component: () => import('@/pages/brand/components/BrandPublishTab.vue'),
},
{
path: '/delete-brand',
name: 'delete-brand',
component: () => import('@/pages/brand/components/BrandDeleteBrandTab.vue'),
},
{
path: '/product-risk',
name: 'product-risk',
component: () => import('@/pages/brand/components/BrandProductRiskTab.vue'),
},
{
path: '/shop-match',
name: 'shop-match',
component: () => import('@/pages/brand/components/BrandShopMatchTab.vue'),
},
{
path: '/price-track',
name: 'price-track',
component: () => import('@/pages/brand/components/BrandPriceTrackTab.vue'),
},
{
path: '/patrol-delete',
name: 'patrol-delete',
component: () => import('@/pages/brand/components/BrandPatrolDeleteTab.vue'),
},
{
path: '/query-asin',
name: 'query-asin',
component: () => import('@/pages/brand/components/BrandQueryAsinTab.vue'),
},
{
path: '/shop-data-crawl',
name: 'shop-data-crawl',
component: () => import('@/pages/brand/components/BrandShopDataCrawlTab.vue'),
},
{
path: '/withdraw',
name: 'withdraw',
component: () => import('@/pages/brand/components/BrandWithdrawTab.vue'),
},
// ---- 兜底:未匹配路径回首页(保持与 MPA 时代"未知页 404 后回首页"一致体验)----
{ path: '/:pathMatch(.*)*', redirect: '/home' },
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, _from, savedPosition) {
// 返回工具台的 #group-xxx 锚点由组件 onMounted 处理;普通滚动回顶部
if (savedPosition) return savedPosition
return { top: 0 }
},
})
export default router
@@ -30,6 +30,8 @@ export interface BrandTaskItem {
desc?: string desc?: string
file_paths?: string[] file_paths?: string[]
created_at?: string created_at?: string
/** 最后更新(终态时即完成时间,作为结束时间兜底) */
updated_at?: string
progress_total?: number progress_total?: number
progress_current?: number progress_current?: number
error_message?: string error_message?: string
@@ -12,6 +12,16 @@ export interface ConvertResultItem {
success: boolean; success: boolean;
error?: string; error?: string;
downloadUrl?: string; downloadUrl?: string;
/** 所属任务IDbiz_file_task.id */
taskId?: number;
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
taskStatus?: string;
/** 任务创建时间 */
createdAt?: string;
/** 任务开始时间 */
startedAt?: string;
/** 任务结束时间(未结束为空) */
finishedAt?: string;
} }
export interface ConvertRunVo { export interface ConvertRunVo {
@@ -12,6 +12,16 @@ export interface DedupeResultItem {
success: boolean; success: boolean;
error?: string; error?: string;
downloadUrl?: string; downloadUrl?: string;
/** 所属任务IDbiz_file_task.id */
taskId?: number;
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
taskStatus?: string;
/** 任务创建时间 */
createdAt?: string;
/** 任务开始时间 */
startedAt?: string;
/** 任务结束时间(未结束为空) */
finishedAt?: string;
} }
export interface DedupeRunVo { export interface DedupeRunVo {

Some files were not shown because too many files have changed in this diff Show More