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:
File diff suppressed because one or more lines are too long
+15
@@ -24,4 +24,19 @@ public class ConvertResultItemVo {
|
||||
|
||||
@Schema(description = "下载地址")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "所属任务ID(biz_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;
|
||||
}
|
||||
|
||||
+40
-3
@@ -201,12 +201,14 @@ public class ConvertRunService {
|
||||
}
|
||||
|
||||
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::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 200"))
|
||||
.stream()
|
||||
.last("limit 200"));
|
||||
// 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
|
||||
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
|
||||
return entities.stream()
|
||||
.map(entity -> {
|
||||
ConvertResultItemVo vo = new ConvertResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
@@ -215,11 +217,46 @@ public class ConvertRunService {
|
||||
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
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;
|
||||
})
|
||||
.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) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
|
||||
+15
@@ -27,4 +27,19 @@ public class DedupeResultItemVo {
|
||||
|
||||
@Schema(description = "结果文件大小(字节)")
|
||||
private Long resultFileSize;
|
||||
|
||||
@Schema(description = "所属任务ID(biz_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;
|
||||
}
|
||||
|
||||
+40
-3
@@ -389,12 +389,14 @@ public class DedupeRunService {
|
||||
}
|
||||
|
||||
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::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 50"))
|
||||
.stream()
|
||||
.last("limit 50"));
|
||||
// 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
|
||||
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
|
||||
return entities.stream()
|
||||
.map(entity -> {
|
||||
DedupeResultItemVo vo = new DedupeResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
@@ -403,11 +405,46 @@ public class DedupeRunService {
|
||||
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
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;
|
||||
})
|
||||
.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) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
|
||||
+10
@@ -78,4 +78,14 @@ public class DeleteBrandResultItemVo {
|
||||
|
||||
@Schema(description = "由于列表来自 history 接口,增加所属任务的状态(RUNNING/SUCCESS/FAILED),协助前端准确定位执行中任务")
|
||||
private String taskStatus;
|
||||
|
||||
/**
|
||||
* 任务创建时间(biz_file_task.created_at,格式化字符串),列表接口返回。
|
||||
*/
|
||||
private String createdAt;
|
||||
|
||||
/**
|
||||
* 任务结束时间(biz_file_task.finished_at,未结束为 null),列表接口返回。
|
||||
*/
|
||||
private String finishedAt;
|
||||
}
|
||||
|
||||
+9
@@ -293,6 +293,7 @@ public class DeleteBrandRunService {
|
||||
List<FileTaskEntity> recentTouchedTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId,
|
||||
FileTaskEntity::getStatus,
|
||||
FileTaskEntity::getCreatedAt,
|
||||
FileTaskEntity::getUpdatedAt,
|
||||
FileTaskEntity::getFinishedAt)
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
@@ -375,6 +376,9 @@ public class DeleteBrandRunService {
|
||||
// 真实补充
|
||||
if (entity.getTaskId() != null) {
|
||||
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;
|
||||
if (taskItems != null && entity.getSourceFilename() != null && !entity.getSourceFilename().isBlank()) {
|
||||
for (DeleteBrandResultItemVo candidate : taskItems) {
|
||||
@@ -1234,6 +1238,7 @@ public class DeleteBrandRunService {
|
||||
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId,
|
||||
FileTaskEntity::getStatus,
|
||||
FileTaskEntity::getCreatedAt,
|
||||
FileTaskEntity::getUpdatedAt,
|
||||
FileTaskEntity::getFinishedAt)
|
||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||
@@ -1282,6 +1287,10 @@ public class DeleteBrandRunService {
|
||||
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)
|
||||
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
|
||||
+10
@@ -62,4 +62,14 @@ public class PriceTrackResultItemVo {
|
||||
|
||||
@Schema(description = "所属轮次,1-based", example = "2")
|
||||
private Integer roundIndex;
|
||||
|
||||
/**
|
||||
* 任务创建时间(biz_file_task.created_at,格式化字符串),列表接口返回。
|
||||
*/
|
||||
private String createdAt;
|
||||
|
||||
/**
|
||||
* 任务结束时间(biz_file_task.finished_at,未结束为 null),列表接口返回。
|
||||
*/
|
||||
private String finishedAt;
|
||||
}
|
||||
|
||||
+26
-9
@@ -142,7 +142,7 @@ public class PriceTrackTaskService {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
|
||||
private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
|
||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return tasks;
|
||||
@@ -151,7 +151,8 @@ public class PriceTrackTaskService {
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
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))));
|
||||
}
|
||||
return tasks;
|
||||
@@ -208,16 +209,16 @@ public class PriceTrackTaskService {
|
||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||
return vo;
|
||||
}
|
||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
||||
Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
|
||||
List<FileTaskEntity> tasks = selectTaskHistoryFieldsByIdsInBatches(taskIds);
|
||||
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();
|
||||
@@ -229,14 +230,14 @@ public class PriceTrackTaskService {
|
||||
long jobsLoadedAt = System.nanoTime();
|
||||
List<PriceTrackResultItemVo> items = new ArrayList<>();
|
||||
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);
|
||||
long finishedAt = System.nanoTime();
|
||||
log.info("[price-track] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||
userId,
|
||||
entities.size(),
|
||||
statusByTaskId.size(),
|
||||
taskById.size(),
|
||||
jobMap.size(),
|
||||
elapsedMs(startedAt, finishedAt),
|
||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||
@@ -1720,16 +1721,32 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setTaskId(entity.getTaskId());
|
||||
vo.setShopName(entity.getSourceFilename());
|
||||
vo.setShopId(entity.getSourceFileUrl());
|
||||
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;
|
||||
vo.setSuccess(ok);
|
||||
vo.setError(entity.getErrorMessage());
|
||||
@@ -1767,7 +1784,7 @@ public class PriceTrackTaskService {
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
for (FileResultEntity fr : rows) {
|
||||
detail.getItems().add(toHistoryItemVo(fr, task.getStatus()));
|
||||
detail.getItems().add(toHistoryItemVo(fr, task));
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
+8
@@ -79,4 +79,12 @@ public class ProductRiskResultItemVo {
|
||||
@JsonAlias("skip_asin_details_by_country")
|
||||
@JsonProperty("skipAsinDetailsByCountry")
|
||||
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;
|
||||
}
|
||||
|
||||
+26
-9
@@ -133,7 +133,7 @@ public class ProductRiskTaskService {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
|
||||
private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
|
||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return tasks;
|
||||
@@ -142,7 +142,8 @@ public class ProductRiskTaskService {
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
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))));
|
||||
}
|
||||
return tasks;
|
||||
@@ -202,17 +203,17 @@ public class ProductRiskTaskService {
|
||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||
return vo;
|
||||
}
|
||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
||||
Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
|
||||
List<FileTaskEntity> tasks = selectTaskHistoryFieldsByIdsInBatches(taskIds);
|
||||
for (FileTaskEntity t : tasks) {
|
||||
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();
|
||||
List<ProductRiskResultItemVo> items = new ArrayList<>();
|
||||
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);
|
||||
long finishedAt = System.nanoTime();
|
||||
log.info("[product-risk] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||
userId,
|
||||
entities.size(),
|
||||
statusByTaskId.size(),
|
||||
taskById.size(),
|
||||
jobMap.size(),
|
||||
elapsedMs(startedAt, finishedAt),
|
||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||
@@ -999,7 +1000,7 @@ public class ProductRiskTaskService {
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
for (FileResultEntity fr : rows) {
|
||||
detail.getItems().add(toHistoryItemVo(fr, task.getStatus()));
|
||||
detail.getItems().add(toHistoryItemVo(fr, task));
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
@@ -1048,16 +1049,32 @@ public class ProductRiskTaskService {
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setTaskId(entity.getTaskId());
|
||||
vo.setShopName(entity.getSourceFilename());
|
||||
vo.setShopId(entity.getSourceFileUrl());
|
||||
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;
|
||||
vo.setSuccess(ok);
|
||||
vo.setError(entity.getErrorMessage());
|
||||
|
||||
+26
-9
@@ -168,7 +168,7 @@ public class ShopMatchTaskService {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(List<Long> taskIds) {
|
||||
private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
|
||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return tasks;
|
||||
@@ -177,7 +177,8 @@ public class ShopMatchTaskService {
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
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))));
|
||||
}
|
||||
return tasks;
|
||||
@@ -237,16 +238,16 @@ public class ShopMatchTaskService {
|
||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||
return vo;
|
||||
}
|
||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
||||
Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
for (FileTaskEntity task : selectTaskStatusesByIdsInBatches(taskIds)) {
|
||||
for (FileTaskEntity task : selectTaskHistoryFieldsByIdsInBatches(taskIds)) {
|
||||
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();
|
||||
List<ProductRiskResultItemVo> items = new ArrayList<>();
|
||||
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);
|
||||
long finishedAt = System.nanoTime();
|
||||
log.info("[shop-match] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||
userId,
|
||||
entities.size(),
|
||||
statusByTaskId.size(),
|
||||
taskById.size(),
|
||||
jobMap.size(),
|
||||
elapsedMs(startedAt, finishedAt),
|
||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||
@@ -963,7 +964,7 @@ public class ShopMatchTaskService {
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
for (FileResultEntity row : rows) {
|
||||
detail.getItems().add(toHistoryItemVo(row, task.getStatus()));
|
||||
detail.getItems().add(toHistoryItemVo(row, task));
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
@@ -1031,16 +1032,32 @@ public class ShopMatchTaskService {
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setTaskId(entity.getTaskId());
|
||||
vo.setShopName(entity.getSourceFilename());
|
||||
vo.setShopId(entity.getSourceFileUrl());
|
||||
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;
|
||||
vo.setSuccess(success);
|
||||
vo.setError(entity.getErrorMessage());
|
||||
|
||||
+15
@@ -35,4 +35,19 @@ public class SplitResultItemVo {
|
||||
|
||||
@Schema(description = "压缩包内文件列表")
|
||||
private List<SplitArchiveEntryVo> entries;
|
||||
|
||||
@Schema(description = "所属任务ID(biz_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;
|
||||
}
|
||||
|
||||
+41
-3
@@ -29,6 +29,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -161,12 +162,14 @@ public class SplitRunService {
|
||||
}
|
||||
|
||||
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::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"))
|
||||
.stream()
|
||||
.last("limit 100"));
|
||||
// 任务级信息(状态/开始/结束时间)来自 biz_file_task,按 task_id 批量联查
|
||||
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
|
||||
return entities.stream()
|
||||
.map(entity -> {
|
||||
SplitResultItemVo vo = new SplitResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
@@ -178,11 +181,46 @@ public class SplitRunService {
|
||||
vo.setError(entity.getErrorMessage());
|
||||
vo.setEntryCount(0);
|
||||
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;
|
||||
})
|
||||
.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) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 数富AI SPA 根组件:页面均由路由渲染(URL 无 .html 后缀)
|
||||
</script>
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
@@ -1,7 +1,33 @@
|
||||
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 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 前端统一入口(SPA,URL 无 .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>
|
||||
<div class="page-shell module-page amazon-console">
|
||||
<AmazonTopBar />
|
||||
<div
|
||||
class="page-shell module-page amazon-console"
|
||||
:style="{ '--scroll-color': currentGroup?.color ?? '#2dd4bf' }"
|
||||
>
|
||||
<AmazonTopBar :active-cat="activeCat" @change="onCatChange" />
|
||||
|
||||
<main class="console-body">
|
||||
<!-- Hero 宣传区 -->
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-left">
|
||||
<span class="hero-pill">运营辅助 · 数据安全 · 高效跟价</span>
|
||||
<h1 class="hero-title">让亚马逊运营,更清晰高效</h1>
|
||||
<p class="hero-sub">
|
||||
从采集、去重、查品到上架跟价,20 个工具按「前端 · 运营 · 后勤」三线组织,每条工具一句话讲清用途,标准流程一步不缺。
|
||||
</p>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<strong style="color: #2dd4bf">20</strong>
|
||||
<span>个工具</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong style="color: #60a5fa">3</strong>
|
||||
<span>大分类</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong style="color: #f59e0b">8</strong>
|
||||
<span>前端标准流程步骤</span>
|
||||
</div>
|
||||
<!-- Hero(仅前端工具分类显示,对齐 gemini-code 主页) -->
|
||||
<section v-if="currentGroup && currentGroup.key === 'front'" class="hero">
|
||||
<div class="hero-left">
|
||||
<span class="hero-pill">运营辅助 · 数据安全 · 高效跟价</span>
|
||||
<h1 class="hero-title">让亚马逊运营,更清晰高效</h1>
|
||||
<p class="hero-sub">
|
||||
从采集、去重、查品到上架跟价,20 个工具按「前端 · 运营 · 后勤」三线组织,每条工具一句话讲清用途,标准流程一步不缺。
|
||||
</p>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<strong style="color: #2dd4bf">20</strong>
|
||||
<span>个工具</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong style="color: #60a5fa">3</strong>
|
||||
<span>大分类</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong style="color: #f59e0b">8</strong>
|
||||
<span>前端标准流程步骤</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-card">
|
||||
<div class="hero-card">
|
||||
<div>
|
||||
<div class="hero-card-head">
|
||||
<div class="hero-card-dots">
|
||||
<i class="dot dot--teal"></i>
|
||||
<i class="dot dot--blue"></i>
|
||||
<i class="dot dot--brand"></i>
|
||||
<span class="dots-line"></span>
|
||||
</div>
|
||||
<span class="hero-card-caption">数富AI · 运营工作台</span>
|
||||
</div>
|
||||
<h2 class="hero-card-title">让每一次运营动作,<br />都有清晰的工作流。</h2>
|
||||
<div class="hero-card-divider"></div>
|
||||
<p class="hero-card-sub">内置完整使用教程,从软件配置到运营上架,每一步流程清晰可查。</p>
|
||||
<button type="button" class="hero-card-btn" @click="scrollToTools">查看工具</button>
|
||||
<p class="hero-card-sub">内置完整使用教程,从软件配置到运营上架,</p>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<!-- 三组工具:标题 + 标准工作流 + 卡片墙 -->
|
||||
<section v-for="group in visibleGroups" :key="group.key" class="tool-group" :id="`group-${group.key}`">
|
||||
<header class="group-head">
|
||||
<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">
|
||||
<!-- 当前分类:标准工作流 + 卡片墙(分类切换式,同 gemini-code) -->
|
||||
<section v-if="currentGroup" class="tool-group" :id="`group-${currentGroup.key}`">
|
||||
<section v-if="visibleWorkflow(currentGroup)?.length" class="workflow-panel">
|
||||
<div class="workflow-head">
|
||||
<span class="workflow-title">{{ group.workflowTitle }}</span>
|
||||
<span class="workflow-badge" :style="{ color: group.color }">{{ group.workflowBadge }}</span>
|
||||
<span class="workflow-hint">{{ group.workflowHint }}</span>
|
||||
<span class="workflow-title">{{ currentGroup.workflowTitle }}</span>
|
||||
<span class="workflow-badge">{{ currentGroup.workflowBadge }}</span>
|
||||
<span class="workflow-hint">{{ currentGroup.workflowHint }}</span>
|
||||
</div>
|
||||
<ol class="workflow-steps">
|
||||
<li
|
||||
v-for="(step, index) in visibleWorkflow(group)"
|
||||
:key="`${group.key}-${index}`"
|
||||
class="workflow-step"
|
||||
:class="{ 'workflow-step--disabled': !step.toolId || !stepHref(step.toolId) }"
|
||||
@click="onWorkflowStepClick(step)"
|
||||
>
|
||||
<span class="step-circle" :style="{ background: group.weak, color: group.color }">{{ index + 1 }}</span>
|
||||
<span class="step-name">{{ step.name }}</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>
|
||||
<template v-for="(step, index) in visibleWorkflow(currentGroup)" :key="`${currentGroup.key}-${index}`">
|
||||
<li
|
||||
class="workflow-step"
|
||||
:class="{ 'workflow-step--disabled': !step.toolId || !stepHref(step.toolId) }"
|
||||
:style="{ '--gc': currentGroup.color }"
|
||||
@click="onWorkflowStepClick(step)"
|
||||
>
|
||||
<span class="step-circle" :style="circleStyle(currentGroup)">{{ index + 1 }}</span>
|
||||
<span class="step-name">{{ step.name }}</span>
|
||||
<span class="step-desc">{{ step.desc }}</span>
|
||||
</li>
|
||||
<span v-if="index < visibleWorkflow(currentGroup).length - 1" class="step-line" :style="lineStyle()"></span>
|
||||
</template>
|
||||
</ol>
|
||||
</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">
|
||||
<template v-for="tool in group.tools" :key="tool.id">
|
||||
<a v-if="resolveHref(tool.href)" :href="resolveHref(tool.href)!" class="tool-card">
|
||||
<template v-for="tool in currentGroup.tools" :key="tool.id">
|
||||
<router-link v-if="resolveHref(tool.href)" :to="resolveHref(tool.href)!" class="tool-card" :style="cardStyle(currentGroup)">
|
||||
<div class="tool-card-top">
|
||||
<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 class="tool-card-name">{{ tool.name }}</div>
|
||||
<div class="tool-card-desc">{{ tool.desc }}</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">
|
||||
<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 class="tool-card-name">{{ tool.name }}</div>
|
||||
<div class="tool-card-desc">{{ tool.desc }}</div>
|
||||
@@ -112,25 +119,33 @@
|
||||
</template>
|
||||
|
||||
<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 AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.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 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 { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const allowedKeys = ref<string[]>([])
|
||||
const visibleGroups = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
|
||||
/** 权限接口异常时为 true:展示全部工具与时间线,避免页面空白 */
|
||||
const permissionUnavailable = ref(false)
|
||||
const statusText = ref('')
|
||||
const statusType = ref<'normal' | 'error'>('normal')
|
||||
const activeCat = ref<'front' | 'ops' | 'logi'>('front')
|
||||
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 → /xxx(SPA 路径,见 shared/page-prefix) */
|
||||
function resolveHref(rawHref?: string): string {
|
||||
return resolvePageHref(rawHref)
|
||||
}
|
||||
@@ -145,6 +160,20 @@ function stepHref(toolId?: string) {
|
||||
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) {
|
||||
if (!step.toolId) {
|
||||
return
|
||||
@@ -152,7 +181,7 @@ function onWorkflowStepClick(step: WorkflowStep) {
|
||||
const target = TOOL_MAP.get(step.toolId)
|
||||
const href = resolveHref(target?.href ?? '')
|
||||
if (href) {
|
||||
window.location.href = href
|
||||
router.push(href)
|
||||
return
|
||||
}
|
||||
showSoon(target?.href ? `${step.name}仅桌面客户端可用` : `${step.name}暂未开通,敬请期待`)
|
||||
@@ -167,31 +196,46 @@ function onPendingClick(tool: ToolInfo) {
|
||||
}
|
||||
|
||||
function showSoon(name: string) {
|
||||
statusText.value = `${name}暂未开通,敬请期待`
|
||||
statusType.value = 'normal'
|
||||
toast(`${name}暂未开通,敬请期待`, 'normal')
|
||||
}
|
||||
|
||||
function toast(msg: string, type: 'normal' | 'error' = 'normal') {
|
||||
statusText.value = msg
|
||||
statusType.value = type
|
||||
if (statusTimer) {
|
||||
window.clearTimeout(statusTimer)
|
||||
}
|
||||
statusTimer = window.setTimeout(() => {
|
||||
statusText.value = ''
|
||||
}, 2200)
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
function scrollToTools() {
|
||||
void nextTick(() => {
|
||||
document.querySelector('.card-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToHashGroup() {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (!hash) return
|
||||
const target = document.getElementById(hash)
|
||||
if (target) {
|
||||
void nextTick(() => {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
async function onDownloadClick() {
|
||||
// 教程客户端压缩包取自 MinIO 公开直链(client/tutorial/,见 download-btn.ts)
|
||||
const api = getPywebviewApi()
|
||||
if (api?.open_external_url) {
|
||||
// 桌面端:交系统默认浏览器下载,可看到下载进度
|
||||
try {
|
||||
const res = await api.open_external_url(TUTORIAL_DOWNLOAD_URL)
|
||||
if (!res?.success) toast(`下载失败:${res?.error ?? '请重试'}`, 'error')
|
||||
} catch (_error) {
|
||||
toast('下载失败,请重试', 'error')
|
||||
}
|
||||
return
|
||||
}
|
||||
// 网页端:新窗口打开直链下载
|
||||
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 () => {
|
||||
@@ -204,7 +248,7 @@ onMounted(async () => {
|
||||
visibleGroups.value = permissionUnavailable.value
|
||||
? TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] }))
|
||||
: filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value)
|
||||
scrollToHashGroup()
|
||||
parseHashGroup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -214,7 +258,11 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
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 {
|
||||
@@ -222,23 +270,46 @@ onMounted(async () => {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 22px 34px 44px;
|
||||
background: #151a25;
|
||||
padding: 14px 28px 28px;
|
||||
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 {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto 26px;
|
||||
border: 1px solid #2e3a52;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1b2233 0%, #151a24 100%);
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 26px 28px;
|
||||
padding: 26px 30px;
|
||||
border: 1px solid #3e4a62;
|
||||
border-radius: 12px;
|
||||
background: #232a3b;
|
||||
}
|
||||
|
||||
.hero-left {
|
||||
@@ -248,75 +319,78 @@ onMounted(async () => {
|
||||
|
||||
.hero-pill {
|
||||
display: inline-block;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(45, 212, 191, 0.14);
|
||||
margin-bottom: 12px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid rgba(45, 212, 191, 0.2);
|
||||
border-radius: 20px;
|
||||
background: #0f2e28;
|
||||
color: #2dd4bf;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 14px 0 0;
|
||||
margin: 0 0 8px;
|
||||
color: #f5f8fc;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
max-width: 620px;
|
||||
margin: 10px 0 0;
|
||||
color: #a0acbe;
|
||||
max-width: 650px;
|
||||
margin: 0 0 20px;
|
||||
color: #c8d2e2;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
gap: 44px;
|
||||
margin-top: 22px;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat span {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #5e6878;
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
color: #a0acbe;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Hero 右侧工作台卡 */
|
||||
/* Hero 右侧工作台卡(350 宽 / 10px 圆角,对齐 gemini-code hero-right-card) */
|
||||
.hero-card {
|
||||
width: 300px;
|
||||
width: 350px;
|
||||
flex-shrink: 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #2e3a52;
|
||||
border-radius: 12px;
|
||||
background: #202a3e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
border: 1px solid #3e4a62;
|
||||
border-radius: 10px;
|
||||
background: #2b3447;
|
||||
}
|
||||
|
||||
.hero-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hero-card-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -326,70 +400,189 @@ onMounted(async () => {
|
||||
|
||||
.dot--blue {
|
||||
background: #60a5fa;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.dot--brand {
|
||||
background: #f59e0b;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.dots-line {
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 2px;
|
||||
margin-top: 14px;
|
||||
background: #3e4a62;
|
||||
}
|
||||
|
||||
.hero-card-caption {
|
||||
color: #5e6878;
|
||||
font-size: 10px;
|
||||
color: #a0acbe;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hero-card-title {
|
||||
margin: 16px 0 0;
|
||||
margin: 0 0 12px;
|
||||
color: #f5f8fc;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
line-height: 1.6;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero-card-divider {
|
||||
height: 1px;
|
||||
margin: 14px 0;
|
||||
margin-bottom: 12px;
|
||||
background: #3e4a62;
|
||||
}
|
||||
|
||||
.hero-card-sub {
|
||||
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;
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-card-btn {
|
||||
margin-top: 14px;
|
||||
padding: 9px 22px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
.workflow-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.workflow-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.hero-card-btn:hover {
|
||||
background: #f7b13c;
|
||||
.workflow-step:not(.workflow-step--disabled):hover {
|
||||
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 {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto 34px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
@@ -407,177 +600,101 @@ onMounted(async () => {
|
||||
|
||||
.group-name {
|
||||
color: #f5f8fc;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.group-desc {
|
||||
color: #5e6878;
|
||||
color: #a0acbe;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
margin-left: auto;
|
||||
color: #5e6878;
|
||||
color: #a0acbe;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ===== 工作流时间线 ===== */
|
||||
.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 底 + 2px #2A3344 边框 + 10px 圆角 min-height 165,对齐 gemini-code tool-card) */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding: 16px 18px 14px;
|
||||
border: 2px solid #2e3a52;
|
||||
border-radius: 12px;
|
||||
background: #1c2333;
|
||||
min-height: 165px;
|
||||
padding: 16px;
|
||||
border: 2px solid #2a3344;
|
||||
border-radius: 10px;
|
||||
background: #2b3447;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
/* 悬浮:边框高亮为分类色 + 背景变亮 + 上浮阴影(对齐 gemini-code hover 并按要求加边框高亮) */
|
||||
.tool-card:hover {
|
||||
border-color: #2dd4bf;
|
||||
background: #262f42;
|
||||
background: #323d52;
|
||||
border-color: var(--gc, #2dd4bf);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tool-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tool-tag {
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-size: 9px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 悬浮:角标变分类色实底白字(保留的亮反馈) */
|
||||
.tool-card:hover .tool-tag {
|
||||
background: var(--gc, #2dd4bf) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.tool-card-name {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 6px;
|
||||
color: #f5f8fc;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tool-card-desc {
|
||||
margin-top: 6px;
|
||||
color: #5e6878;
|
||||
flex: 1;
|
||||
color: #a0acbe;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tool-card-go {
|
||||
margin-top: 14px;
|
||||
color: #5e6878;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
color: #a0acbe;
|
||||
font-size: 11px;
|
||||
transition: color 0.18s ease;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.tool-card:hover .tool-card-go {
|
||||
color: #2dd4bf;
|
||||
color: var(--gc, #2dd4bf);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -587,31 +704,33 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.tool-card--disabled:hover {
|
||||
border-color: #2e3a52;
|
||||
background: #1c2333;
|
||||
border-color: #2a3344;
|
||||
background: #2b3447;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tool-card--disabled:hover .tool-card-go {
|
||||
color: #5e6878;
|
||||
color: #a0acbe;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ===== toast ===== */
|
||||
/* ===== toast(右下角 + 品牌色左边条,对齐 gemini-code toast) ===== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
bottom: 60px;
|
||||
right: 30px;
|
||||
z-index: 1000;
|
||||
padding: 12px 24px;
|
||||
max-width: min(420px, calc(100vw - 48px));
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
max-width: min(420px, calc(100vw - 60px));
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #f59e0b;
|
||||
background: #3d4a63;
|
||||
color: #f5f8fc;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
@@ -620,6 +739,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left-color: #f87171;
|
||||
background: rgba(176, 42, 55, 0.92);
|
||||
}
|
||||
|
||||
@@ -628,8 +748,9 @@ onMounted(async () => {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
@@ -637,7 +758,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.console-body {
|
||||
padding: 18px 20px 36px;
|
||||
padding: 14px 16px 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,23 +11,29 @@
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 渐变装饰条固定 青→蓝→紫→橙(对齐 gemini-code footer-gradient-bar)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.amazon-footer {
|
||||
flex: 0 0 auto;
|
||||
background: #151a25;
|
||||
background: #1a1f2e;
|
||||
border-top: 1px solid #3e4a62;
|
||||
}
|
||||
|
||||
.amazon-footer__gradient {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 12px;
|
||||
gap: 20px;
|
||||
padding: 10px 28px;
|
||||
}
|
||||
|
||||
.bug-banner {
|
||||
@@ -35,24 +41,26 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
padding: 9px 24px;
|
||||
gap: 40px;
|
||||
padding: 10px 24px;
|
||||
border: 1px solid #2dd4bf;
|
||||
border-radius: 8px;
|
||||
background: #1e293b;
|
||||
text-decoration: none;
|
||||
transition: background 0.18s ease, border-color 0.18s ease;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bug-banner:hover {
|
||||
background: #0f172a;
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 12px rgba(45, 212, 191, 0.2);
|
||||
}
|
||||
|
||||
.bug-banner__title {
|
||||
flex-shrink: 0;
|
||||
color: #2dd4bf;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
<AmazonTopBar :active-cat="groupKey" />
|
||||
|
||||
<div class="tool-head">
|
||||
<a class="tool-head__back" :href="backHref">← 返回{{ group ? group.name : '工具' }}列表</a>
|
||||
<span class="tool-head__sep"></span>
|
||||
<router-link class="tool-head__back" :to="backHref">← 返回{{ group ? group.name : '工具' }}列表</router-link>
|
||||
<span class="tool-head__title">{{ tool?.name ?? '' }}</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>
|
||||
|
||||
<!-- 工具说明提示卡已按需求移除(原棕色底 note 卡区域) -->
|
||||
|
||||
<div class="tool-shell-body">
|
||||
<slot />
|
||||
</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 { 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<{
|
||||
/** 工具 id(见 tool-catalog TOOL_MAP) */
|
||||
toolId: string
|
||||
@@ -47,53 +59,79 @@ const backHref = computed(() => `${resolvePageHref('/new_web_source/amazon-conso
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
min-height: 62px;
|
||||
padding: 10px 34px;
|
||||
background: #151a25;
|
||||
border-bottom: 1px solid #2a3344;
|
||||
background: #1a1f2e;
|
||||
}
|
||||
|
||||
.tool-head__back {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #3e4a62;
|
||||
border-radius: 6px;
|
||||
color: #c8d2e2;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-head__back:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tool-head__sep {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #3e4a62;
|
||||
background: #2b3447;
|
||||
color: #f5f8fc;
|
||||
}
|
||||
|
||||
.tool-head__title {
|
||||
flex-shrink: 0;
|
||||
color: #f5f8fc;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-head__desc {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
max-width: 650px;
|
||||
color: #a0acbe;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
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;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-head__aliprice:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.tool-shell-body {
|
||||
@@ -105,8 +143,7 @@ const backHref = computed(() => `${resolvePageHref('/new_web_source/amazon-conso
|
||||
@media (max-width: 1100px) {
|
||||
.tool-head {
|
||||
padding: 10px 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
<template>
|
||||
<header class="amazon-top">
|
||||
<div class="brand">
|
||||
<router-link class="brand" :to="toolbarHref" title="返回工具台首页">
|
||||
<span class="brand-logo">富</span>
|
||||
<div class="brand-text">
|
||||
<span class="brand-text">
|
||||
<span class="brand-name">数富AI</span>
|
||||
<span class="brand-sub">亚马逊运营工具台</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<nav class="cat-tabs" aria-label="分类导航">
|
||||
<template v-for="cat in visibleCats" :key="cat.key">
|
||||
<a
|
||||
<router-link
|
||||
class="cat-tab"
|
||||
: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-name">{{ cat.name }}</span>
|
||||
<span class="cat-badge" :style="badgeStyle(cat)">{{ cat.tools.length }}</span>
|
||||
<span class="cat-underline" :style="{ background: cat.color }"></span>
|
||||
</a>
|
||||
<span class="cat-tab-content">
|
||||
<span class="cat-dot" :style="{ background: cat.color }"></span>
|
||||
<span class="cat-name">{{ cat.name }}</span>
|
||||
<span class="cat-badge" :style="badgeStyle(cat)">{{ cat.tools.length }}</span>
|
||||
</span>
|
||||
<span class="cat-underline" :style="underlineStyle(cat)"></span>
|
||||
</router-link>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="top-right">
|
||||
<a href="/home" class="btn-home">返回首页</a>
|
||||
<BrandApiSecretSettingsButton />
|
||||
<span class="help-mark" title="帮助">?</span>
|
||||
<BrandApiSecretSettingsButton variant="topbar" />
|
||||
<router-link class="help-btn" :to="workHref" title="采集工作台">⚙</router-link>
|
||||
<span class="help-btn" title="如需支持请点击底部BUG登记系统">?</span>
|
||||
<span class="admin-chip"><span class="admin-badge">AD</span>{{ username }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -50,17 +54,34 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ change: [key: 'front' | 'ops' | 'logi'] }>()
|
||||
|
||||
const activeCat = computed(() => props.activeCat)
|
||||
const visibleCats = ref<VisibleGroup[]>(TOOL_GROUPS.map((group) => ({ ...group, tools: [...group.tools] })))
|
||||
const username = ref('admin')
|
||||
|
||||
/** 分类 tab 点击回到工具台对应分组 */
|
||||
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) {
|
||||
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 } : {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const localName = typeof window === 'undefined' ? '' : (window.localStorage.getItem('username') || '').trim()
|
||||
if (localName) username.value = localName
|
||||
try {
|
||||
const keys = await getCurrentUserAppColumnKeys()
|
||||
visibleCats.value = filterGroupsByPermission(TOOL_GROUPS, keys)
|
||||
@@ -79,9 +100,10 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 0 24px;
|
||||
background: #151a25;
|
||||
border-bottom: 1px solid #2a3344;
|
||||
padding: 0 28px;
|
||||
background: #1a1f2e;
|
||||
border-bottom: 1px solid #3e4a62;
|
||||
font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -89,6 +111,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
@@ -97,11 +120,12 @@ onMounted(async () => {
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
border-radius: 8px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
@@ -112,14 +136,13 @@ onMounted(async () => {
|
||||
|
||||
.brand-name {
|
||||
color: #f5f8fc;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
color: #5e6878;
|
||||
color: #a0acbe;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -130,36 +153,46 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cat-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 132px;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
width: 140px;
|
||||
height: 42px;
|
||||
background: transparent;
|
||||
color: #c8d2e2;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cat-tab:hover {
|
||||
background: #232a3b;
|
||||
.cat-tab-content {
|
||||
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;
|
||||
}
|
||||
|
||||
.cat-tab--active {
|
||||
background: #3d4a63;
|
||||
color: #f5f8fc;
|
||||
}
|
||||
|
||||
.cat-tab--active .cat-tab-content {
|
||||
background: #3d4a63;
|
||||
}
|
||||
|
||||
.cat-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -171,33 +204,23 @@ onMounted(async () => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: #2b3447;
|
||||
color: #5e6878;
|
||||
padding: 1px 7px;
|
||||
border-radius: 12px;
|
||||
background: #343f55;
|
||||
color: #a0acbe;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cat-tab--active .cat-badge {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cat-underline {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: -4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cat-tab--active .cat-underline {
|
||||
opacity: 1;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
@@ -207,29 +230,45 @@ onMounted(async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-home {
|
||||
color: #a0acbe;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-home:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.help-mark {
|
||||
.help-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #232a3b;
|
||||
color: #a0acbe;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: help;
|
||||
color: #c8d2e2;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
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) {
|
||||
|
||||
@@ -13,13 +13,16 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string
|
||||
size?: number
|
||||
/** 分类主色:传入时图标用该纯色底(对齐主程序 _get_icon 按分类色) */
|
||||
color?: string
|
||||
}>(),
|
||||
{
|
||||
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)
|
||||
</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'
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 亚马逊运营工具台 · 工具目录
|
||||
*
|
||||
* 来源:reference/数富AI工具台_含源代码/数富AI工具台_主程序.py 的 CATS 数据,
|
||||
* 来源:原 tkinter 版工具台(reference/数富AI工具台_含源代码,已废弃删除)的 CATS 数据,
|
||||
* 页面链接按现有 BrandTopBar 导航映射到既有功能页(表单 + 右侧任务全部保留现有)。
|
||||
*/
|
||||
|
||||
@@ -47,9 +47,6 @@ export type ToolGroup = {
|
||||
workflow?: WorkflowStep[]
|
||||
}
|
||||
|
||||
/** 现有功能页地址(全部为 new_web_source 的 Vue 构建页) */
|
||||
const VM = '/new_web_source'
|
||||
|
||||
export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
{
|
||||
key: 'front',
|
||||
@@ -77,7 +74,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
name: '采集数据',
|
||||
desc: '批量采集站点商品数据,按照自己要求数据',
|
||||
tag: '流程·1',
|
||||
href: `${VM}/collect-data.html`,
|
||||
href: '/collect-data',
|
||||
permissionKeys: ['collect-data'],
|
||||
},
|
||||
{
|
||||
@@ -85,7 +82,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
name: 'ASIN变体采集',
|
||||
desc: '支持粘贴和文档,按照采集国家选择导出',
|
||||
tag: '流程·2',
|
||||
href: `${VM}/variant-collection.html`,
|
||||
href: '/variant-collection',
|
||||
permissionKeys: ['variant-collection'],
|
||||
},
|
||||
{
|
||||
@@ -94,7 +91,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
desc: '全站唯一 ASIN 归属:谁先采集归谁,自动过滤撞款',
|
||||
tag: '重点',
|
||||
core: true,
|
||||
href: `${VM}/dedupe.html`,
|
||||
href: '/dedupe',
|
||||
permissionKeys: ['dedupe'],
|
||||
},
|
||||
{
|
||||
@@ -102,7 +99,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
name: '货源查询',
|
||||
desc: '关闭图片检查和类目检测不扣费,打开需要算力费用',
|
||||
tag: '流程·4',
|
||||
href: `${VM}/similar-asin.html`,
|
||||
href: '/similar-asin',
|
||||
permissionKeys: ['similar-asin'],
|
||||
},
|
||||
{
|
||||
@@ -110,7 +107,7 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
name: '品牌检测',
|
||||
desc: '核查上架数据品牌风险,精确 / 嵌入两种策略',
|
||||
tag: '流程·5',
|
||||
href: `${VM}/brand.html`,
|
||||
href: '/brand',
|
||||
permissionKeys: ['brand'],
|
||||
},
|
||||
{
|
||||
@@ -118,21 +115,21 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
name: '外观专利检测',
|
||||
desc: '检测商品外观专利,版权,标题,图片。详细页风险,规避下架',
|
||||
tag: '流程·6',
|
||||
href: `${VM}/appearance-patent.html`,
|
||||
href: '/appearance-patent',
|
||||
permissionKeys: ['appearance-patent'],
|
||||
},
|
||||
{
|
||||
id: 'split',
|
||||
name: '数据拆分',
|
||||
desc: '大文件按规则拆分,便于分批处理与上传',
|
||||
href: `${VM}/split.html`,
|
||||
href: '/split',
|
||||
permissionKeys: ['split'],
|
||||
},
|
||||
{
|
||||
id: 'convert',
|
||||
name: '格式转换',
|
||||
desc: '数据格式一键转换,适配各工具导入',
|
||||
href: `${VM}/convert.html`,
|
||||
href: '/convert',
|
||||
permissionKeys: ['convert'],
|
||||
},
|
||||
],
|
||||
@@ -164,14 +161,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
id: 'list',
|
||||
name: '上架',
|
||||
desc: '把安全数据批量上架到指定店铺',
|
||||
href: `${VM}/publish.html`,
|
||||
href: '/publish',
|
||||
permissionKeys: ['publish'],
|
||||
},
|
||||
{
|
||||
id: 'delasin',
|
||||
name: '指定删除ASIN',
|
||||
desc: '批量删除需清理的 ASIN(风险 / 优化 / 邮箱汇总)',
|
||||
href: `${VM}/delete-brand.html`,
|
||||
href: '/delete-brand',
|
||||
permissionKeys: ['delete-brand'],
|
||||
},
|
||||
{
|
||||
@@ -180,14 +177,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
desc: '4 类风险一键处理:禁止显示 / 需要批准 / 详情已删 / 账户状态',
|
||||
tag: '高频',
|
||||
core: true,
|
||||
href: `${VM}/product-risk.html`,
|
||||
href: '/product-risk',
|
||||
permissionKeys: ['product-risk'],
|
||||
},
|
||||
{
|
||||
id: 'time',
|
||||
name: '定时匹配',
|
||||
desc: '定时价格匹配,更快更及时抢回购物车',
|
||||
href: `${VM}/shop-match.html`,
|
||||
href: '/shop-match',
|
||||
permissionKeys: ['shop-match'],
|
||||
},
|
||||
{
|
||||
@@ -196,21 +193,21 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
desc: '核心工具 · 时时跟价抢购物车,支持指定 ASIN 快速跟价',
|
||||
tag: '核心',
|
||||
core: true,
|
||||
href: `${VM}/price-track.html`,
|
||||
href: '/price-track',
|
||||
permissionKeys: ['pricing', 'price-track'],
|
||||
},
|
||||
{
|
||||
id: 'patrol',
|
||||
name: '巡店删除',
|
||||
desc: '按商品状态巡店清理,摸清店铺商品全貌',
|
||||
href: `${VM}/patrol-delete.html`,
|
||||
href: '/patrol-delete',
|
||||
permissionKeys: ['patrol-delete'],
|
||||
},
|
||||
{
|
||||
id: 'qasin',
|
||||
name: '查询ASIN',
|
||||
desc: '排查出单 ASIN 是否仍在售,保住优质数据',
|
||||
href: `${VM}/query-asin.html`,
|
||||
href: '/query-asin',
|
||||
permissionKeys: ['query-asin'],
|
||||
},
|
||||
{
|
||||
@@ -219,14 +216,14 @@ export const TOOL_GROUPS: ReadonlyArray<ToolGroup> = [
|
||||
desc: '核心 · 抓取店铺排名 / 品牌 / 销售,支撑复查与优化',
|
||||
tag: '核心',
|
||||
core: true,
|
||||
href: `${VM}/shop-data-crawl.html`,
|
||||
href: '/shop-data-crawl',
|
||||
permissionKeys: ['shop-data-crawl', 'shop_data_crawl'],
|
||||
},
|
||||
{
|
||||
id: 'cash',
|
||||
name: '取款',
|
||||
desc: '留足平台扣款费用,其余全部提现',
|
||||
href: `${VM}/withdraw.html`,
|
||||
href: '/withdraw',
|
||||
permissionKeys: ['withdraw'],
|
||||
},
|
||||
{
|
||||
@@ -345,7 +342,7 @@ export const TOOL_DETAILS: Readonly<Record<string, string>> = {
|
||||
brand: '核查上架数据品牌风险,先跑精确匹配,后跑嵌入式',
|
||||
delasin: '收集统一要删除的ASIN,批量删除清理',
|
||||
risk:
|
||||
'A 搜索结果中禁止显示;B 需要批准(最通用、使用频率最高);C 详情页面已删除;D 账户状态处理(需审批时用后台处理)。',
|
||||
'A 搜索结果中禁止显示;B 需要批准(最通用、使用频率最高);C 详情页面已删除;D 账户状态处理(需要审批没有时候后台处理)。',
|
||||
follow:
|
||||
'一个虚拟桌面跟一个国家,错开跑即可时时跟价抢购物车。指定 ASIN 文档跟价:用后台有利润数据快速跟价,下班前跑一下,确保出单数据及时是我们的购物车。',
|
||||
patrol: '巡店删除是按商品状态删除,如审核多次不通过的商品。不放任何状态跑一遍,可清楚掌握店铺数据情况。',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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>密钥设置</span>
|
||||
</button>
|
||||
@@ -173,6 +173,16 @@ type SecretState = {
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** topbar=顶栏纯文字样式(对齐主程序);默认=现有胶囊按钮 */
|
||||
variant?: 'topbar'
|
||||
}>(),
|
||||
{
|
||||
variant: undefined,
|
||||
},
|
||||
)
|
||||
const proxyUrl = ref('')
|
||||
const proxyMode = ref<ProxyMode>(1)
|
||||
const proxyLoading = ref(false)
|
||||
@@ -378,6 +388,29 @@ loadStates()
|
||||
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 {
|
||||
color: #8dc4ff;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ parsing ? '解析中...' : '解析并创建任务' }}
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue">
|
||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||||
{{ pushing ? '启动中...' : '启动任务' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">Python 回传字段按 asin、国家、url、标题提交;后端接收分片后累计 50 条调用 LLM,当前任务区展示 LLM 回流和结果组装进度。</p>
|
||||
@@ -41,96 +41,32 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">外观专利检测</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">运行中任务</span>
|
||||
<strong>{{ dashboard.pendingTaskCount }}</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 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">结束时间:{{ 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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="外观专利检测"
|
||||
:cards="patentCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无当前任务"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<template #item-extra="{ item }">
|
||||
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
|
||||
</template>
|
||||
<template #history-item-extra="{ item }">
|
||||
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</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 v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||
@click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||
</template>
|
||||
</TaskCenterPanel>
|
||||
</section>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -142,6 +78,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.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 {
|
||||
activateAppearancePatentTask,
|
||||
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) {
|
||||
if (item.taskId == null) return item
|
||||
const liveItem = liveProgressItems.value[item.taskId]
|
||||
@@ -531,7 +514,7 @@ async function pushToPythonQueue() {
|
||||
clearParsedTask()
|
||||
await loadDashboard()
|
||||
await loadHistory({ force: true })
|
||||
ElMessage.success('已推送到 Python 队列')
|
||||
ElMessage.success('已启动任务')
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
@@ -979,6 +962,26 @@ onUnmounted(() => {
|
||||
stopPolling()
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1006,36 +1009,12 @@ onUnmounted(() => {
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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); }
|
||||
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
|
||||
@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)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -76,44 +76,36 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">
|
||||
<span>品牌检测任务</span>
|
||||
<div class="panel-actions-row">
|
||||
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
|
||||
</div>
|
||||
<div class="task-list-wrap">
|
||||
<div v-if="!tasks.length" class="empty-tasks">
|
||||
暂无品牌检测任务。选择待检文件后点击「运行」或「添加到任务队列」。
|
||||
</div>
|
||||
<ul v-else class="task-list">
|
||||
<li v-for="item in tasks" :key="`brand-${item.id}`" class="task-item">
|
||||
<div class="left">
|
||||
<span class="id" :title="taskDesc(item)">{{ taskDesc(item) }}</span>
|
||||
<div class="files">任务 ID:{{ item.id }}</div>
|
||||
<div v-if="item.file_paths?.length" class="files">文件:{{ item.file_paths.length }} 个</div>
|
||||
<div class="files">创建时间:{{ formatDateTime(item.created_at) }}</div>
|
||||
<template v-if="isRunning(item)">
|
||||
<div class="task-progress">
|
||||
<div class="task-progress-meta">
|
||||
<span>任务进度</span>
|
||||
<span>{{ taskProgress(item) }}%</span>
|
||||
</div>
|
||||
<div class="task-progress-track">
|
||||
<div class="task-progress-fill" :style="{ width: `${taskProgress(item)}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorMessage(item)" class="files error">{{ errorMessage(item) }}</div>
|
||||
</template>
|
||||
<div v-else-if="errorMessage(item)" class="files error">错误:{{ errorMessage(item) }}</div>
|
||||
</div>
|
||||
<div class="task-right">
|
||||
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
|
||||
<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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="品牌检测任务"
|
||||
:cards="brandCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无当前任务,提交检测后运行中任务会显示在这里"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<template #item-actions="{ item }">
|
||||
<template v-if="itemSource(item)">
|
||||
<button v-if="isRunning(itemSource(item))" type="button" class="btn-delete"
|
||||
@click="cancelTask(itemSource(item))">取消</button>
|
||||
<button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
|
||||
@click="deleteTask(itemSource(item))">删除</button>
|
||||
</template>
|
||||
</template>
|
||||
<template #history-item-actions="{ item }">
|
||||
<template v-if="itemSource(item)">
|
||||
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||
@click="downloadResult(itemSource(item))">下载结果</button>
|
||||
<button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
|
||||
@click="deleteTask(itemSource(item))">删除</button>
|
||||
</template>
|
||||
</template>
|
||||
</TaskCenterPanel>
|
||||
</section>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -124,18 +116,21 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 { requestPostJson } from '@/shared/api/http'
|
||||
import {
|
||||
cancelBrandTask,
|
||||
createBrandTask,
|
||||
deleteBrandTask,
|
||||
expandBrandFolder,
|
||||
getBrandTaskDownloadUrl,
|
||||
getBrandTasks,
|
||||
runBrandNow,
|
||||
type BrandTaskItem,
|
||||
} from '@/shared/api/brand'
|
||||
|
||||
/** 扩展文件夹接口响应(向后端真实字段 paths 兼容,shared 类型仅声明 items 时以本接口为准) */
|
||||
interface BrandExpandFolderPathsResponse {
|
||||
success: boolean
|
||||
paths?: string[]
|
||||
@@ -172,6 +167,11 @@ function isRunning(item: BrandTaskItem) {
|
||||
return (item.status || '').toLowerCase() === 'running'
|
||||
}
|
||||
|
||||
function isBusyBrandTask(item: BrandTaskItem) {
|
||||
const status = (item.status || '').toLowerCase()
|
||||
return status === 'pending' || status === 'running'
|
||||
}
|
||||
|
||||
function isTerminal(item: BrandTaskItem) {
|
||||
const status = (item.status || '').toLowerCase()
|
||||
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)))
|
||||
}
|
||||
|
||||
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) {
|
||||
return (item.error_message || '').trim()
|
||||
}
|
||||
@@ -205,7 +200,7 @@ function errorMessage(item: BrandTaskItem) {
|
||||
function statusText(status?: BrandTaskItem['status']) {
|
||||
const value = (status || '').toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
pending: '等待中',
|
||||
pending: '排队中',
|
||||
running: '执行中',
|
||||
success: '已完成',
|
||||
failed: '失败',
|
||||
@@ -219,10 +214,57 @@ function statusClass(status?: BrandTaskItem['status']) {
|
||||
if (value === 'running') return 'running'
|
||||
if (value === 'success') return 'success'
|
||||
if (value === 'failed') return 'failed'
|
||||
if (value === 'cancelled') return 'cancelled'
|
||||
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) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
@@ -267,7 +309,7 @@ async function selectFolder() {
|
||||
try {
|
||||
const folder = await bridge.select_brand_folder()
|
||||
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) {
|
||||
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
|
||||
return
|
||||
@@ -433,6 +475,26 @@ onBeforeUnmount(() => {
|
||||
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>
|
||||
|
||||
<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:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.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; }
|
||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
||||
.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: 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; }
|
||||
.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); }
|
||||
@media (max-width: 1100px) {
|
||||
.main-content { flex-direction: column; height: auto; }
|
||||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||||
|
||||
@@ -102,10 +102,10 @@
|
||||
:disabled="pushing || !lastTaskId"
|
||||
@click="pushToPythonQueue"
|
||||
>
|
||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||||
{{ pushing ? '启动中...' : '启动任务' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务(PENDING);推送到 Python 队列将激活任务(RUNNING),Python 端按 50 条/页拉取明细执行采集。</p>
|
||||
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务(PENDING);启动任务将激活任务(RUNNING),Python 端按 50 条/页拉取明细执行采集。</p>
|
||||
|
||||
<div v-if="queuePushResult" class="queue-debug-card">
|
||||
<div class="section-title queue-debug-title">推送结果</div>
|
||||
@@ -117,93 +117,26 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">采集数据</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">运行中任务</span>
|
||||
<strong>{{ dashboard.pendingTaskCount }}</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 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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="采集数据"
|
||||
:cards="collectCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无当前任务"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<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 v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||
@click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||
</template>
|
||||
</TaskCenterPanel>
|
||||
</section>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -215,6 +148,8 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.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 { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
|
||||
@@ -518,7 +453,7 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
ElMessage.warning('当前环境未提供 Python 队列能力')
|
||||
ElMessage.warning('当前环境未提供任务队列能力')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
@@ -549,12 +484,12 @@ async function pushToPythonQueue() {
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || '入队失败')
|
||||
}
|
||||
queuePushResult.value = `已推送至 Python 队列(队列长度:${result.queue_size ?? '-'})`
|
||||
queuePushResult.value = `已启动任务(队列长度:${result.queue_size ?? '-'})`
|
||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||
if (lastTaskId.value) {
|
||||
progressLoop.add(lastTaskId.value)
|
||||
}
|
||||
ElMessage.success('已推送到 Python 队列')
|
||||
ElMessage.success('已启动任务')
|
||||
await loadDashboard()
|
||||
} catch (error) {
|
||||
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(() => {
|
||||
void loadCountryPreference()
|
||||
void loadDashboard()
|
||||
@@ -759,6 +751,26 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -766,7 +778,7 @@ onBeforeUnmount(() => {
|
||||
.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; }
|
||||
.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; }
|
||||
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||||
.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-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); }
|
||||
.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) {
|
||||
.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)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -111,59 +111,37 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">转换结果</div>
|
||||
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">已处理文件</span>
|
||||
<strong>{{ convertSummary.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">成功结果</span>
|
||||
<strong>{{ convertSummary.successCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">失败文件</span>
|
||||
<strong>{{ convertSummary.failedCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>生成的结果压缩包列表</span>
|
||||
</div>
|
||||
|
||||
<div v-if="convertResultItems.length === 0" class="empty-tasks">
|
||||
暂无转换结果,完成格式转换后会在这里展示结果压缩包
|
||||
</div>
|
||||
|
||||
<ul v-else class="task-list clean-result-list">
|
||||
<li v-for="item in convertResultItems"
|
||||
: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="downloadConvertResult(item)">
|
||||
下载压缩包
|
||||
</button>
|
||||
<button v-if="item.resultId" type="button" class="btn-delete"
|
||||
@click="deleteConvertHistoryRecord(item.resultId)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="转换结果"
|
||||
:cards="convertCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无当前任务,完成格式转换后会在这里展示"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<template #history-item-actions="{ item }">
|
||||
<template v-if="itemSource(item)">
|
||||
<button
|
||||
v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
|
||||
type="button"
|
||||
class="download"
|
||||
@click="downloadConvertResult(itemSource(item))"
|
||||
>
|
||||
下载压缩包
|
||||
</button>
|
||||
<button
|
||||
v-if="itemSource(item).resultId"
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
@click="deleteConvertHistoryRecord(itemSource(item).resultId!)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</TaskCenterPanel>
|
||||
</section>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -174,6 +152,8 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 type { BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import {
|
||||
@@ -186,7 +166,6 @@ import {
|
||||
runConvert,
|
||||
setDefaultConvertTemplate,
|
||||
type ConvertResultItem,
|
||||
type ConvertRunVo,
|
||||
type ConvertTemplateVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
@@ -199,7 +178,6 @@ const convertArchiveName = ref('')
|
||||
const convertUploadedFiles = ref<UploadedJavaFile[]>([])
|
||||
const convertRunning = ref(false)
|
||||
const convertResultItems = ref<ConvertResultItem[]>([])
|
||||
const convertSummary = ref<ConvertRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
||||
const convertTemplates = ref<ConvertTemplateVo[]>([])
|
||||
const convertTemplateId = ref('')
|
||||
const templateUploading = ref(false)
|
||||
@@ -215,6 +193,77 @@ const canDeleteCurrentTemplate = computed(
|
||||
() => 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>) {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.upload_file_to_java) {
|
||||
@@ -458,7 +507,6 @@ async function submitConvertRun() {
|
||||
? convertArchiveName.value
|
||||
: undefined,
|
||||
})
|
||||
convertSummary.value = result
|
||||
convertResultItems.value = result.items || []
|
||||
await loadConvertHistory()
|
||||
if (result.total > 0 && result.successCount === 0) {
|
||||
@@ -485,12 +533,6 @@ async function loadConvertHistory() {
|
||||
try {
|
||||
const response = await getConvertHistory()
|
||||
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 {
|
||||
// ignore history load errors
|
||||
}
|
||||
@@ -526,6 +568,26 @@ onMounted(() => {
|
||||
loadConvertTemplates().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>
|
||||
|
||||
<style scoped>
|
||||
@@ -832,81 +894,6 @@ onMounted(() => {
|
||||
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 {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
@@ -931,60 +918,16 @@ onMounted(() => {
|
||||
background: rgba(231, 76, 60, 0.22);
|
||||
}
|
||||
|
||||
.empty-tasks {
|
||||
.files {
|
||||
font-size: 12px;
|
||||
color: #5e6878;
|
||||
font-size: 13px;
|
||||
padding: 24px 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.clean-placeholder {
|
||||
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) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
@@ -1000,19 +943,5 @@ onMounted(() => {
|
||||
.right-panel {
|
||||
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>
|
||||
|
||||
@@ -85,59 +85,29 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">去重结果</div>
|
||||
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">已处理文件</span>
|
||||
<strong>{{ cleanSummary.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">成功结果</span>
|
||||
<strong>{{ cleanSummary.successCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">失败文件</span>
|
||||
<strong>{{ cleanSummary.failedCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>处理后的 Excel 列表</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="去重结果"
|
||||
:cards="dedupeCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无当前任务,完成数据去重后会在这里展示"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<template #item-actions="{ item }">
|
||||
<template v-if="itemSource(item)">
|
||||
<button v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
|
||||
type="button" class="download" @click="downloadCleanResult(itemSource(item))">
|
||||
下载文件
|
||||
</button>
|
||||
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||
@click="deleteCleanHistoryRecord(itemSource(item).resultId!)">
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</TaskCenterPanel>
|
||||
</section>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -148,6 +118,8 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 { 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'
|
||||
@@ -168,6 +140,88 @@ const cleanProgressProcessed = ref(0)
|
||||
const cleanResultItems = ref<DedupeResultItem[]>([])
|
||||
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
||||
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
|
||||
// 最近一次去重运行信息:用于右侧"当前任务"卡的占位展示(开始时间/运行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() {
|
||||
cleanSelectedColumns.value = [...cleanAvailableColumns.value]
|
||||
@@ -292,6 +346,8 @@ async function submitCleanRun() {
|
||||
|
||||
try {
|
||||
cleanRunning.value = true
|
||||
latestRunId.value = ''
|
||||
cleanRunStartedAt.value = formatDateTime(new Date().toISOString())
|
||||
const progress = await runDedupe({
|
||||
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
|
||||
selectedColumns: cleanSelectedColumns.value,
|
||||
@@ -312,6 +368,7 @@ async function submitCleanRun() {
|
||||
items: cleanResultItems.value,
|
||||
}
|
||||
})
|
||||
latestRunId.value = progress.runId
|
||||
cleanSummary.value = result
|
||||
cleanResultItems.value = result.items || []
|
||||
await loadCleanHistory()
|
||||
@@ -410,6 +467,26 @@ async function downloadCleanResult(item: DedupeResultItem) {
|
||||
onMounted(() => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -666,81 +743,6 @@ onMounted(() => {
|
||||
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 {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
@@ -775,50 +777,6 @@ onMounted(() => {
|
||||
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) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
@@ -834,19 +792,5 @@ onMounted(() => {
|
||||
.right-panel {
|
||||
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>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length"
|
||||
@click="pushToPythonQueue">
|
||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||||
{{ pushing ? '启动中...' : '启动任务' }}
|
||||
</button>
|
||||
<span class="loading-msg">
|
||||
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ queuePushResult }}
|
||||
</div>
|
||||
@@ -77,135 +77,29 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">删除品牌结果</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">已处理文件</span>
|
||||
<strong>{{ summary.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">成功结果</span>
|
||||
<strong>{{ summary.successCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">失败文件</span>
|
||||
<strong>{{ summary.failedCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>删除品牌结果列表</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasVisibleItems" class="empty-tasks">
|
||||
暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果
|
||||
</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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="删除品牌结果"
|
||||
:cards="deleteBrandCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<template #item-actions="{ item }">
|
||||
<button v-if="canDownloadTaskResult(itemSource(item))" type="button" class="download"
|
||||
@click="downloadTaskResult(itemSource(item))">下载</button>
|
||||
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||
@click="deleteHistoryRecord(itemSource(item).resultId!)">删除</button>
|
||||
</template>
|
||||
<template #history-item-actions="{ item }">
|
||||
<button v-if="canDownloadTaskResult(itemSource(item))" type="button" class="download"
|
||||
@click="downloadTaskResult(itemSource(item))">下载</button>
|
||||
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||
@click="deleteHistoryRecord(itemSource(item).resultId!)">删除</button>
|
||||
</template>
|
||||
</TaskCenterPanel>
|
||||
</section>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -217,6 +111,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.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 { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import {
|
||||
@@ -403,11 +299,78 @@ const historySectionItems = computed(() =>
|
||||
(item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item))
|
||||
),
|
||||
);
|
||||
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
||||
const queueRunnableItems = computed(() =>
|
||||
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() {
|
||||
if (typeof window !== 'undefined') {
|
||||
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) {
|
||||
if (item.matchStatus === 'INDEX_STALE' && isUsableMatchedItem(item)) {
|
||||
return `已匹配 ${item.shopId || ''}(索引过期,可继续推送)`.trim()
|
||||
@@ -1135,7 +1124,7 @@ async function submitRun() {
|
||||
const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item))
|
||||
|
||||
queuePushResult.value = hasRunnableItems
|
||||
? '解析完成,可在左侧点击“推送到 Python 队列”开始串行处理'
|
||||
? '解析完成,可在左侧点击“启动任务”开始串行处理'
|
||||
: '解析完成,当前没有可推送的文件'
|
||||
queuePayloadText.value = ''
|
||||
|
||||
@@ -1147,9 +1136,9 @@ async function submitRun() {
|
||||
if (hasBlockedItems) {
|
||||
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
|
||||
} else if (hasStaleMatchedItems) {
|
||||
ElMessage.warning('部分文件命中的是过期索引,仍可推送到 Python 队列;后台刷新后会自动重新匹配。')
|
||||
ElMessage.warning('部分文件命中的是过期索引,仍可启动任务;后台刷新后会自动重新匹配。')
|
||||
} else if (hasRunnableItems) {
|
||||
ElMessage.success('删除品牌解析完成,请在左侧推送到 Python 队列')
|
||||
ElMessage.success('删除品牌解析完成,请在左侧启动任务')
|
||||
} else {
|
||||
ElMessage.warning('当前没有可推送的文件,请先等待店铺索引刷新。')
|
||||
}
|
||||
@@ -1379,7 +1368,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
|
||||
return { success: true, retryable: false }
|
||||
} else {
|
||||
const message = pushResult?.error || '未知错误'
|
||||
qpr = `推送到 Python 队列失败:${message}`
|
||||
qpr = `启动任务失败:${message}`
|
||||
queuePushResult.value = qpr
|
||||
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
|
||||
if (options?.auto && isQueueBusyError(message)) {
|
||||
@@ -1407,7 +1396,7 @@ async function pushToPythonQueue() {
|
||||
activeItemKey.value = ''
|
||||
return
|
||||
}
|
||||
ElMessage.success('已开始按顺序推送到 Python 队列')
|
||||
ElMessage.success('已开始按顺序启动任务')
|
||||
}
|
||||
|
||||
async function maybeAutoAdvance() {
|
||||
@@ -1535,6 +1524,26 @@ onUnmounted(() => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1754,154 +1763,12 @@ onUnmounted(() => {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
color: #5e6878;
|
||||
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 {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
@@ -1926,60 +1793,10 @@ onUnmounted(() => {
|
||||
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) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
@@ -1995,25 +1812,6 @@ onUnmounted(() => {
|
||||
.right-panel {
|
||||
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>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="section-title">店铺输入</div>
|
||||
<div class="input-zone">
|
||||
<div class="hint">
|
||||
左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“推送到 Python 队列”才会创建并执行任务。任务只会跑下方勾选的国家。
|
||||
左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“启动任务”才会创建并执行任务。任务只会跑下方勾选的国家。
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<el-input
|
||||
@@ -136,7 +136,7 @@
|
||||
:disabled="pushing || !matchedRunnableItems.length"
|
||||
@click="pushToPythonQueue"
|
||||
>
|
||||
{{ pushing ? "任务执行中..." : "推送到 Python 队列" }}
|
||||
{{ pushing ? "任务执行中..." : "启动任务" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -154,215 +154,85 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">匹配与任务</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">备选店铺</span>
|
||||
<strong>{{ dashboard.candidateCount }}</strong>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="匹配与任务"
|
||||
:cards="patrolCards"
|
||||
:current-items="currentTaskViews"
|
||||
: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 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 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"
|
||||
<el-table
|
||||
v-else
|
||||
:data="matchedItems"
|
||||
:row-key="rowKeyForMatch"
|
||||
:highlight-current-row="false"
|
||||
class="result-table match-table"
|
||||
>
|
||||
暂无任务记录。推送到 Python 队列后,这里会展示当前任务和历史任务。
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">当前任务</div>
|
||||
<ul class="task-list">
|
||||
<li
|
||||
v-for="item in currentSectionItems"
|
||||
:key="taskGroupKey(item)"
|
||||
class="task-item"
|
||||
<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)"
|
||||
>
|
||||
<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 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>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -373,6 +243,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
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 {
|
||||
addPatrolDeleteCandidate,
|
||||
addPatrolDeleteCondition,
|
||||
@@ -456,6 +328,48 @@ const currentSectionItems = computed(() =>
|
||||
const historySectionItems = computed(() =>
|
||||
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(
|
||||
() =>
|
||||
queueWorkerRunning.value ||
|
||||
@@ -571,7 +485,7 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
|
||||
const message = (row.matchMessage || "").trim();
|
||||
if (message) return message;
|
||||
if (row.matched && row.matchStatus === "MATCHED") {
|
||||
return "索引已命中,可推送到 Python 队列";
|
||||
return "索引已命中,可启动任务";
|
||||
}
|
||||
if (row.matched) {
|
||||
return "已命中索引,请结合状态列确认";
|
||||
@@ -1320,6 +1234,26 @@ onUnmounted(() => {
|
||||
clearSleepTimers();
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1363,42 +1297,19 @@ onUnmounted(() => {
|
||||
.queue-debug-title { 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; }
|
||||
.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; }
|
||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
|
||||
.empty-tasks.narrow { padding: 12px 8px; }
|
||||
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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); }
|
||||
.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); }
|
||||
@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)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
{{ matching ? '匹配中…' : '匹配店铺' }}
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
|
||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
||||
{{ pushing ? '启动中…' : '启动任务' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>,跟价结果合并输出。全量和自定义的区别仅在于ASIN来源不同。</p>
|
||||
@@ -152,109 +152,53 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">匹配与任务</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary pr-summary">
|
||||
<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 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="100" />
|
||||
<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="100" 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 label="说明" min-width="140" 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="!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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="匹配与任务"
|
||||
:cards="priceTrackCards"
|
||||
:current-items="currentTaskViews"
|
||||
: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>
|
||||
<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="100" />
|
||||
<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="100" 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 label="说明" min-width="140" 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>
|
||||
</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" @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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -267,6 +211,8 @@ import { ElMessage } from 'element-plus'
|
||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.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 { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||
import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
@@ -386,6 +332,25 @@ const historyItems = ref<PriceTrackHistoryItem[]>([])
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const taskDetails = ref<Record<number, string>>({})
|
||||
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 pollingInFlight = ref(false)
|
||||
let disposed = false
|
||||
@@ -1050,7 +1015,7 @@ async function pushToPythonQueueLegacy() {
|
||||
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
|
||||
addPollingTask(taskVo.taskId)
|
||||
scheduleNextPoll(true)
|
||||
ElMessage.success('已推送到 Python 队列')
|
||||
ElMessage.success('已启动任务')
|
||||
} catch (e) {
|
||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
@@ -1097,7 +1062,7 @@ async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayloa
|
||||
nonEmptyObjectKeys: mode === 'asin' ? ['asin_rows_by_country'] : [],
|
||||
})
|
||||
if (!(await passGuard(guard))) {
|
||||
const reason = `任务 ${taskId} 数据校验未通过,已阻止推送到 Python 队列`
|
||||
const reason = `任务 ${taskId} 数据校验未通过,已阻止启动任务`
|
||||
await compensateDispatchFailure(taskId, reason)
|
||||
throw new Error(reason)
|
||||
}
|
||||
@@ -1105,12 +1070,12 @@ async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayloa
|
||||
try {
|
||||
pushResult = await api.enqueue_json(queuePayload)
|
||||
} 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)
|
||||
throw new Error(reason)
|
||||
}
|
||||
if (!pushResult?.success) {
|
||||
const reason = pushResult?.error || 'Python 队列拒绝接收任务'
|
||||
const reason = pushResult?.error || '任务队列拒绝接收任务'
|
||||
await compensateDispatchFailure(taskId, 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))
|
||||
)
|
||||
|
||||
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) {
|
||||
if (!taskId) return ''
|
||||
@@ -1770,6 +1768,26 @@ onUnmounted(() => {
|
||||
timers.clearScope()
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -2253,61 +2271,19 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
font-size: 13px;
|
||||
color: #a0acbe;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.empty-tasks {
|
||||
/* 匹配结果空态(#cards-extra 区块内使用) */
|
||||
.match-empty {
|
||||
color: #5e6878;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-tasks.narrow {
|
||||
padding: 12px 8px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.match-table {
|
||||
@@ -2325,96 +2301,6 @@ onUnmounted(() => {
|
||||
.ok { color: #27ae60; }
|
||||
.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 {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
@@ -2454,10 +2340,6 @@ onUnmounted(() => {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #2e3a52;
|
||||
}
|
||||
|
||||
.pr-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<aside class="left-panel">
|
||||
<div class="section-title">店铺名称</div>
|
||||
<div class="input-zone">
|
||||
<div class="hint">输入店铺名后点「确认」加入下方备选区(按当前登录用户保存)。仅索引可命中的店铺可入库。勾选备选后点「匹配店铺」,再「推送到 Python 队列」。</div>
|
||||
<div class="hint">输入店铺名后点「确认」加入下方备选区(按当前登录用户保存)。仅索引可命中的店铺可入库。勾选备选后点「匹配店铺」,再「启动任务」。</div>
|
||||
<div class="input-row">
|
||||
<el-input v-model="shopInput" clearable placeholder="请输入店铺名称" @keyup.enter="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 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">
|
||||
<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"
|
||||
@@ -72,7 +72,7 @@
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length"
|
||||
@click="pushToPythonQueue">
|
||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
||||
{{ pushing ? '启动中…' : '启动任务' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">在搜索结果中禁止显示、详情页面已删除会把匹配店铺合并为一个任务入队,避免同时打开多个紫鸟窗口;其它筛选仍按店铺逐条入队。Python
|
||||
@@ -87,107 +87,53 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">匹配与任务</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary pr-summary">
|
||||
<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 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="100" />
|
||||
<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="100" 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 label="说明" min-width="140" 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="!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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="匹配与任务"
|
||||
:cards="riskCards"
|
||||
:current-items="currentTaskViews"
|
||||
: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>
|
||||
<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="100" />
|
||||
<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="100" 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 label="说明" min-width="140" 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>
|
||||
</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" @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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -198,6 +144,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 {
|
||||
addProductRiskCandidate,
|
||||
@@ -387,6 +335,25 @@ const pollingTaskIds = ref<number[]>([])
|
||||
const taskDetails = ref<Record<number, string>>({})
|
||||
/** 本地缓存 POST /tasks/batch 各 task 最新快照 + 创建任务响应,便于刷新后查看 */
|
||||
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 pollingInFlight = ref(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 = [
|
||||
'无法连接到后端服务',
|
||||
@@ -1267,6 +1266,26 @@ onUnmounted(() => {
|
||||
timers.clearScope()
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1577,61 +1596,19 @@ onUnmounted(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
.empty-tasks {
|
||||
/* 匹配结果空态(#cards-extra 区块内使用) */
|
||||
.match-empty {
|
||||
color: #5e6878;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-tasks.narrow {
|
||||
padding: 12px 8px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.match-table {
|
||||
@@ -1661,101 +1638,6 @@ onUnmounted(() => {
|
||||
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 {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
@@ -1795,10 +1677,6 @@ onUnmounted(() => {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #2e3a52;
|
||||
}
|
||||
|
||||
.clean-result-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -91,110 +91,91 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">上架结果</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">任务文件</span>
|
||||
<strong>{{ summary.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">已处理文件</span>
|
||||
<strong>{{ summary.completed }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">成功结果</span>
|
||||
<strong>{{ summary.successCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<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) }}
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="上架结果"
|
||||
:cards="publishCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无上架任务,完成文件选择后点击“开始上架”创建任务。"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<template #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>
|
||||
<button
|
||||
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>
|
||||
<span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.task.errorMessage" class="task-error">
|
||||
{{ detail.task.errorMessage }}
|
||||
</div>
|
||||
|
||||
<ul class="file-list">
|
||||
<li v-for="file in detail.files" :key="fileKey(file)" class="file-item">
|
||||
<div class="file-main">
|
||||
<div class="file-title" :title="file.sourceFilename || ''">
|
||||
{{ 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 class="publish-file-row-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="publish-file-row-progress-meta">
|
||||
<span>{{ file.progressMessage || statusText(file.status) }}</span>
|
||||
<span>{{ fileProgressPercent(file) }}%</span>
|
||||
</div>
|
||||
<span class="status file-status" :class="statusClass(file.status)">
|
||||
{{ statusText(file.status) }}
|
||||
<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 #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>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
|
||||
</div>
|
||||
<div class="publish-file-row-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="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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -207,6 +188,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.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 { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import {
|
||||
@@ -410,6 +393,65 @@ const visibleTasks = computed(() => {
|
||||
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 files = visibleTasks.value.flatMap((detail) => detail.files || [])
|
||||
const successCount = files.filter((file) => ['SUCCESS', 'COMPLETED'].includes(normalizeStatus(file.status))).length
|
||||
@@ -870,7 +912,7 @@ async function processQueue() {
|
||||
saveQueueState()
|
||||
try {
|
||||
await activatePublishFile(taskId, nextFileId)
|
||||
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已派发到 Python 队列' })
|
||||
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已启动任务' })
|
||||
const payload = buildQueuePayload(taskId, file)
|
||||
// totalRows/totalPages 为 0 时 Python 端翻不到任何明细,会一直停在执行中
|
||||
const guard = checkQueuePayload(payload, {
|
||||
@@ -882,7 +924,7 @@ async function processQueue() {
|
||||
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`)
|
||||
}
|
||||
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
|
||||
? `文件 ${file.sourceFilename || nextFileId} 已入队,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
|
||||
: `文件 ${file.sourceFilename || nextFileId} 已入队,等待执行完成。`
|
||||
@@ -1158,6 +1200,26 @@ onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1167,8 +1229,8 @@ onBeforeUnmount(() => {
|
||||
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
|
||||
.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; }
|
||||
.hint, .loading-msg, .task-meta, .file-info, .history-count { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||||
.btns, .run-row, .task-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.hint, .loading-msg { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||||
.btns, .run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.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 { 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; }
|
||||
.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; }
|
||||
.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:hover { background: #367d54; }
|
||||
.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: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; }
|
||||
.status.pending { background: #3a3424; color: #e4c56a; }
|
||||
.status.running { background: #24394a; color: #75bff1; }
|
||||
.status.success { background: #233c2d; color: #72d598; }
|
||||
.status.failed { background: #472929; color: #ef8d8d; }
|
||||
.file-list { margin: 0; padding: 0; list-style: none; }
|
||||
.file-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid #292929; }
|
||||
.file-main { flex: 1; min-width: 0; }
|
||||
.file-title { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6px; }
|
||||
.file-progress-header { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; color: #969696; font-size: 11px; }
|
||||
.file-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; }
|
||||
.file-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
|
||||
.file-progress-fill.running { background: #409eff; }
|
||||
.file-progress-fill.success { background: #42b36b; }
|
||||
.file-progress-fill.failed { background: #d06161; }
|
||||
.file-error { margin-top: 7px; }
|
||||
.file-status { flex-shrink: 0; }
|
||||
.file-error { color: #ef8d8d; font-size: 12px; }
|
||||
.publish-file-list { margin: 0; padding: 0; list-style: none; }
|
||||
.publish-file-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #2e3a52; }
|
||||
.publish-file-row:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||||
.publish-file-row-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
|
||||
.publish-file-row-name { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.publish-file-row-chip { flex-shrink: 0; padding: 2px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; }
|
||||
.publish-file-row-chip.pending { background: #3a3424; color: #e4c56a; }
|
||||
.publish-file-row-chip.running { background: #24394a; color: #75bff1; }
|
||||
.publish-file-row-chip.success { background: #233c2d; color: #72d598; }
|
||||
.publish-file-row-chip.failed { background: #472929; color: #ef8d8d; }
|
||||
.publish-file-row-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 5px; color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||||
.publish-file-row-progress-meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 8px; color: #969696; font-size: 11px; }
|
||||
.publish-file-row-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; }
|
||||
.publish-file-row-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
|
||||
.publish-file-row-progress-fill.running { background: #409eff; }
|
||||
.publish-file-row-progress-fill.success { background: #42b36b; }
|
||||
.publish-file-row-progress-fill.failed { background: #d06161; }
|
||||
.publish-file-row-error { margin-top: 7px; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.main-content { height: auto; flex-direction: column; }
|
||||
.left-panel { width: 100%; border-right: 0; border-bottom: 1px solid #2e3a52; }
|
||||
.right-panel { min-height: 420px; }
|
||||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.clean-result-summary { grid-template-columns: 1fr; }
|
||||
.task-section-header, .file-item { flex-direction: column; }
|
||||
.task-actions { width: 100%; }
|
||||
.publish-file-row-head { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="section-title">店铺输入</div>
|
||||
<div class="input-zone">
|
||||
<div class="hint">
|
||||
左侧负责录入店铺并加入备选区,确认命中后推送到 Python 队列。查询ASIN按店铺串行执行:上一个任务完成后会自动开始下一个,失败也会继续下一个。
|
||||
左侧负责录入店铺并加入备选区,确认命中后启动任务。查询ASIN按店铺串行执行:上一个任务完成后会自动开始下一个,失败也会继续下一个。
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<el-input
|
||||
@@ -77,7 +77,7 @@
|
||||
:disabled="isQueueBusy || !matchedRunnableItems.length"
|
||||
@click="pushToPythonQueue"
|
||||
>
|
||||
{{ isQueueBusy ? "串行执行中..." : "推送到 Python 队列" }}
|
||||
{{ isQueueBusy ? "串行执行中..." : "启动任务" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -95,221 +95,85 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">匹配与任务</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">备选店铺</span>
|
||||
<strong>{{ dashboard.candidateCount }}</strong>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="匹配与任务"
|
||||
:cards="queryAsinCards"
|
||||
:current-items="currentTaskViews"
|
||||
: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 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 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"
|
||||
<el-table
|
||||
v-else
|
||||
:data="matchedItems"
|
||||
:row-key="rowKeyForMatch"
|
||||
:highlight-current-row="false"
|
||||
class="result-table match-table"
|
||||
>
|
||||
暂无任务记录。推送到 Python 队列后,这里会展示当前任务和历史任务。
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">当前任务</div>
|
||||
<ul class="task-list">
|
||||
<li
|
||||
v-for="item in currentSectionItems"
|
||||
:key="`${item.resultId}-${item.taskId}`"
|
||||
class="task-item"
|
||||
<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)"
|
||||
>
|
||||
<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 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>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -320,6 +184,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
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 {
|
||||
addQueryAsinCandidate,
|
||||
createQueryAsinTask,
|
||||
@@ -386,6 +252,44 @@ const currentSectionItems = computed(() =>
|
||||
const historySectionItems = computed(() =>
|
||||
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(
|
||||
() =>
|
||||
!!activeTaskId.value ||
|
||||
@@ -499,7 +403,7 @@ function formatMatchRemark(row: QueryAsinShopQueueItem) {
|
||||
const message = (row.matchMessage || "").trim();
|
||||
if (message) return message;
|
||||
if (row.matched && row.matchStatus === "MATCHED") {
|
||||
return "索引已命中,可推送到 Python 队列";
|
||||
return "索引已命中,可启动任务";
|
||||
}
|
||||
if (row.matched) {
|
||||
return "已命中索引,请结合状态列确认";
|
||||
@@ -1206,6 +1110,26 @@ onUnmounted(() => {
|
||||
clearSleepTimers();
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1237,43 +1161,20 @@ onUnmounted(() => {
|
||||
.queue-debug-title { 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; }
|
||||
.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; }
|
||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
|
||||
.empty-tasks.narrow { padding: 12px 8px; }
|
||||
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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); }
|
||||
.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); }
|
||||
@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)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -66,52 +66,51 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">匹配与任务</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<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 class="subsection-title">匹配结果</div>
|
||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">匹配后将在这里显示结果</div>
|
||||
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
|
||||
class="result-table match-table">
|
||||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||||
<el-table-column label="匹配" width="64" 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="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
|
||||
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="64" 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="!currentItems.length && !historySectionItems.length" class="empty-tasks">暂无任务记录</div>
|
||||
<div v-if="currentItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">当前任务</div>
|
||||
<ul class="task-list">
|
||||
<TaskRow v-for="item in currentItems" :key="historyKey(item)" :item="item"
|
||||
@download="downloadResult" @delete="deleteTaskRecord" />
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="historySectionItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">历史记录</div>
|
||||
<ul class="task-list">
|
||||
<TaskRow v-for="item in historySectionItems" :key="historyKey(item)" :item="item"
|
||||
@download="downloadResult" @delete="deleteTaskRecord" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="匹配与任务"
|
||||
:cards="shopDataCards"
|
||||
:current-items="currentTaskViews"
|
||||
: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>
|
||||
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
|
||||
class="result-table match-table">
|
||||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||||
<el-table-column label="匹配" width="64" 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="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
|
||||
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="64" align="center"><template #default="{ row }">
|
||||
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template #item-extra="{ item }">
|
||||
<div v-if="itemSource(item).error" class="files error-text">错误:{{ itemSource(item).error }}</div>
|
||||
</template>
|
||||
<template #history-item-extra="{ item }">
|
||||
<div v-if="itemSource(item).error" class="files error-text">错误:{{ itemSource(item).error }}</div>
|
||||
</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" @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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -119,9 +118,11 @@
|
||||
</template>
|
||||
|
||||
<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 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 { useZiniaoVersion } from '@/shared/utils/ziniao-version'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
@@ -160,28 +161,6 @@ const COUNTRY_OPTIONS = [
|
||||
{ code: 'IT', label: '意大利' },
|
||||
] 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 ziniaoVersion = useZiniaoVersion()
|
||||
const shopInput = ref('')
|
||||
@@ -213,6 +192,39 @@ let disposed = false
|
||||
const matchedRunnableItems = computed(() => matchedItems.value.filter((item) => item.matched))
|
||||
const currentItems = 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 countryCheckboxRows = computed(() => {
|
||||
const selected = new Set(orderedCountryCodes.value)
|
||||
@@ -566,20 +578,40 @@ onUnmounted(() => {
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.module-page { min-height: 100vh; background: #151a25; }
|
||||
.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; }
|
||||
.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; }
|
||||
.input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; }
|
||||
.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 { flex: 0 0 auto; }
|
||||
.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 { --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; }
|
||||
@@ -595,25 +627,14 @@ onUnmounted(() => {
|
||||
.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; }
|
||||
.btn-queue { background: #67c23a; }
|
||||
.panel-header { height: 52px; padding: 16px 22px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 700; }
|
||||
.task-list-wrap { height: calc(100% - 52px); padding: 18px 22px 28px; overflow-y: auto; }
|
||||
.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; }
|
||||
.subsection-title { margin: 18px 0 10px; color: #a0acbe; font-size: 13px; font-weight: 700; }
|
||||
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 16px; text-align: center; }
|
||||
.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; }
|
||||
.result-list-header { padding: 13px 0; border-bottom: 1px solid #333f55; color: #f5f8fc; font-weight: 700; }
|
||||
.task-list { margin: 0; padding: 0; list-style: none; }
|
||||
: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; }
|
||||
:deep(.split-result-main) { min-width: 0; }
|
||||
:deep(.id) { display: block; margin-bottom: 5px; color: #f5f8fc; font-weight: 700; }
|
||||
:deep(.files) { margin-top: 3px; color: #5e6878; font-size: 12px; }
|
||||
: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); } }
|
||||
.ok { color: #67c23a; }
|
||||
.files { font-size: 12px; color: #5e6878; }
|
||||
.fail, .error-text { color: #f56c6c; }
|
||||
.download, .btn-delete { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #c8d2e2; cursor: pointer; }
|
||||
.download { border-color: #409eff; color: #8dc4ff; }
|
||||
.btn-delete { color: #f56c6c; }
|
||||
@media (max-width: 900px) { .main-content { height: auto; flex-direction: column; }.left-panel { width: 100%; border-right: 0; } }
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<aside class="left-panel">
|
||||
<div class="section-title">店铺候选</div>
|
||||
<div class="input-zone">
|
||||
<div class="hint">输入店铺名后点“添加”加入下方备选区。勾选备选店铺后先做“匹配店铺”,确认命中紫鸟索引后再推送到 Python 队列。</div>
|
||||
<div class="hint">输入店铺名后点“添加”加入下方备选区。勾选备选店铺后先做“匹配店铺”,确认命中紫鸟索引后再启动任务。</div>
|
||||
<div class="input-row">
|
||||
<el-input v-model="shopInput" clearable placeholder="请输入店铺名后回车或点击添加" @keyup.enter="confirmAdd" />
|
||||
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">{{ adding ? '添加中...' : '添加' }}</button>
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
<div v-if="countryPrefSaving" class="country-pref-status">保存中...</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">
|
||||
<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" />
|
||||
@@ -59,47 +59,48 @@
|
||||
|
||||
<div class="run-row">
|
||||
<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>
|
||||
<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>
|
||||
</aside>
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">匹配与任务</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<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 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="100" />
|
||||
<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="100" 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 label="说明" min-width="140" 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="!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"><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>
|
||||
</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="`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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="匹配与任务"
|
||||
:cards="shopMatchCards"
|
||||
:current-items="currentTaskViews"
|
||||
: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>
|
||||
<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="100" />
|
||||
<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="100" 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 label="说明" min-width="140" 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>
|
||||
</template>
|
||||
<template #item-extra="{ item }">
|
||||
<div v-if="currentTaskStageText(itemSource(item))" class="files">{{ currentTaskStageText(itemSource(item)) }}</div>
|
||||
<div v-if="nextScheduledDisplay(itemSource(item))" class="files">下一次: {{ nextScheduledDisplay(itemSource(item)) }}</div>
|
||||
</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" @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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -110,6 +111,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 { 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'
|
||||
@@ -145,6 +148,11 @@ const historyItems = ref<ShopMatchHistoryItem[]>([])
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const taskDetails = ref<Record<number, string>>({})
|
||||
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 pollingInFlight = ref(false)
|
||||
const dispatchTimers = new Map<number, number>()
|
||||
@@ -156,7 +164,40 @@ let disposed = false
|
||||
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 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 }))] })
|
||||
|
||||
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 } }
|
||||
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() })
|
||||
|
||||
/**
|
||||
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||||
*/
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -560,39 +621,18 @@ onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); cle
|
||||
.queue-debug-title { 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; }
|
||||
.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; }
|
||||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 16px; text-align: center; }
|
||||
.empty-tasks.narrow { padding: 12px 8px; }
|
||||
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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: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); }
|
||||
@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>
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId"
|
||||
@click="pushToPythonQueue">
|
||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||||
{{ pushing ? '启动中...' : '启动任务' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">Python 回传字段按 id、asin、国家、价格、标题、图片链接数组提交;后端接收分片后按 10 条一批送检,结果区展示真实 LLM 批次进度。</p>
|
||||
@@ -80,104 +80,32 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">货源查询</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">运行中任务</span>
|
||||
<strong>{{ dashboard.pendingTaskCount }}</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 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">结束时间:{{ 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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="货源查询"
|
||||
:cards="asinCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无当前任务"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<template #item-extra="{ item }">
|
||||
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
|
||||
</template>
|
||||
<template #history-item-extra="{ item }">
|
||||
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</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 v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||||
@click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||
</template>
|
||||
</TaskCenterPanel>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -190,6 +118,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.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 {
|
||||
activateSimilarAsinTask,
|
||||
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) {
|
||||
if (item.taskId == null) return item
|
||||
const liveItem = liveProgressItems.value[item.taskId]
|
||||
@@ -599,7 +571,7 @@ async function pushToPythonQueue() {
|
||||
clearParsedTask()
|
||||
await loadDashboard()
|
||||
await loadHistory({ force: true })
|
||||
ElMessage.success('已推送到 Python 队列')
|
||||
ElMessage.success('已启动任务')
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
@@ -1122,6 +1094,26 @@ onUnmounted(() => {
|
||||
stopPolling()
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1152,8 +1144,7 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.subsection-title {
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
color: #a0acbe;
|
||||
margin-bottom: 10px;
|
||||
@@ -1368,172 +1359,11 @@ onUnmounted(() => {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.file-progress-count {
|
||||
margin-top: 4px;
|
||||
color: #858585;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.download {
|
||||
padding: 6px 10px;
|
||||
color: #c8d2e2;
|
||||
@@ -1557,9 +1387,5 @@ onUnmounted(() => {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #2e3a52;
|
||||
}
|
||||
|
||||
.clean-result-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,65 +98,37 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">拆分结果</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">已处理文件</span>
|
||||
<strong>{{ splitSummary.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">成功结果</span>
|
||||
<strong>{{ splitSummary.successCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">失败文件</span>
|
||||
<strong>{{ splitSummary.failedCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>拆分结果压缩包列表</span>
|
||||
</div>
|
||||
|
||||
<div v-if="splitResultItems.length === 0" class="empty-tasks">
|
||||
暂无拆分结果,完成数据拆分后会在这里展示压缩包文件
|
||||
</div>
|
||||
|
||||
<ul v-else class="task-list clean-result-list">
|
||||
<li v-for="item in splitResultItems"
|
||||
:key="`${item.resultId || item.outputFilename || item.sourceFilename}-${item.rowCount || 0}`"
|
||||
class="task-item split-result-item">
|
||||
<div class="left split-result-main">
|
||||
<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>
|
||||
<TaskCenterPanel
|
||||
:on-batch-delete="batchDeleteHistory"
|
||||
title="拆分结果"
|
||||
:cards="splitCards"
|
||||
:current-items="currentTaskViews"
|
||||
:history-items="historyTaskViews"
|
||||
current-title="当前任务"
|
||||
current-empty-text="暂无当前任务,完成数据拆分后会在这里展示"
|
||||
history-empty-text="暂无历史记录"
|
||||
>
|
||||
<template #history-item-actions="{ item }">
|
||||
<template v-if="itemSource(item)">
|
||||
<button
|
||||
v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
|
||||
type="button"
|
||||
class="download"
|
||||
@click="downloadSplitResult(itemSource(item))"
|
||||
>
|
||||
下载压缩包
|
||||
</button>
|
||||
<button
|
||||
v-if="itemSource(item).resultId"
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
@click="deleteSplitHistoryRecord(itemSource(item).resultId!)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</TaskCenterPanel>
|
||||
</section>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -167,8 +139,10 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 { 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 { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
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 splitRunning = ref(false)
|
||||
const splitResultItems = ref<SplitResultItem[]>([])
|
||||
const splitSummary = ref<SplitRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
||||
const splitDisplayPaths = computed(() => splitSelectedPaths.value.slice(0, 8))
|
||||
|
||||
function updateSplitSummaryFromItems(items: SplitResultItem[]) {
|
||||
splitSummary.value = {
|
||||
total: items.length,
|
||||
successCount: items.filter((item) => item.success).length,
|
||||
failedCount: items.filter((item) => !item.success).length,
|
||||
items,
|
||||
// ---- 右侧任务面板统一视图(TaskCenterPanel)----
|
||||
const splitCards = 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 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
|
||||
: undefined,
|
||||
})
|
||||
splitSummary.value = result
|
||||
splitResultItems.value = result.items || []
|
||||
await loadSplitHistory()
|
||||
if (result.total > 0 && result.successCount === 0) {
|
||||
@@ -341,7 +378,6 @@ async function loadSplitHistory() {
|
||||
try {
|
||||
const response = await getSplitHistory()
|
||||
splitResultItems.value = response.items || []
|
||||
updateSplitSummaryFromItems(splitResultItems.value)
|
||||
} catch {
|
||||
// ignore history load errors
|
||||
}
|
||||
@@ -387,6 +423,26 @@ async function downloadSplitResult(item: SplitResultItem) {
|
||||
onMounted(() => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -685,110 +741,12 @@ onMounted(() => {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
color: #5e6878;
|
||||
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 {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
@@ -813,60 +771,6 @@ onMounted(() => {
|
||||
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) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
@@ -882,24 +786,5 @@ onMounted(() => {
|
||||
.right-panel {
|
||||
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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div v-if="$slots.logoExtra" class="logo-extra">
|
||||
<slot name="logoExtra" />
|
||||
</div>
|
||||
<a v-if="showHomeLink" href="/home" class="btn-home">返回首页</a>
|
||||
<router-link v-if="showHomeLink" to="/home" class="btn-home">返回首页</router-link>
|
||||
</div>
|
||||
|
||||
<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-items">
|
||||
<template v-for="item in group.items" :key="item.key">
|
||||
<a v-if="item.href" :href="item.href" class="nav-item" :class="{ active: active === item.key }">
|
||||
<router-link v-if="item.href" :to="item.href" class="nav-item" :class="{ active: active === item.key }">
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</router-link>
|
||||
<span v-else class="nav-item disabled" :class="{ active: active === item.key }">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
@@ -94,30 +94,30 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
||||
columnKey: 'brand_front_tools',
|
||||
label: '前端工具',
|
||||
items: [
|
||||
{ key: 'collect-data', label: '采集数据', href: '/new_web_source/collect-data.html' },
|
||||
{ key: 'variant-collection', label: 'ASIN变体采集', href: '/new_web_source/variant-collection.html' },
|
||||
// { key: 'image-video', label: '视频', href: '/new_web_source/image-video.html' },
|
||||
{ key: 'brand', label: '品牌检测', href: '/new_web_source/brand.html' },
|
||||
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' },
|
||||
{ key: 'similar-asin', label: '货源查询', href: '/new_web_source/similar-asin.html' },
|
||||
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' },
|
||||
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' },
|
||||
{ key: 'convert', label: '格式转换', href: '/new_web_source/convert.html' },
|
||||
{ key: 'collect-data', label: '采集数据', href: '/collect-data' },
|
||||
{ key: 'variant-collection', label: 'ASIN变体采集', href: '/variant-collection' },
|
||||
// { key: 'image-video', label: '视频', href: '/image-video' },
|
||||
{ key: 'brand', label: '品牌检测', href: '/brand' },
|
||||
{ key: 'appearance-patent', label: '外观专利检测', href: '/appearance-patent' },
|
||||
{ key: 'similar-asin', label: '货源查询', href: '/similar-asin' },
|
||||
{ key: 'dedupe', label: '数据去重', href: '/dedupe' },
|
||||
{ key: 'split', label: '数据拆分', href: '/split' },
|
||||
{ key: 'convert', label: '格式转换', href: '/convert' },
|
||||
],
|
||||
},
|
||||
{
|
||||
columnKey: 'brand_operation_tools',
|
||||
label: '运营工具',
|
||||
items: [
|
||||
{ key: 'publish', label: '上架', href: '/new_web_source/publish.html' },
|
||||
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
|
||||
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
|
||||
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
||||
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html', aliases: ['price-track'] },
|
||||
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
|
||||
{ key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' },
|
||||
{ key: 'shop-data-crawl', label: '店铺数据抓取', href: '/new_web_source/shop-data-crawl.html', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] },
|
||||
{ key: 'withdraw', label: '取款', href: '/new_web_source/withdraw.html' },
|
||||
{ key: 'publish', label: '上架', href: '/publish' },
|
||||
{ key: 'delete-brand', label: '删除ASIN', href: '/delete-brand' },
|
||||
{ key: 'product-risk', label: '商品风险解决', href: '/product-risk' },
|
||||
{ key: 'shop-match', label: '定时匹配', href: '/shop-match' },
|
||||
{ key: 'pricing', label: '跟价', href: '/price-track', aliases: ['price-track'] },
|
||||
{ key: 'patrol-delete', label: '巡店删除', href: '/patrol-delete' },
|
||||
{ key: 'query-asin', label: '查询ASIN', href: '/query-asin' },
|
||||
{ key: 'shop-data-crawl', label: '店铺数据抓取', href: '/shop-data-crawl', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] },
|
||||
{ key: 'withdraw', label: '取款', href: '/withdraw' },
|
||||
{ key: 'shop-status', label: '店铺状态查询' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -86,45 +86,9 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">
|
||||
<span>变体采集任务</span>
|
||||
<div class="panel-actions">
|
||||
<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">
|
||||
<div class="panel-toolbar">
|
||||
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
|
||||
<div v-if="totalCount" class="toolbar-pagination">
|
||||
<span class="page-info">共 {{ totalCount }} 条 · 第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||
<div class="page-btns">
|
||||
<button type="button" class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
|
||||
@@ -132,6 +96,38 @@
|
||||
</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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -166,6 +162,8 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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,
|
||||
type VariantTaskListItem,
|
||||
@@ -214,6 +212,59 @@ const canSubmit = computed(() =>
|
||||
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 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:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.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 { display: flex; gap: 8px; }
|
||||
.panel-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 20px 0; }
|
||||
.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; }
|
||||
.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; }
|
||||
.toolbar-pagination { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.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.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-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; }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="section-title">店铺输入</div>
|
||||
<div class="input-zone">
|
||||
<div class="hint">
|
||||
左侧负责录入店铺并加入备选区,确认命中后推送到 Python 队列。每次推送会把本次选中的所有店铺合并为一条取款任务,任务之间串行执行。
|
||||
左侧负责录入店铺并加入备选区,确认命中后启动任务。每次推送会把本次选中的所有店铺合并为一条取款任务,任务之间串行执行。
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<el-input
|
||||
@@ -88,7 +88,7 @@
|
||||
:disabled="isQueueBusy || !matchedRunnableItems.length"
|
||||
@click="pushToPythonQueue"
|
||||
>
|
||||
{{ isQueueBusy ? "串行执行中..." : "推送到 Python 队列" }}
|
||||
{{ isQueueBusy ? "串行执行中..." : "启动任务" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -106,223 +106,114 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">匹配与任务</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<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 class="match-zone">
|
||||
<div class="match-zone-header">
|
||||
<span>匹配结果</span>
|
||||
</div>
|
||||
|
||||
<div class="subsection-title">匹配结果</div>
|
||||
<div v-if="!matchedItems.length" class="empty-tasks narrow">
|
||||
<div v-if="!matchedItems.length" class="match-zone-empty">
|
||||
完成“匹配店铺”后,这里会展示匹配结果。
|
||||
</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"
|
||||
<div v-else class="match-zone-scroll">
|
||||
<el-table
|
||||
:data="matchedItems"
|
||||
:row-key="rowKeyForMatch"
|
||||
:highlight-current-row="false"
|
||||
class="result-table match-table"
|
||||
>
|
||||
暂无任务记录。推送到 Python 队列后,这里会展示当前任务和历史任务。
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">当前任务</div>
|
||||
<ul class="task-list">
|
||||
<li
|
||||
v-for="item in currentSectionItems"
|
||||
:key="`current-${item.taskId || item.resultId}`"
|
||||
class="task-item"
|
||||
<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)"
|
||||
>
|
||||
<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 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>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</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>
|
||||
</div>
|
||||
</AmazonToolPageShell>
|
||||
@@ -333,6 +224,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
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 {
|
||||
addWithdrawCandidate,
|
||||
createWithdrawTask,
|
||||
@@ -419,6 +312,43 @@ const hasQueuedTaskWork = computed(
|
||||
const hasQueueWork = 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() {
|
||||
return typeof window !== "undefined"
|
||||
? window.localStorage.getItem("uid") || "0"
|
||||
@@ -604,7 +534,7 @@ function formatMatchRemark(row: WithdrawShopQueueItem) {
|
||||
const message = (row.matchMessage || "").trim();
|
||||
if (message) return message;
|
||||
if (row.matched && row.matchStatus === "MATCHED") {
|
||||
return "索引已命中,可推送到 Python 队列";
|
||||
return "索引已命中,可启动任务";
|
||||
}
|
||||
if (row.matched) {
|
||||
return "已命中索引,请结合状态列确认";
|
||||
@@ -1313,6 +1243,26 @@ onUnmounted(() => {
|
||||
clearSleepTimers();
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1347,43 +1297,74 @@ onUnmounted(() => {
|
||||
.queue-debug-title { 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; }
|
||||
.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; }
|
||||
.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 {
|
||||
flex: 0 0 auto;
|
||||
margin: 16px 20px 0;
|
||||
border: 1px solid #2e3a52;
|
||||
border-radius: 10px;
|
||||
background: #1c2333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.shop-detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.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); }
|
||||
@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)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -26,49 +26,34 @@
|
||||
</div>
|
||||
</div>
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<a :href="logoutHref" class="logout-link">退出</a>
|
||||
<router-link :to="logoutHref" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="entrances">
|
||||
<a
|
||||
<router-link
|
||||
v-if="canShow.amazon"
|
||||
:href="amazonHref || undefined"
|
||||
:to="amazonHref"
|
||||
class="entrance-btn"
|
||||
:class="{ disabled: !amazonHref }"
|
||||
data-column-key="brand"
|
||||
>亚马逊</a>
|
||||
<a
|
||||
v-else-if="showAmazonDisabled"
|
||||
class="entrance-btn disabled"
|
||||
data-column-key="brand"
|
||||
>亚马逊</a>
|
||||
>亚马逊</router-link>
|
||||
|
||||
<a
|
||||
<router-link
|
||||
v-if="canShow.video"
|
||||
:href="videoHref || undefined"
|
||||
:to="videoHref"
|
||||
class="entrance-btn wb"
|
||||
:class="{ disabled: !videoHref }"
|
||||
data-column-key="wb"
|
||||
>视频</a>
|
||||
<a
|
||||
v-else-if="showVideoDisabled"
|
||||
class="entrance-btn wb disabled"
|
||||
data-column-key="wb"
|
||||
>视频</a>
|
||||
>视频</router-link>
|
||||
|
||||
<a
|
||||
<router-link
|
||||
v-if="canShow.image"
|
||||
:href="imageHref || undefined"
|
||||
:to="imageHref"
|
||||
class="entrance-btn image"
|
||||
:class="{ disabled: !imageHref }"
|
||||
data-column-key="image"
|
||||
>图片</a>
|
||||
<a
|
||||
v-else-if="showImageDisabled"
|
||||
class="entrance-btn image disabled"
|
||||
data-column-key="image"
|
||||
>图片</a>
|
||||
>图片</router-link>
|
||||
</div>
|
||||
|
||||
<div class="toast" :class="{ show: Boolean(toastText) }">{{ toastText }}</div>
|
||||
@@ -77,8 +62,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { requestGetJson } from '@/shared/api/http'
|
||||
import { buildJavaUrl } from '@/shared/api/url'
|
||||
import { restoreLoginUser } from '@/shared/auth/ensure-auth'
|
||||
import { getCurrentUserAppColumnRaw, type PermissionMenuItem } from '@/shared/api/permission'
|
||||
import { getPywebviewApi, isDesktopRuntime } from '@/shared/bridges/pywebview'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
@@ -90,7 +75,7 @@ const updateReady = ref(false)
|
||||
const updateFileUrl = ref('')
|
||||
const toastText = ref('')
|
||||
|
||||
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token(login.html?logout=1)
|
||||
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token(/login?logout=1)
|
||||
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`)
|
||||
|
||||
type PermissionState = { amazon: boolean; video: boolean; image: boolean }
|
||||
@@ -105,10 +90,6 @@ const imageHref = computed(() => resolvePageHref('/new_web_source/image.html'))
|
||||
const canShow = computed(() =>
|
||||
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() {
|
||||
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
||||
@@ -116,7 +97,7 @@ function getUid() {
|
||||
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 allowedColumnKeys: 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)
|
||||
}
|
||||
;(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
|
||||
if (root) {
|
||||
hasRootMetadata = true
|
||||
@@ -154,19 +136,10 @@ async function loadPermissions() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await requestGetJson<{ success: boolean; data?: Array<Record<string, unknown>> }>(
|
||||
buildJavaUrl(`/api/admin/permission-users/${currentUid}/column-permissions?menuType=app`),
|
||||
)
|
||||
if (resp?.success && Array.isArray(resp.data)) {
|
||||
permissionState.value = computePermissionState(resp.data)
|
||||
try {
|
||||
window.localStorage.setItem(`app_column_permissions:${currentUid}`, JSON.stringify(resp.data))
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
} else {
|
||||
permissionUnavailable.value = true
|
||||
}
|
||||
// 经 shared/api/permission 层拉取原始权限菜单项(含本地缓存写入;接口失败走 catch)
|
||||
const items = await getCurrentUserAppColumnRaw()
|
||||
permissionState.value = computePermissionState(items)
|
||||
permissionUnavailable.value = false
|
||||
} catch {
|
||||
permissionUnavailable.value = true
|
||||
}
|
||||
@@ -177,21 +150,10 @@ async function loadCurrentUser() {
|
||||
try {
|
||||
const localName = typeof window === 'undefined' ? '' : window.localStorage.getItem('username') || ''
|
||||
if (localName) username.value = localName
|
||||
const token = typeof window === 'undefined' ? '' : window.localStorage.getItem('aiimage_auth_token') || ''
|
||||
const resp = await window.fetch('/newApi/check_login', {
|
||||
credentials: 'include',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
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 {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
// 经 shared/auth 层恢复登录快照(页面不得直连 /newApi,见 shared-api-allowlist 测试约定)
|
||||
const restored = await restoreLoginUser()
|
||||
if (restored?.username) {
|
||||
username.value = restored.username
|
||||
}
|
||||
} catch {
|
||||
/* 保留本地用户名 */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="currentView === 'menu'" class="image-video-page">
|
||||
<header class="page-header">
|
||||
<span class="header-title">数富AI</span>
|
||||
<a class="back-link" :href="goHome()">返回首页</a>
|
||||
<router-link class="back-link" :to="goHome()">返回首页</router-link>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<button v-else-if="hasMenuPermission('image')" type="button" class="entrance-btn" @click="showDesktopOnly('图片工作台')">
|
||||
图片
|
||||
</button>
|
||||
</router-link>
|
||||
</main>
|
||||
|
||||
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
||||
@@ -56,7 +53,7 @@
|
||||
:show-actions="false"
|
||||
>
|
||||
<template #logoExtra>
|
||||
<button type="button" class="delivery-page__top-return" @click="currentView = 'menu'">
|
||||
<button type="button" class="delivery-page__top-return" @click="goBackToMenu">
|
||||
返回二级菜单
|
||||
</button>
|
||||
</template>
|
||||
@@ -71,30 +68,28 @@
|
||||
</template>
|
||||
|
||||
<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 BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue'
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { isDesktopClientPage } from '@/shared/page-prefix'
|
||||
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
|
||||
|
||||
type ViewMode = 'menu' | 'delivery'
|
||||
|
||||
const isDesktop = isDesktopClientPage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function goHome() {
|
||||
// 桌面端首页是 Flask /home(重定向到 Vue home);dev 下是 MPA 的 /home.html
|
||||
return isDesktop ? '/home' : '/home.html'
|
||||
// 桌面端与 Web 统一为 SPA 首页路径
|
||||
return '/home'
|
||||
}
|
||||
|
||||
function showDesktopOnly(name: string) {
|
||||
showStatus(`${name}为桌面客户端功能,请在本地客户端中打开`, 'error')
|
||||
}
|
||||
|
||||
const currentView = ref<ViewMode>('menu')
|
||||
/** 视图由路由 query 驱动:?view=delivery 为带货视频工作台,否则二级菜单页 */
|
||||
const currentView = ref<ViewMode>(route.query.view === 'delivery' ? 'delivery' : 'menu')
|
||||
const allowedColumnKeys = ref(new Set<string>())
|
||||
const permissionsLoading = ref(true)
|
||||
const launching = ref(false)
|
||||
@@ -139,10 +134,22 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.view,
|
||||
(view) => {
|
||||
currentView.value = view === 'delivery' ? 'delivery' : 'menu'
|
||||
},
|
||||
)
|
||||
|
||||
function openDeliveryWorkspace() {
|
||||
currentView.value = 'delivery'
|
||||
router.push({ path: '/image-video', query: { view: 'delivery' } })
|
||||
}
|
||||
|
||||
function goBackToMenu() {
|
||||
router.push({ path: '/image-video' })
|
||||
}
|
||||
|
||||
|
||||
function formatBytes(value: number) {
|
||||
const size = Number(value || 0)
|
||||
if (!Number.isFinite(size) || size <= 0) return '未知大小'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import './index.css'
|
||||
|
||||
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 CloneDetailPanel from './components/panels/CloneDetailPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface BuildResult {
|
||||
params?: Record<string, unknown>
|
||||
error?: string
|
||||
@@ -446,7 +449,7 @@ async function callGenerate(rawParams: Record<string, unknown>) {
|
||||
signal: currentAbortController.signal,
|
||||
})
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/login'
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
result = await resp.json()
|
||||
@@ -595,7 +598,7 @@ async function loadHistoryPage(append: boolean) {
|
||||
try {
|
||||
const resp = await fetch(url, { credentials: 'same-origin' })
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/login'
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
@@ -1182,7 +1185,7 @@ loadHistoryPage(false)
|
||||
<div class="logo-area">
|
||||
<img class="logo" :src="logoUrl" alt="logo" />
|
||||
<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>
|
||||
<nav class="nav-tabs">
|
||||
<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: 适合敏感肌 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">
|
||||
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 DEVICE_ID_KEY = 'aiimage_device_id'
|
||||
@@ -68,10 +71,6 @@ function togglePassword() {
|
||||
passwordVisible.value = !passwordVisible.value
|
||||
}
|
||||
|
||||
function isDesktopClient() {
|
||||
return Boolean(window.pywebview?.api) || window.location.pathname === '/login'
|
||||
}
|
||||
|
||||
function clearAppPermissionCaches() {
|
||||
try {
|
||||
const keys: string[] = []
|
||||
@@ -137,17 +136,8 @@ async function submitLogin() {
|
||||
errorMessage.value = '未获取到设备ID,请在桌面端打开'
|
||||
return
|
||||
}
|
||||
const loginResp = await window.fetch('/newApi/login', {
|
||||
method: 'POST',
|
||||
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(() => ({}))
|
||||
// 经 shared/api/user 层登录(页面不得直连 /newApi,见 shared-api-allowlist 测试约定)
|
||||
const loginBody = await loginWithDevice(account, password.value, deviceId)
|
||||
if (!loginBody || loginBody.success !== true || !loginBody.data) {
|
||||
errorMessage.value = (loginBody && (loginBody.message || loginBody.msg)) || '登录失败'
|
||||
return
|
||||
@@ -174,20 +164,10 @@ async function submitLogin() {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
// 把 Java 签发的 JWT 同步到 Python 同源 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) */
|
||||
}
|
||||
// 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorage(JWT 走 Bearer),无 cookie 依赖
|
||||
clearAppPermissionCaches()
|
||||
// 桌面端走 Flask /home;Web 独立部署(路径含 /new_web_source)与 dev 走 resolvePageHref 归一
|
||||
window.location.href = isDesktopClient() ? '/home' : resolvePageHref('/new_web_source/home.html')
|
||||
// SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router)
|
||||
await router.replace('/home')
|
||||
} catch {
|
||||
errorMessage.value = '网络异常,请稍后重试'
|
||||
} finally {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 数富AI 桌面前端 SPA 路由(无 .html 后缀)
|
||||
*
|
||||
* 原 MPA(22 个 html 入口 + 22 个 *-main.ts)合并为单入口 index.html;
|
||||
* 页面组件懒加载(每个工具页独立 chunk),URL 统一为 /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
|
||||
file_paths?: string[]
|
||||
created_at?: string
|
||||
/** 最后更新(终态时即完成时间,作为结束时间兜底) */
|
||||
updated_at?: string
|
||||
progress_total?: number
|
||||
progress_current?: number
|
||||
error_message?: string
|
||||
|
||||
@@ -12,6 +12,16 @@ export interface ConvertResultItem {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
downloadUrl?: string;
|
||||
/** 所属任务ID(biz_file_task.id) */
|
||||
taskId?: number;
|
||||
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
|
||||
taskStatus?: string;
|
||||
/** 任务创建时间 */
|
||||
createdAt?: string;
|
||||
/** 任务开始时间 */
|
||||
startedAt?: string;
|
||||
/** 任务结束时间(未结束为空) */
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface ConvertRunVo {
|
||||
|
||||
@@ -12,6 +12,16 @@ export interface DedupeResultItem {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
downloadUrl?: string;
|
||||
/** 所属任务ID(biz_file_task.id) */
|
||||
taskId?: number;
|
||||
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
|
||||
taskStatus?: string;
|
||||
/** 任务创建时间 */
|
||||
createdAt?: string;
|
||||
/** 任务开始时间 */
|
||||
startedAt?: string;
|
||||
/** 任务结束时间(未结束为空) */
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface DedupeRunVo {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user